Skip to content

ERA-11057: track styles: track color expression #1243

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Mar 6, 2025
Merged

Conversation

gaboDev
Copy link
Contributor

@gaboDev gaboDev commented Feb 27, 2025

What does this PR do?

  • Adds time-of-day coloring expression to track points
  • Adds batch support to useMapSource and useMapLayer hooks

How does it look

image

Relevant link(s)

@gaboDev gaboDev added the deploy label Mar 3, 2025
@gaboDev gaboDev requested review from luixlive and JoshuaVulcan and removed request for luixlive March 3, 2025 14:23
@gaboDev gaboDev requested review from AlanCalvillo and luixlive March 3, 2025 14:23
@gaboDev gaboDev marked this pull request as ready for review March 3, 2025 14:24
@gaboDev gaboDev changed the title ERA-11057 ERA-11057: track styles: track color expression Mar 3, 2025
return isTimeOfDayColoringActive
? {
...trimmedTrackData,
twoPointLineStringTrackPoints: buildFeatureCollectionOfTwoPointLineStringSegments(trimmedTrackData.track, timeOfDayTimeZone)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to split all track point by segments formed by a line string object of 2 points having: the coors and times of point A and B, also assigning a startColor and endColor based on the time of each point

This object will allow us to set an specific gradient color to each line, being a workaround of the issue of MapBox not being able to apply dynamically data-drive stop colors for a gradient line

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trackSegments would suffice as a name here. that's really all it is.

sourcesConfigs,
layersConfigs,
hasTimeOfDaySegments
} = useMemo(() => generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, two things are happening internally:

  1. We grab the two-line segments calculated on the selector and create groups of the all possible combination of pairs of color stops (64), this with the purposes of reduce the amount of sources/layers created
  2. For each pair, We create layer/source config objects to be passed to the map

@gaboDev
Copy link
Contributor Author

gaboDev commented Mar 3, 2025

Most of the changes of this PR are related with the refactor of useMapSource and useMapLayer, since the approach for the coloring expression required some grouping it was necessary to adapt those hooks to be use with batches of object configs.

The actual approach can be seen in these files:

  1. src/selectors/tracks/index.js
  2. src/TracksLayer/track.js
  3. src/TracksLayer/utils/index.js

Copy link
Contributor

@luixlive luixlive left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a first pass. The intention is clear. I haven't tested the code yet, we would need some nice data in the environment. Also I'll wait for the missing tests you mentioned. But for now I have several small suggestions to try to make this piece of code as readable as possible since it's a very specific feature.

id,
source: sourceId,
type,
layout: layout || {},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are already defining layout as layout = {} so adding a default here again is redundant.

return isTimeOfDayColoringActive
? {
...trimmedTrackData,
twoPointLineStringTrackPoints: buildFeatureCollectionOfTwoPointLineStringSegments(trimmedTrackData.track, timeOfDayTimeZone)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the reasoning behind this naming twoPointLineStringTrackPoints (for both the property and the method) right now, but it worries me that it will not be intuitive when we come back two months later and read this code. It feels a bit obscure. Could you think of a name a bit more explicit about its relation with the time of day feature? buildTimeOfDayFeatureCollection maybe. And of course, add comments inside the method explaining that the feature collection consists of several line strings connecting each two coordinates.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luixlive @gaboDev I would more recommend a very accurate-and-generic name. There's nothing time-of-day specific here, and as we increase style customization it might be pretty desirable and recyclable to have these segmented tracks. I like something simple and clean like buildTrackSegments(track) IMHO.

Copy link
Contributor

@luixlive luixlive Mar 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it sounds like we want to define "segment" like a feature collection of line strings joining pairs of points. I'm ok with that, it would be nice to document it somewhere, even just as a comment in the method so its clear what we mean with that.

But I differ about something else. You say @JoshuaVulcan that there is nothing related to the time of day feature, but there is: the start color and end color properties added to these "segments":
image

That is part of the method and it is specifically related to that feature. That's why I was thinking on a name that links this method with the feature. If we want to put a generic name to it I would recommend to not add those properties there, add them separately. But I don't know if it's really worth it and if we are going to reuse this method later. It sounds like we can leave it as is for now and then refactor if it's necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I agree with trackSegments for the prop and buildTrackSegments for the method

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just consider the fact that it has logic related to the time of day period feature. trackSegments sounds very generic and doesn't tell what is really going on. We could remove that logic from there to keep it generic, or make sure that the name expresses the intent.

Copy link
Collaborator

@JoshuaVulcan JoshuaVulcan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty light-touch first pass, mostly concerned with supplementing existing comments and looking at code style/naming conventions. It's close!

const map = useContext(MapContext);
const sourceIdsRef = useRef([]);
const sourcesConfigs = useMemo(() => Array.isArray(sourceConfig) ? sourceConfig : [sourceConfig], [sourceConfig]);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just have a plural name (such as useMapSources) and have it always accept an array of objects? That would be clearer and simpler than accepting these two formats for sourceConfig, and take the mystery/magic out of naming it useMapSource but secretly allowing more than one source at a time in the hook

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it!

jest.useRealTimers();
});

});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing tests for multi-source scenarios

import { getTimeOfDayPeriodBasedOnTime, trimTrackDataToTimeRange } from '../../utils/tracks';
import {
trimTrackDataToTimeRange,
buildFeatureCollectionOfTwoPointLineStringSegments
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Luis already mentioned, but buildFeatureCollectionOfTwoPointLineStringSegments is a pretty stanky and confusing name

return isTimeOfDayColoringActive
? {
...trimmedTrackData,
twoPointLineStringTrackPoints: buildFeatureCollectionOfTwoPointLineStringSegments(trimmedTrackData.track, timeOfDayTimeZone)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trackSegments would suffice as a name here. that's really all it is.

return isTimeOfDayColoringActive
? {
...trimmedTrackData,
twoPointLineStringTrackPoints: buildFeatureCollectionOfTwoPointLineStringSegments(trimmedTrackData.track, timeOfDayTimeZone)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luixlive @gaboDev I would more recommend a very accurate-and-generic name. There's nothing time-of-day specific here, and as we increase style customization it might be pretty desirable and recyclable to have these segmented tracks. I like something simple and clean like buildTrackSegments(track) IMHO.

trackTotalMinutesInTZ >= timeOfDayPeriod.rangeMinutesMin && trackTotalMinutesInTZ <= timeOfDayPeriod.rangeMinutesMax
);

return period ?? TIME_OF_DAY_PERIODS[0];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return period ?? TIME_OF_DAY_PERIODS[0];

A default here means we're straight-up presenting potentially incorrect information about the time of day for a segment....that doesn't feel particularly safe to me

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That one came from a test, not sure where, anyways I removed it and did several test to see if any time got no period object and indeed, there were some of them not getting set that property, so I added a validation and works great now!

});
}

return {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file already imports the featureCollection utility fn from turf, why not just return featureCollection(segments) ? Seems cleaner to me.

}
};
})
return isTimeOfDayColoringActive
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One extra comment. I just realized while working on another ticket that you added this conditional but never actually tested it. This file already has unit tests, we are just missing one test to make sure that the time of day segments are added or not based on isTimeOfDayColoringActive. Let's add it please 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Copy link
Contributor

@luixlive luixlive left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking this PR, I think @JoshuaVulcan has a better understanding on the reasoning behind and if he likes the approach I think it's ok too. Still, added some more comments on very minor things that I think could improve performance and code readability. Nice work, this ticket was a tricky one!

);
};

const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still have the concern about having two configurations: the individual layer configuration and the default configuration. I feel it counter-intuitive. I'd just leave the layer configurations and the implementation can do the job of injecting common configs just before providing them to this hook. What do you think? 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaboDev this is a good point -- while I think it is generally useful to give a "default" config plus overrides per-layer, the interfaces by which you can do this are pretty unclear and not easy to reason through. Can you improve code legibility to better-document and more plainly state what the configuration objects accept and which takes precedence?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the end, calculating layers for segment pairs was the only place where the default config param was being used, so it was just matter of inject that global options to each layer (which was kind of already happened) so global default configs for a batch of layers was not really necessary, I think the implementator should be in charge of provide a proper layer config object per batch so We left out of the hook this responsibility

? layerConfig.options.condition
: true;

const shouldUpdateMapLayer = (layerConfig, map) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return isn't needed and the logical expression has some unnecessary !! coercions since you are already wrapping it with one:

const shouldUpdateMapLayer = (layerConfig, map) => !!(layerConfig?.id && hasLayerCondition(layerConfig) && map.getLayer(layerConfig.id))

import { MapContext } from '../../App';
import { MAX_ZOOM, MIN_ZOOM } from '../../constants';

const hasLayerCondition = (layerConfig) => layerConfig?.options?.hasOwnProperty('condition')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this method implies that it will return true if the layer has a condition property (independent of its value). Maybe assertLayerCondition? And here we sill have the issue about the condition being null.

&& layerConfig?.type
&& layerConfig?.sourceId
&& map.getSource(layerConfig.sourceId)
&& layerConfig?.options?.condition !== false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, now I'm confused, why aren't we using the new method hasLayerCondition. I like the direct comparison to false, but then why sometimes we use the method and sometimes we don't?
Also, why aren't we using shouldUpdateMapLayer here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldUpdateMapLayer implies that the layer should exist which is not required here, and regarding condition, I forgot to use the method here 😄

filter,
before
} = {}
} = layerConfig;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I feel like extracting these variables from the object just adds a bunch of unnecessary lines to the file.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least it describes the shape of the expected configuration, which would otherwise only be intuited by reading the whole function thoroughly. 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The add layer effect is way more cleaner now, this destructuring was removed from it

}

// Handle line-gradient and line-color conflict
if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things:
1- I see a console warn here, is this information that important to be printed? Will it become just noise?
2- Is this hook's responsibility to handle this "conflict"? I feel like the implementator should be careful to not add both styles if the conflict with each other, instead of deleting a random property here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO this should just be removed entirely. Resolutions in style conflicts should be fixed upstream by the implementor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed!

&& before
&& map.getLayer(layerConfig.id)
){
map.moveLayer(layerConfig.id, before);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we check if we really need to call this method? I mean, this useEffect will be called with any change in the config objects and for every single layer with a before property it will call the moveLayer. But, do we need to? Maybe they are already well located in the layer stack right? I don't know how much this could impact performance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice observation I can take a look to it

layerConfigsBatch.forEach(layerConfig => {
if ( shouldUpdateMapLayer(layerConfig, map) ) {
const { options: { minZoom, maxZoom } = {} } = layerConfig;
map.setLayerZoomRange(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing, maybe the zoom is already in the right level, but we are not checking that. We are just iterating and calling the setLayerZoomRange for every single layer when there is a single change in the config objects. Considering that now we are supporting batches that could have dozens of layers, this may have some performance impact.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed both effect since where practically just increasing performance with no specific purpose

}, [map, layerConfigsBatch, defaultConfig]);

useEffect(() => {
const refs = layerIdsRef.current;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still confused on why we redefine a ref into a more generic name ref

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a linter warn about accessing a ref in a unmount callback, so just to get ride of it I declared this outside that callback, I can renaming the variable or add a disable-lint line

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't disable the linter for that line, it exists for a reason 😅

sourceConfigsBatch.forEach(sourceConfig => {
const source = map?.getSource?.(sourceConfig?.id);
if (sourceConfig?.id && sourceConfig?.data && source){
const timeout = window.setTimeout(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump to my original question, why do we use timeouts here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed already, sorry missed that in the last iteration

Copy link
Contributor

@luixlive luixlive left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving just for unblocking, but please read my latest suggestions, and I'd also recommend to wait for a second pass from @JoshuaVulcan since he has more insight on the reasoning behind this approach.

Copy link
Collaborator

@JoshuaVulcan JoshuaVulcan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaboDev there's some good feedback from @luixlive to address -- especially open questions around the efficiency of iterating long lists to support small changes on individual members of those lists.

Additionally, looks like there's some funk in the processing/rendering of the tracks themselves; namely, if I reduce my track length to a temporal period which should have 0 track points, some time-of-day track data remains drawn on the map. See here

time-of-day-segments-not-cleaning-up

}

// Handle line-gradient and line-color conflict
if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO this should just be removed entirely. Resolutions in style conflicts should be fixed upstream by the implementor.

);
};

const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaboDev this is a good point -- while I think it is generally useful to give a "default" config plus overrides per-layer, the interfaces by which you can do this are pretty unclear and not easy to reason through. Can you improve code legibility to better-document and more plainly state what the configuration objects accept and which takes precedence?

filter,
before
} = {}
} = layerConfig;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least it describes the shape of the expected configuration, which would otherwise only be intuited by reading the whole function thoroughly. 🤔

}, [map, layerConfigsBatch, defaultConfig]);

useEffect(() => {
const refs = layerIdsRef.current;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't disable the linter for that line, it exists for a reason 😅

@gaboDev gaboDev requested a review from JoshuaVulcan March 6, 2025 16:49
@gaboDev
Copy link
Contributor Author

gaboDev commented Mar 6, 2025

Additionally, looks like there's some funk in the processing/rendering of the tracks themselves; namely, if I reduce my track length to a temporal period which should have 0 track points, some time-of-day track data remains drawn on the map. See here

time-of-day-segments-not-cleaning-up

This bug was related with old layers refs being still in the map while not being present in the layer config batch, I just add some logic to remove those layers when necessary

@JoshuaVulcan

Copy link
Collaborator

@JoshuaVulcan JoshuaVulcan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaboDev approved to not block, but we have an ongoing Slack conversation on the value of maintaining both the full track line + two-point segments, vs just holding the two-point segments in memory.

@JoshuaVulcan JoshuaVulcan merged commit cda7184 into develop Mar 6, 2025
3 checks passed
@JoshuaVulcan JoshuaVulcan deleted the ERA-11057 branch March 6, 2025 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants