Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions backend/maps/services/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from ..constants import LAYER_INVENTAIRE_FOR, LAYER_INVENTAIRE_BIO, LAYER_ENQUETE

########## FILTERS ENTRYPOINT ##########

def get_all4trees_filters(user_map):
# Retrieve all layers data
layer_inventaire_for = get_layer(user_map, LAYER_INVENTAIRE_FOR)
layer_inventaire_bio = get_layer(user_map, LAYER_INVENTAIRE_BIO)
layer_enquete = get_layer(user_map, LAYER_ENQUETE)

# Retrieve the data points properties
layer_data_inventaire_for = get_layer_data_properties(layer_inventaire_for)
layer_data_inventaire_bio = get_layer_data_properties(layer_inventaire_bio)
layer_data_enquete = get_layer_data_properties(layer_enquete)

# Compute filters values
project_values = get_project_values(
layer_data_inventaire_for=layer_data_inventaire_for,
layer_data_inventaire_bio=layer_data_inventaire_bio,
)
loc1_values = get_loc1_values(
layer_data_inventaire_for=layer_data_inventaire_for,
layer_data_inventaire_bio=layer_data_inventaire_bio,
)

return {
"project": project_values,
"loc1": loc1_values,
}

def get_layer(user_map, layer_id):
return user_map.handle_request(method='POST',path=layer_id, filters={})

def get_layer_data_properties(layer)-> list(dict):
features = layer["features"]
return [feat["properties"] for feat in features]

########## FILTERS PER PROPERTY ##########

def get_project_values(
layer_data_inventaire_for,
layer_data_inventaire_bio,
):
"""Retrieve projects property on layers where this field exist"""
return {
LAYER_INVENTAIRE_FOR: {
"property_name": "project",
"values": list(set([item["project"] for item in layer_data_inventaire_for])),
},
LAYER_INVENTAIRE_BIO: {
"property_name": "project",
"values": list(set([item["project"] for item in layer_data_inventaire_bio])),
},
}

def get_loc1_values(
layer_data_inventaire_for,
layer_data_inventaire_bio,
):
"""Retrieve loc1 property on layers where this field exist"""
return {
LAYER_INVENTAIRE_FOR: {
"property_name": "loc1",
"values": list(set([item["loc1"] for item in layer_data_inventaire_for])),
},
LAYER_INVENTAIRE_BIO: {
"property_name": "loc1",
"values": list(set([item["loc1"] for item in layer_data_inventaire_bio])),
},
}
1 change: 1 addition & 0 deletions backend/maps/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
path("replace-data/", views.replace_data_view, name="maps-replace-data"),
path("add-fk/", views.add_foreign_key_view, name="maps-add-fk"),
path("remove-fk/", views.remove_foreign_key_view, name="maps-remove-fk"),
path("get-filters/", views.get_filters, name="maps-get-filters"),
re_path(r"^(?!dashboard)(?P<subpath>\w+)", views.my_map_view, name="maps-data"),
path("dashboard/<layer_id>", views.dashboard_view, name="dashboard-data"),
]
10 changes: 10 additions & 0 deletions backend/maps/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .constants import LAYER_INVENTAIRE_FOR
from .datapackage_manager import DatapackageManager
from .services.user_map import get_user_map
from .services.filters import get_all4trees_filters

@api_view(['GET', 'POST'])
@authentication_classes([JWTAuthentication])
Expand Down Expand Up @@ -38,6 +39,15 @@ def dashboard_view(request, layer_id):
"error": f'Layer "{layer_id}" not yet supported'
}, status=status.HTTP_501_NOT_IMPLEMENTED)

@api_view(['GET'])
@authentication_classes([JWTAuthentication])
def get_filters(request):
"""
Return filters values to be used frontend side for coordo filtering
"""
user_map = get_user_map(request.user)
result = get_all4trees_filters(user_map)
return JsonResponse(result)

@api_view(["POST"])
@permission_classes([IsAuthenticated])
Expand Down
6 changes: 6 additions & 0 deletions webapp/src/app/providers/map-provider-all4trees.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type ReactNode, useCallback, useRef, useState } from "react";
import { useAuth } from "@features/auth";
import { useCategoriesFilters } from "@features/categories-filters/use-categories-filters";
import { renderAnchor, renderLayerRow } from "@features/controls/layer-control";
import { syncInitialLayerFilters } from "@features/map-filters/apply-layer-filter";

import { API_URL } from "@shared/api/client";
import { MapContext } from "@shared/contexts/map-context-all4trees";
Expand Down Expand Up @@ -56,6 +57,11 @@ export function MapProviderAll4Trees({ children }: MapProviderAll4TreesProps) {
hideLayer: mapApiRef.current?.hideLayer,
showLayer: mapApiRef.current?.showLayer,
});

const map = mapApiRef.current?.mapInstance;
if (map) {
syncInitialLayerFilters({ map });
}
};

node.addEventListener(EVENTS.MAP_READY, handleReady);
Expand Down
14 changes: 12 additions & 2 deletions webapp/src/features/external-data/getter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ExternalData } from "@entities/data";
import { EXTERNAL_RESOURCES_BY_LAYER } from "@entities/resources";
import type { ExternalData, LabelData } from "@entities/data";
import { EXTERNAL_RESOURCES_BY_LAYER, LABEL_DATA } from "@entities/resources";

import type { ApiClient } from "@shared/api/client";

Expand All @@ -21,3 +21,13 @@ export const getExternalDataPromiseByLayer = (
? () => client.getCatalogResourceList(layerId, resourceList)
: () => Promise.resolve(EMPTY_EXTERNAL_DATA);
};

export const getLabelData = ({
externalData,
layerId,
}: {
externalData: ExternalData;
layerId: string;
}) => {
return externalData[LABEL_DATA.get(layerId) || ""] || ([] as LabelData[]);
};
2 changes: 1 addition & 1 deletion webapp/src/features/fallback/error-boundary-fallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { type FallbackProps, getErrorMessage } from "react-error-boundary";

import { ICON_SIZE_HEADER } from "@features/indicators/components/constants";

import type { APIError } from "@shared/api/types";
import { useTranslation } from "@shared/i18n";
import type { APIError } from "@shared/lib/types";
import { cn } from "@shared/lib/utils";
import { Alert, AlertTitle } from "@shared/ui/alert";

Expand Down
155 changes: 155 additions & 0 deletions webapp/src/features/map-filters/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# map-filters

Per-layer map filtering, applied client-side with MapLibre's
[`map.setFilter()`](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#setfilter)
and persisted in localStorage.

## Files

| File | Role |
| --- | --- |
| `types.ts` | Persisted state shape. `FILTER_KINDS` + the `LayerFilter` union. |
| `storage.ts` | localStorage key per layer, `FILTERABLE_LAYERS`, non-React reader. |
| `apply-layer-filter.ts` | Expression building and the actual `setFilter` call. |
| `use-layer-filters.ts` | React binding: persisted state + checkbox props + push to map. |
| `layers/*.tsx` | One panel per layer, deciding which groups it shows. |
| `components/` | Presentational widgets (`CheckboxGroup`). |

## How it fits together

```text
layers/forest-inventory.tsx declares its FilterGroups (key + propertyName + values)
└─ useLayerFilters(layerId) reads/writes localStorage, hands back checkbox props
└─ applyLayerFilter() builds ["all", …] and calls map.setFilter(layerId, …)
```

On reload, `syncInitialLayerFilters({ map })` replays the persisted state. It runs
from `map-provider-all4trees.tsx` on `MAP_READY`, **not** from the panel — the
sidebar keeps the panel in a hidden `<Activity>` until its tab is opened, so the
panel's effects would not have run yet.

## State shape

```ts
// localStorage["d4g:map-filters:inventaire_for"]
{
"project": { kind: "values", propertyName: "proj", values: ["A", "B"] },
"loc1": { kind: "values", propertyName: "loc1", values: [3, 7] }
}
```

Three rules make this work without seeding defaults:

- **Group absent** → no restriction. A fresh visitor has `{}` and sees everything;
a value the backend starts serving shows up unfiltered.
- **Group present, empty `values`** → deliberate "nothing selected", hides everything.
- `propertyName` is stored with the selection so the reload sync can rebuild the
expression without waiting for the `getFilters()` payload.

Selections are stored as the API's own values (numbers stay numbers). Checkbox
identifiers are strings, so the hook maps between them with `String(value)`.

## Semantics

- **Within a group → OR.** `["in", ["get", prop], ["literal", [...]]]`, so several
checked boxes need no special handling.
- **Between groups → AND.** `["all", clauseA, clauseB]`.
- **On top of the layer's own filter.** MapLibre keeps a *single* filter slot per
layer, and coordo puts `["!", ["has", "point_count"]]` on layers bound to a
clustered source. `applyLayerFilter` snapshots that base filter on first touch
and always re-ANDs it, so it is never clobbered.

> **Never call `map.setFilter()` for a filterable layer from anywhere else.** The
> single filter slot means the last caller wins and silently drops every other
> clause. Cross-cutting filters (a global date, say) must contribute their
> clauses through `applyLayerFilter`.

## Recipes

### Add a group to an existing layer

In the layer's panel, build a `FilterGroup` and spread the hook's props:

```tsx
const ecosGroup: FilterGroup = {
key: "ecos",
propertyName: ecos.property_name, // from the getFilters() payload
values: ecos.values,
};

<CheckboxGroup
items={ecos.values.map((value) => ({ identifier: String(value), label: String(value) }))}
title="Ecosystem"
{...getCheckboxGroupProps(ecosGroup)}
/>
```

Nothing else to wire: the group key becomes its localStorage key, and the effect
in `useLayerFilters` pushes the change to the map.

### Add a filterable layer

1. Create `layers/<layer>.tsx` and render it from `map-filters.tsx`.
2. Add the layer id to `FILTERABLE_LAYERS` in `storage.ts` — otherwise its filters
apply while the panel is open but are **not** replayed on reload.

### Add a filter kind

`RANGE` is the worked example — nothing renders it yet, but it is implemented
end to end and is the template to copy.

1. **`types.ts`** — add the kind to `FILTER_KINDS` and its variant to the
`LayerFilter` union.
2. **`apply-layer-filter.ts`** — add a `case` to `buildFilterClause`. Adding the
union member first makes the missing case a compile error.
3. **`use-layer-filters.ts`** — the current hook only knows how to *edit* a
`VALUES` filter (checkbox toggles). A new kind needs its own writer, e.g. a
`getRangeProps(group)` returning `{ value, onChange }` for a slider or date
picker. The reading side (the effect, the storage key) is kind-agnostic and
needs no change.
4. **`components/`** — add the widget.

Returning `null` from `buildFilterClause` means "restricts nothing", which is
also how a state written by an older build (unknown `kind`) degrades.

### Dates and other comparisons

MapLibre expressions have no date type. Store the date on the feature as an
ISO-8601 string (they sort lexicographically, so `>=` / `<=` work as-is) or as an
epoch number, then use a `RANGE` filter:

```ts
{ kind: FILTER_KINDS.RANGE, max: "2025-12-31", min: "2025-01-01", propertyName: "survey_date" }
```

MapLibre compares strictly and **evaluates to `false` on a type mismatch instead
of erroring** — a filter that silently matches nothing is nearly always a bound
whose type differs from the feature property's.

## Known limitation: cluster counts

`setFilter` is a *render-time* filter, while clustering happens at the **source**
level. On a clustered layer (`LAYERS_WITH_CLUSTERS`) the individual points filter
correctly, but the cluster bubbles keep counting filtered-out features.

There is no "recount" API: supercluster indexes whatever is in the source, so the
counts only move if the source data moves. `setClusterOptions()` only toggles
clustering on/off. Options considered, none implemented yet:

- **Re-cluster client-side.** Cache the original FeatureCollection once with
`GeoJSONSource.getData()`, filter it in JS — `featureFilter()` from
`@maplibre/maplibre-gl-style-spec` compiles the very expression
`buildFilterClause` already returns, so there is no second implementation to
keep in sync — then `source.setData(subset)`. Exact counts, frontend-only,
works for every kind. Costs: whole dataset in memory, a re-index per change
(debounce), async-ordering guards, and it conflicts with `setLayerFilters` on
the same layer.
- **Refetch from the backend** via coordo's `setLayerFilters`, which POSTs a
filter payload and replaces the source data. No client memory cost, but the
backend has to implement the `{op, args}` DSL for these properties and it is a
round-trip per change.
- **Hide the cluster layers** while a filter is active. Cheap and never wrong,
but loses the summary and renders every point at low zoom.

Source `clusterProperties` was ruled out: it can only aggregate a category set
known at style-build time, so it cannot serve user-chosen values or ranges.
Loading