-
Notifications
You must be signed in to change notification settings - Fork 13
Issue 889: Add Observation Only Zones #904
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
base: main
Are you sure you want to change the base?
Issue 889: Add Observation Only Zones #904
Conversation
| </View> | ||
| )} | ||
| {mapLayer && ( | ||
| // TODO: Render in two groups with respective labels: <Center_ID> Forecast Zones & Other Regions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rustynwac let's pin down the UX we want for the two sets of zones in the filter modal with an eye to what the websites are doing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rustynwac @stevekuznetsov did we decide how we want to represent the zones?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rustynwac let's sync on this
| const [filterModalVisible, {set: setFilterModalVisible, on: showFilterModal}] = useToggle(false); | ||
| const {data: metaData} = useAvalancheCenterMetadata(center_id); | ||
| const alternateZonesUrl = metaData?.widget_config?.observation_viewer?.alternate_zones || ''; | ||
| const {data: alternateZones} = useAlternateObservationZones(alternateZonesUrl, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please keep the result pattern and add this to the incompleteQueryState block.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do - that incompleteQueryState block is pretty sweet!
| ); | ||
|
|
||
| if (incompleteQueryState(observationsResult, mapResult) || !mapLayer) { | ||
| if (incompleteQueryState(observationsResult, mapResult) || !filteredMapLayer) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The check for !mapLayer is to guard against that request returning nothing - let's check here for the responses, not computed values from the responses, e.g. || !mapLayer || !centerMetadata || !alternateZones
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense - thanks
| coordinates: string; | ||
| } | ||
|
|
||
| interface KMLPlacemark { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please write a zod schema (or generate one from the interface here, or a known good KML library like togeojson) so we can parse and validate our incoming data.
|
|
||
| export const useAlternateObservationZones = (url?: string, options?: UseAlternateObservationZonesOptions): UseQueryResult<AlternateObservationZones[] | null, AxiosError> => { | ||
| const {logger} = useContext<LoggerProps>(LoggerContext); | ||
| const key = ['alternateObservationZone-', url]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we please mirror the pattern in other hooks to allow for pre-fetching?
|
|
||
| const alternateZones: AlternateObservationZones[] = kmlData.kml.Document.Folder.Placemark.map(placemark => { | ||
| let coordinates: [number, number][] = []; | ||
| const parseCoordinates = (coordinateString: string): [number, number][] => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good candidate for a set of unit tests!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevekuznetsov are you speaking specifically about the coordinate parsing function or the broader KML parsing functionality?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any of the logic here which takes static data as input and spits out other static data in some other format!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevekuznetsov I added a few unit tests for this. It looks like a recent package change may have caused a version conflict for "strip-ansi", causing the Jest tests to fail with "TypeError: stripAnsi is not a function". It looks like a dependency of "xml-js" may have changed the "strip-ansi" version.
I'm curious about the trade offs of breaking out smaller functions like parseCoordinates for testing. On the one hand this exposes them for individual unit test, but on the other they no longer have access to objects in the parent function (like logger).
| ); | ||
| return { | ||
| ...mapLayer, | ||
| features: [...mapLayer.features, ...zonesNotInMapLayer], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When we display observations to the user in card form, are we grabbing the first item from this list that matches for labelling? I think we should try to be explicit - do users expect to see an observation matching a custom zone and a forecast zone labelled with the former or the latter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevekuznetsov, sorry, I'm not entirely sure what you mean here. Are you saying that we want to make sure that if there are multiple matches, we want to be explicit about prioritizing the forecast zone?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm actually not sure what avalanche center staff would expect in this situation - I've reached out to the NAC and I'll follow up here once I hear back. However, regardless of which path is taken, I was hoping we could have explicit logic (observation, forecast_zones, custom_zones) => label that would encode the choice, as opposed to having implicit logic (return the first match from a list containing both types of zones that might or might not be sorted in a specific order).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got some clarity from the NAC, we should prioritize the custom zones if present.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for getting clarification on that!
| <ObservationsFilterForm | ||
| requestedTime={requestedTime} | ||
| mapLayer={mapLayer} | ||
| mapLayer={filteredMapLayer} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevekuznetsov I initially had a type for alternate observation zones, however, this creates type issues when those zones are merged with other zones from the map layer. Would there be future utility for adding the alternate zones to the actual map layer, or should I change the type expected for mapLayer here?
| </View> | ||
| )} | ||
| {mapLayer && ( | ||
| // TODO: Render in two groups with respective labels: <Center_ID> Forecast Zones & Other Regions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rustynwac @stevekuznetsov did we decide how we want to represent the zones?
|
|
||
| const alternateZones: AlternateObservationZones[] = kmlData.kml.Document.Folder.Placemark.map(placemark => { | ||
| let coordinates: [number, number][] = []; | ||
| const parseCoordinates = (coordinateString: string): [number, number][] => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevekuznetsov I added a few unit tests for this. It looks like a recent package change may have caused a version conflict for "strip-ansi", causing the Jest tests to fail with "TypeError: stripAnsi is not a function". It looks like a dependency of "xml-js" may have changed the "strip-ansi" version.
I'm curious about the trade offs of breaking out smaller functions like parseCoordinates for testing. On the one hand this exposes them for individual unit test, but on the other they no longer have access to objects in the parent function (like logger).
| </View> | ||
| )} | ||
| {mapLayer && ( | ||
| // TODO: Render in two groups with respective labels: <Center_ID> Forecast Zones & Other Regions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rustynwac let's sync on this
| const [filterConfig, setFilterConfig] = useState<ObservationFilterConfig>(originalFilterConfig); | ||
| const [filterModalVisible, {set: setFilterModalVisible, on: showFilterModal, off: hideFilterModal}] = useToggle(false); | ||
| const avalancheZoneMetadata = useAvalancheCenterMetadata(center_id); | ||
| const alternateZonesUrl: string = (avalancheZoneMetadata.data?.widget_config?.observation_viewer?.alternate_zones as string) || ''; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
alternate_zones should be string | null today - no need for as string assertion, since we need to handle null (or, you coerce with || '' to empty string)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, good point. Thanks!
| const originalFilterConfig: ObservationFilterConfig = useMemo(() => createDefaultFilterConfig(additionalFilters), [additionalFilters]); | ||
| const [filterConfig, setFilterConfig] = useState<ObservationFilterConfig>(originalFilterConfig); | ||
| const [filterModalVisible, {set: setFilterModalVisible, on: showFilterModal, off: hideFilterModal}] = useToggle(false); | ||
| const avalancheZoneMetadata = useAvalancheCenterMetadata(center_id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super-duper nit: we prefer to suffix with Result for the tanstack-query result object (applies here and to the useAlternateObservationZones return)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, that is a smart convention. Added it!
| const [filterModalVisible, {set: setFilterModalVisible, on: showFilterModal, off: hideFilterModal}] = useToggle(false); | ||
| const avalancheZoneMetadata = useAvalancheCenterMetadata(center_id); | ||
| const alternateZonesUrl: string = (avalancheZoneMetadata.data?.widget_config?.observation_viewer?.alternate_zones as string) || ''; | ||
| const {data: alternateObservationZones} = useAlternateObservationZones(alternateZonesUrl); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't spread out data, we use the full result for e.g. the NotFound page
|
|
||
| const mergedMapLayer = useMemo(() => { | ||
| if (alternateObservationZones && mapLayer) { | ||
| const zonesNotInMapLayer: AlternateObservationZones = alternateObservationZones.filter( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TypeScript compiler seems unhappy here - are we correctly typing the alternateObservationZones as a FeatureCollection only?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gosh, I went around and around on this; in the end, the only way I was able to prevent the mergedMapLayer from throwing a bunch of type errors, was to create a new type MergedMapLayer, that supports a union z.union([mapLayerFeatureSchema, observationZonesFeatureSchema]);. Totally open to other solutions if I'm missing something!
| }; | ||
|
|
||
| function queryKey(url: string) { | ||
| return ['alternateZoneKML-', url]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| return ['alternateZoneKML-', url]; | |
| return ['alternateZoneKML', {url: url}]; |
|
|
||
| export function parseKmlData(response: string): AlternateObservationZones { | ||
| const kmlDataJson = xml2json(response, {compact: true, spaces: 2}); | ||
| const kmlData = JSON.parse(kmlDataJson) as KMLData; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What we're doing in the rest of the codebase is using Zod to make sure that JSON.parse() spits out an object with the fields we're actually expecting. I think you already have a schema for KMLData, so we can zod.safeParse() the input and catch errors here instead of failing at runtime when we incorrectly access a field that we expected to exist but is missing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a really cool capability, thanks for pointing me towards it; I added it here and in my transform function. One approach at getting the feature collections to merge involved populating the feature properties with default values to make them compatible with the mapLayer. For this the safeParse was super helpful!
| const feature: AlternateObservationZones = { | ||
| type: 'Feature', | ||
| geometry: { | ||
| type: Array.isArray(coordinates[0][0]) ? 'MultiPolygon' : 'Polygon', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might be looking at an older version of this - did you decide if it would work to collapse this to MultiPolygon in every case?
| }); | ||
| export type KMLData = z.infer<typeof KMLDataSchema>; | ||
|
|
||
| const AlternateObservationZonesSchema = z.union([ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooops, sorry - finished the review before realizing you had not yet updated the code 🤦
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, shoot - I should have let you know I was still grappling with the type issues. I've picked up the comments above, and committed all of my changes!
|
|
||
| const mergedMapLayer = useMemo((): MergedMapLayer => { | ||
| if (!mapLayer || !observationOnlyZones || !observationOnlyZones.features || observationOnlyZones.features.length === 0) { | ||
| return mapLayer as MergedMapLayer; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as is 99.999999% of the time a signal that something is wrong - either the types as written or some logic around them. Since TypeScript uses structural typing, as long as the shape of mapLayer matches MergedMapLayer this should pass without any assertion necessary
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(so, we should try to never use as and see what needs to be done to remove the uses that we have)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks - I'll file that away as something to look out for in the future!
| queryKey: key, | ||
| queryFn: (): Promise<ObservationZonesFeatureCollection> => fetchAlternateObservationZones(thisLogger, url, center_id), | ||
| enabled: !!url, | ||
| cacheTime: 60, // TODO: Change cache to one day after testing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couple TODOs left over?
| const kmlFile = KMLFileSchema.safeParse(JSON.parse(kmlResponse)); | ||
|
|
||
| if (!kmlFile.success) { | ||
| logger.error(`Invalid KML file: ${JSON.stringify(kmlFile.error.format())}`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Love it - so if this is not successful, you've got the code falling back to the zones from the NAC and failing gracefully. Great!
Can we also use Sentry.captureException to capture this (see other hooks for examples) to help us get notified if something is parsing wrong?
| type: 'Polygon', | ||
| coordinates: [coordinates], | ||
| }, | ||
| id: Math.floor(Math.random() * 9000000) + 1000000, //Todo: replace with a better id generation method |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the requirement for these IDs? Could we just use the iteration index from placemarks.map?
stevekuznetsov
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking awesome! Just a couple small comments and I think we're good to go!
|
There's something goofy going on with your |
d2748a1 to
f1575de
Compare
da73980 to
6694fab
Compare
6694fab to
ed223ca
Compare


Description
This feature incorporates observation only zones into Avy; applying observation zone names to the observation preview cards and adding these zones to the filter widget.
Details
Related Issue
Closes #889
Acceptance Criteria
Considerations
The map view on the MWAC and BTAC websites does not show the observation only zones, so I decided to decouple the mapLayer and the observation only zones to match this. Additionally, merging them into the mapLayer was breaking the map view.
In the ObservationsFilterForm, do we want to visually separate "{Center Id} Forecast Zones" and "Other Regions" similar to the website form or create collapsible sections? If there are too many zones that push other sections off screen, will those other sections go unnoticed or be difficult to navigate to?
As mentioned in "details", do we want to filter out zones that have no observations?
Currently "unknown zone" still flashing briefly when observation preview cards initially render.
As you can tell, Typescript is not my daily driver; I'm certainly open to any suggestions for how I can improve my typing!
Updates
Before
After
Testing Steps / QA Criteria
git pull wn-889-add-observation-zonesyarn start