Skip to content

Conversation

@wheninseattle
Copy link
Collaborator

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

  • Some centers (MWAC, BTAC, others?) have designated observation only zones outside of their official forecast zones. When available, a url to a KML file is provided in the avalanche center's meta data at: >> metaData.widget_config.observation_viewer.alternate_zones
  • This url is passed to the useAlternateObserationZones hook which fetches and parses the KML file, returning features similar to those in the mapLayer.
  • If provided, these alternate zones are merged with the features from the mapLayer prior to the flattening of the observations list, so that these zones labels are properly associated with the observations.
  • Optional This mergedMapLayer is subsequently filtered by the available observations, removing zones that don't contain any observations. I could see this being a positive to reduce clutter in the already full filter widget, but on the other hand it could be advantageous for users to see the full list of zones even if they don't have observations.
  • Finally, the resulting, filteredMapLayer is passed to the ObservationsFilterForm.

Related Issue

Closes #889

Acceptance Criteria

  • When available, add observation only zones to observation list view and filter
  • Observation preview cards, should be labeled with appropriate zone name, including observation only zones
  • When observation only zone is selected in the ObservationFilterForm, the list of observations should be correctly filtered for that zone.

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.

  • image
  • 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?

  • image
  • 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

  • image
  • image

After

  • image
  • image
  • image

Testing Steps / QA Criteria

  1. Use git pull wn-889-add-observation-zones
  2. yarn start
  3. Set avalanche center to BTAC or MWAC
  4. Check to ensure that observation preview cards are labeled with alternate zones when appropriate.
  5. Check to ensure that the ObservationsFilterForm includes additional, observation only zones

</View>
)}
{mapLayer && (
// TODO: Render in two groups with respective labels: <Center_ID> Forecast Zones & Other Regions
Copy link
Collaborator

@stevekuznetsov stevekuznetsov Feb 26, 2025

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.

Copy link
Collaborator Author

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?

Copy link
Collaborator

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, {
Copy link
Collaborator

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.

Copy link
Collaborator Author

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) {
Copy link
Collaborator

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

Copy link
Collaborator Author

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 {
Copy link
Collaborator

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];
Copy link
Collaborator

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][] => {
Copy link
Collaborator

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!

Copy link
Collaborator Author

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?

Copy link
Collaborator

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!

Copy link
Collaborator Author

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],
Copy link
Collaborator

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?

Copy link
Collaborator Author

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?

Copy link
Collaborator

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).

Copy link
Collaborator

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.

Copy link
Collaborator Author

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}
Copy link
Collaborator Author

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
Copy link
Collaborator Author

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][] => {
Copy link
Collaborator Author

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
Copy link
Collaborator

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) || '';
Copy link
Collaborator

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)

Copy link
Collaborator Author

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);
Copy link
Collaborator

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)

Copy link
Collaborator Author

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);
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 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(
Copy link
Collaborator

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?

Copy link
Collaborator Author

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];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
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;
Copy link
Collaborator

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.

Copy link
Collaborator Author

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',
Copy link
Collaborator

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([
Copy link
Collaborator

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 🤦

Copy link
Collaborator Author

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;
Copy link
Collaborator

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

Copy link
Collaborator

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)

Copy link
Collaborator Author

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
Copy link
Collaborator

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())}`);
Copy link
Collaborator

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
Copy link
Collaborator

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?

Copy link
Collaborator

@stevekuznetsov stevekuznetsov left a 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!

@stevekuznetsov
Copy link
Collaborator

There's something goofy going on with your package.json changes - did you intend to touch strip-ansi et al? If not, I would recommend resetting your changes to package.json and yarn.lock and running the minimal set of yarn add commands you need to pull in the KML bits, I think something you changed in yarn.lock (maybe unintentionally) is not acting nice with the CI.

@wheninseattle wheninseattle force-pushed the wn-889-add-observation-zones branch from d2748a1 to f1575de Compare April 17, 2025 07:25
@yuliadub
Copy link
Collaborator

yuliadub commented Aug 11, 2025

added the custom zone name to the individual obs view (for some reason maps are not loading for me, ignore that :D )

before
image

now
after

@yuliadub yuliadub force-pushed the wn-889-add-observation-zones branch from da73980 to 6694fab Compare August 24, 2025 04:52
@yuliadub yuliadub force-pushed the wn-889-add-observation-zones branch from 6694fab to ed223ca Compare September 5, 2025 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adding Observation Zones to MWAC

4 participants