diff --git a/backend/maps/services/filters.py b/backend/maps/services/filters.py new file mode 100644 index 00000000..60d48795 --- /dev/null +++ b/backend/maps/services/filters.py @@ -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])), + }, + } \ No newline at end of file diff --git a/backend/maps/urls.py b/backend/maps/urls.py index 9b47cb56..3d5cabd6 100644 --- a/backend/maps/urls.py +++ b/backend/maps/urls.py @@ -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\w+)", views.my_map_view, name="maps-data"), path("dashboard/", views.dashboard_view, name="dashboard-data"), ] diff --git a/backend/maps/views.py b/backend/maps/views.py index 351f9205..90e0f6ee 100644 --- a/backend/maps/views.py +++ b/backend/maps/views.py @@ -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]) @@ -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]) diff --git a/webapp/src/app/providers/map-provider-all4trees.tsx b/webapp/src/app/providers/map-provider-all4trees.tsx index b28f1db2..749c1890 100644 --- a/webapp/src/app/providers/map-provider-all4trees.tsx +++ b/webapp/src/app/providers/map-provider-all4trees.tsx @@ -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"; @@ -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); diff --git a/webapp/src/features/external-data/getter.ts b/webapp/src/features/external-data/getter.ts index ec2c4563..b5ad17a3 100644 --- a/webapp/src/features/external-data/getter.ts +++ b/webapp/src/features/external-data/getter.ts @@ -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"; @@ -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[]); +}; diff --git a/webapp/src/features/fallback/error-boundary-fallback.tsx b/webapp/src/features/fallback/error-boundary-fallback.tsx index 0f0dde98..3f8b2b3a 100644 --- a/webapp/src/features/fallback/error-boundary-fallback.tsx +++ b/webapp/src/features/fallback/error-boundary-fallback.tsx @@ -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"; diff --git a/webapp/src/features/map-filters/README.md b/webapp/src/features/map-filters/README.md new file mode 100644 index 00000000..850cd2b6 --- /dev/null +++ b/webapp/src/features/map-filters/README.md @@ -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 `` 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, +}; + + ({ 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/.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. diff --git a/webapp/src/features/map-filters/apply-layer-filter.ts b/webapp/src/features/map-filters/apply-layer-filter.ts new file mode 100644 index 00000000..ede710dc --- /dev/null +++ b/webapp/src/features/map-filters/apply-layer-filter.ts @@ -0,0 +1,155 @@ +import type { FilterSpecification, MapInstance } from "@shared/lib/coordo"; + +import { FILTERABLE_LAYERS, readLayerFilters } from "./storage"; +import { + FILTER_KINDS, + type LayerFilter, + type LayerFiltersState, +} from "./types"; + +/** + * Filters a layer already carries — notably the `["!", ["has", "point_count"]]` + * clause coordo puts on layers bound to a clustered source. MapLibre keeps a + * single filter per layer, so we snapshot that one on first touch and keep + * ANDing ours onto it. Keyed by map instance so a remount starts clean. + */ +const BASE_FILTERS = new WeakMap< + MapInstance, + Map +>(); + +const getBaseFilter = (map: MapInstance, layerId: string) => { + let baseFilterByLayer = BASE_FILTERS.get(map); + if (!baseFilterByLayer) { + baseFilterByLayer = new Map(); + BASE_FILTERS.set(map, baseFilterByLayer); + } + + if (!baseFilterByLayer.has(layerId)) { + // `getFilter` is declared as `FilterSpecification | void`; the void branch + // is `undefined` at runtime (layer without a filter). + baseFilterByLayer.set( + layerId, + map.getFilter(layerId) as FilterSpecification | undefined, + ); + } + + return baseFilterByLayer.get(layerId); +}; + +/** + * Turn one persisted filter into a MapLibre expression. Add a `case` here when + * you add a kind to {@link LayerFilter}. + * + * MapLibre compares strictly: an expression whose operands have different types + * evaluates to `false` instead of erroring, so a filter that silently matches + * nothing is almost always a type mismatch between the bound and the feature + * property. + * + * - `VALUES` — OR within the group. `in` against a literal array matches any of + * the selected values, so several checked boxes need no special handling. + * Values keep the type the API served them with: `["literal", ["3"]]` would + * never match a numeric `3`. + * + * - `RANGE` — inclusive `>=` / `<=`, ANDed when both bounds are set. + * Dates go through here: MapLibre has no date type, so a date is stored on the + * feature either as an ISO-8601 string — which sorts lexicographically, so + * plain `>=` works, provided every feature uses the same format and offset — + * or as an epoch number. + * + * ```ts + * // "surveyed during 2025", property stored as ISO strings + * { kind: FILTER_KINDS.RANGE, max: "2025-12-31", min: "2025-01-01", propertyName: "survey_date" } + * + * // same window, property stored as epoch milliseconds + * { kind: FILTER_KINDS.RANGE, max: Date.UTC(2025, 11, 31), min: Date.UTC(2025, 0, 1), propertyName: "survey_date" } + * ``` + */ +const buildFilterClause = (filter: LayerFilter): FilterSpecification | null => { + switch (filter.kind) { + case FILTER_KINDS.VALUES: + return [ + "in", + ["get", filter.propertyName], + ["literal", filter.values], + ] as FilterSpecification; + + case FILTER_KINDS.RANGE: { + const bounds: FilterSpecification[] = []; + + if (filter.min !== undefined) { + bounds.push([ + ">=", + ["get", filter.propertyName], + filter.min, + ] as FilterSpecification); + } + + if (filter.max !== undefined) { + bounds.push([ + "<=", + ["get", filter.propertyName], + filter.max, + ] as FilterSpecification); + } + + // A range with neither bound restricts nothing. + return bounds.length > 0 + ? (["all", ...bounds] as FilterSpecification) + : null; + } + + // Reached only when localStorage holds a kind this build no longer knows. + default: + return null; + } +}; + +/** + * Push a layer's whole filter state to the map: AND between groups, on top of + * the layer's own base filter. + * + * WARNING: every clause targeting `layerId` must go through this one call — + * MapLibre has a single filter slot per layer. A future cross-layer filter + * (date, ranges…) has to contribute its clauses *here* rather than call + * `setFilter` itself, or it would silently drop the per-layer ones. + */ +export const applyLayerFilter = ({ + map, + layerId, + layerFilters, +}: { + map: MapInstance; + layerId: string; + layerFilters: LayerFiltersState; +}) => { + // The panel can render before the style declares the layer. + if (!map.getLayer(layerId)) return; + + const baseFilter = getBaseFilter(map, layerId); + const clauses = Object.values(layerFilters) + .map(buildFilterClause) + .filter((clause) => clause !== null); + + const allClauses = baseFilter ? [baseFilter, ...clauses] : clauses; + + map.setFilter( + layerId, + allClauses.length > 0 + ? (["all", ...allClauses] as FilterSpecification) + : null, + ); +}; + +/** + * Replay the persisted filters onto a freshly loaded map. + */ +export const syncInitialLayerFilters = ({ map }: { map: MapInstance }) => { + FILTERABLE_LAYERS.forEach((layerId) => { + applyLayerFilter({ + layerFilters: readLayerFilters(layerId), + layerId, + map, + }); + }); +}; diff --git a/webapp/src/features/map-filters/components/checkbox-group.tsx b/webapp/src/features/map-filters/components/checkbox-group.tsx new file mode 100644 index 00000000..bde2448f --- /dev/null +++ b/webapp/src/features/map-filters/components/checkbox-group.tsx @@ -0,0 +1,61 @@ +import type { FC, ReactNode } from "react"; + +import { Checkbox, type CheckedState } from "@ui/checkbox"; +import { Field, FieldGroup, FieldLabel } from "@ui/field"; + +type CheckboxGroupItem = { + icon?: ReactNode; + identifier: string; + label: string; +}; + +type CheckboxGroupProps = { + title: string; + items: CheckboxGroupItem[]; + disabled?: boolean; + getIsChecked: (identifier: string) => boolean; + getOnCheckedChange: (identifier: string) => (nextValue: CheckedState) => void; +}; + +const FIELD_HTML_ID = (identifier: string) => + `filter-checkbox-group-${identifier.toLowerCase()}`; + +export const CheckboxGroup: FC = ({ + title, + items, + disabled, + getIsChecked, + getOnCheckedChange, +}) => { + return ( +
+

{title}

+ + {items.map((item) => ( + + + + + {item.icon} + {item.label} + + + ))} + +
+ ); +}; diff --git a/webapp/src/features/map-filters/index.ts b/webapp/src/features/map-filters/index.ts new file mode 100644 index 00000000..ba645bf6 --- /dev/null +++ b/webapp/src/features/map-filters/index.ts @@ -0,0 +1 @@ +export { MapFilters } from "./map-filters"; diff --git a/webapp/src/features/map-filters/layers/forest-inventory.tsx b/webapp/src/features/map-filters/layers/forest-inventory.tsx new file mode 100644 index 00000000..ce6f8d47 --- /dev/null +++ b/webapp/src/features/map-filters/layers/forest-inventory.tsx @@ -0,0 +1,101 @@ +import { TreePineIcon } from "lucide-react"; +import type { FC } from "react"; + +import { useExternalData } from "@features/external-data/context"; +import { getLabelData } from "@features/external-data/getter"; +import { ExternalDataBoundary } from "@features/external-data/suspense-boundary"; +import { findLabel } from "@features/indicators/labels"; + +import { LAYERS } from "@shared/api/layers"; +import type { Filters } from "@shared/api/types"; +import { useMap } from "@shared/hooks/use-map-all4trees"; +import { useTranslation } from "@shared/i18n"; +import { Card, CardTitle } from "@shared/ui/card"; +import { Separator } from "@shared/ui/separator"; + +import { CheckboxGroup } from "../components/checkbox-group"; +import type { FilterGroup } from "../types"; +import { useLayerFilters } from "../use-layer-filters"; + +const GROUP_KEYS = { + Loc1: "loc1", + Project: "project", +} as const; + +const MapFiltersForestInventoryInner: FC<{ filters: Filters }> = ({ + filters, +}) => { + const externalData = useExternalData(); + const { t, i18n } = useTranslation("all4trees"); + const { isReady } = useMap(); + const { getCheckboxGroupProps } = useLayerFilters({ + layerId: LAYERS.INVENTORY_FOR, + }); + + const labelData = getLabelData({ + externalData, + layerId: LAYERS.INVENTORY_FOR, + }); + + const projects = filters[GROUP_KEYS.Project][LAYERS.INVENTORY_FOR]; + const loc1s = filters[GROUP_KEYS.Loc1][LAYERS.INVENTORY_FOR]; + + const projectGroup: FilterGroup = { + key: GROUP_KEYS.Project, + propertyName: projects.property_name, + values: projects.values, + }; + + const loc1Group: FilterGroup = { + key: GROUP_KEYS.Loc1, + propertyName: loc1s.property_name, + values: loc1s.values, + }; + + const getLock1Label = (value: number) => + findLabel(labelData, projects.values[0], i18n.language, "loc1", value) ?? + value.toString(); + + return ( + +
+ + {t("layers.forestInventory")} +
+ + + + ({ + identifier: value, + label: value, + }))} + title="Projects" + {...getCheckboxGroupProps(projectGroup)} + /> + + ({ + identifier: value.toString(), + label: getLock1Label(value), + }))} + title="Loc1" + {...getCheckboxGroupProps(loc1Group)} + /> +
+ ); +}; + +export const MapFiltersForestInventory: FC<{ filters: Filters | null }> = ({ + filters, +}) => { + if (!filters) return null; + + return ( + + + + ); +}; diff --git a/webapp/src/features/map-filters/map-filters.tsx b/webapp/src/features/map-filters/map-filters.tsx new file mode 100644 index 00000000..204813b8 --- /dev/null +++ b/webapp/src/features/map-filters/map-filters.tsx @@ -0,0 +1,17 @@ +import type { FC } from "react"; + +import type { Filters } from "@shared/api/types"; + +import { MapFiltersForestInventory } from "./layers/forest-inventory"; + +type MapFiltersProps = { + filters: Filters | null; +}; + +export const MapFilters: FC = ({ filters }) => { + return ( + <> + + + ); +}; diff --git a/webapp/src/features/map-filters/storage.ts b/webapp/src/features/map-filters/storage.ts new file mode 100644 index 00000000..49f46fa5 --- /dev/null +++ b/webapp/src/features/map-filters/storage.ts @@ -0,0 +1,27 @@ +import { LAYERS } from "@shared/api/layers"; + +import type { LayerFiltersState } from "./types"; + +/** Layers that expose a per-layer filter panel — one localStorage entry each. */ +export const FILTERABLE_LAYERS: string[] = [LAYERS.INVENTORY_FOR]; + +export const getLayerFiltersStorageKey = (layerId: string) => + `d4g:map-filters:${layerId}`; + +/** + * Read a layer's persisted filters outside React. + * + * `useLocalStorage` covers the component side; this is for the map-ready + * initiator, which runs before (and independently of) the filter panel. + */ +export const readLayerFilters = (layerId: string): LayerFiltersState => { + try { + const item = window.localStorage.getItem( + getLayerFiltersStorageKey(layerId), + ); + return item ? JSON.parse(item) : {}; + } catch (error) { + console.error(error); + return {}; + } +}; diff --git a/webapp/src/features/map-filters/types.ts b/webapp/src/features/map-filters/types.ts new file mode 100644 index 00000000..4e14b784 --- /dev/null +++ b/webapp/src/features/map-filters/types.ts @@ -0,0 +1,68 @@ +/** Value of a filterable GeoJSON property, in the type the API serves it. */ +export type FilterValue = string | number; + +export const FILTER_KINDS = { + RANGE: "range", + VALUES: "values", +} as const; + +export type FilterKind = (typeof FILTER_KINDS)[keyof typeof FILTER_KINDS]; + +/** + * A "pick from a list" filter — one checkbox group. The selected values are + * ORed together. + */ +export type ValuesFilter = { + kind: typeof FILTER_KINDS.VALUES; + /** GeoJSON property to filter on (`property_name` from the API payload). */ + propertyName: string; + /** Selected values only. An empty array hides every feature. */ + values: FilterValue[]; +}; + +/** + * Bound of a {@link RangeFilter}. Strings are for dates: MapLibre expressions + * have no date type, so a date lives in the feature as an ISO-8601 string or as + * an epoch number, and both compare correctly (see `buildFilterClause`). + */ +export type RangeBound = string | number; + +/** + * A "between two bounds" filter — dates, scores, tree counts… Both bounds are + * inclusive and optional, so an open-ended range is just one of them. + * + * Nothing renders this yet; it exists as the worked example for adding a kind. + */ +export type RangeFilter = { + kind: typeof FILTER_KINDS.RANGE; + propertyName: string; + /** Inclusive lower bound. Omit for "no lower bound". */ + min?: RangeBound; + /** Inclusive upper bound. Omit for "no upper bound". */ + max?: RangeBound; +}; + +/** Add new kinds to this union — `buildFilterClause` then stops compiling. */ +export type LayerFilter = ValuesFilter | RangeFilter; + +/** + * Everything persisted for one layer, keyed by group ("project", "loc1", …). + * + * A group **absent** from the record restricts nothing, which is why first-time + * visitors need no seeding: `{}` means "show everything". A group present with + * an empty `values` is a deliberate "nothing selected". + * + * `propertyName` is stored alongside the selection on purpose: it lets the map + * apply persisted filters on load without waiting for — or knowing about — the + * `getFilters()` payload. See `syncInitialLayerFilters`. + */ +export type LayerFiltersState = Record; + +/** A checkbox group as offered by the API, before the user touches it. */ +export type FilterGroup = { + /** Stable key, used in localStorage and to look the selection up. */ + key: string; + propertyName: string; + /** Every value the API offers, in API order. */ + values: FilterValue[]; +}; diff --git a/webapp/src/features/map-filters/use-layer-filters.ts b/webapp/src/features/map-filters/use-layer-filters.ts new file mode 100644 index 00000000..e95ece77 --- /dev/null +++ b/webapp/src/features/map-filters/use-layer-filters.ts @@ -0,0 +1,96 @@ +import { useEffect } from "react"; + +import { useLocalStorage } from "@shared/hooks/use-local-storage"; +import { useMap } from "@shared/hooks/use-map-all4trees"; + +import type { CheckedState } from "@ui/checkbox"; + +import { applyLayerFilter } from "./apply-layer-filter"; +import { getLayerFiltersStorageKey } from "./storage"; +import { + FILTER_KINDS, + type FilterGroup, + type LayerFilter, + type LayerFiltersState, + type ValuesFilter, +} from "./types"; + +/** + * Checkboxes can only read and write `VALUES` filters. Another kind stored under + * the same group key (a range, say) is left untouched and reads as "no + * restriction" here — its own widget owns it. + */ +const getValuesFilter = ( + filter: LayerFilter | undefined, +): ValuesFilter | undefined => + filter?.kind === FILTER_KINDS.VALUES ? filter : undefined; + +/** + * Persisted per-layer filter state, kept in sync with the map. + * + * The map is updated whenever the selection changes; the reload case is handled + * upstream by `syncInitialLayerFilters` on MAP_READY. + */ +export const useLayerFilters = ({ layerId }: { layerId: string }) => { + const { isReady, mapApiRef } = useMap(); + const [layerFilters, setLayerFilters] = useLocalStorage( + getLayerFiltersStorageKey(layerId), + {}, + ); + + useEffect(() => { + const map = mapApiRef.current?.mapInstance; + if (!isReady || !map) return; + + applyLayerFilter({ layerFilters, layerId, map }); + }, [isReady, layerFilters, layerId, mapApiRef]); + + /** Spread onto a `` to bind it to `group`. */ + const getCheckboxGroupProps = (group: FilterGroup) => ({ + /** + * A checkbox is checked when + * - either the group is untouched (no restrictions) + * - the checkbox identifier matches one of the selected values + */ + getIsChecked: (identifier: string) => { + const selection = getValuesFilter(layerFilters[group.key]); + // An untouched group restricts nothing, so every box reads as checked. + if (!selection) return true; + + return selection.values.some((value) => String(value) === identifier); + }, + + getOnCheckedChange: (identifier: string) => (nextValue: CheckedState) => { + setLayerFilters((previous) => { + // First interaction with a group starts from "everything selected", + // matching what the boxes were showing. + const selectedIdentifiers = new Set( + (getValuesFilter(previous[group.key])?.values ?? group.values).map( + String, + ), + ); + + if (nextValue === true) { + selectedIdentifiers.add(identifier); + } else { + selectedIdentifiers.delete(identifier); + } + + return { + ...previous, + [group.key]: { + kind: FILTER_KINDS.VALUES, + propertyName: group.propertyName, + // Rebuilt from the API list, so order is preserved and values the + // backend no longer serves drop out on their own. + values: group.values.filter((value) => + selectedIdentifiers.has(String(value)), + ), + }, + }; + }); + }, + }); + + return { getCheckboxGroupProps, layerFilters }; +}; diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index e496dd04..da24726e 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -1,4 +1,4 @@ -import type { APIError } from "../lib/types"; +import type { APIError, Filters } from "./types"; export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; @@ -62,6 +62,8 @@ export const createApiClient = (authToken: string | null) => ({ ), getDashboardData: (layerId: string) => fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), + getFilters: (): Promise => + fetchJSONWithAuth(`/maps/get-filters/`, {}, authToken), }); export type ApiClient = ReturnType; diff --git a/webapp/src/shared/api/types.ts b/webapp/src/shared/api/types.ts new file mode 100644 index 00000000..dec6dbd7 --- /dev/null +++ b/webapp/src/shared/api/types.ts @@ -0,0 +1,21 @@ +type FilterLeaf = { + property_name: string; + values: Array; +}; + +export type Filters = { + project: { + inventaire_for: FilterLeaf; + inventaire_bio: FilterLeaf; + }; + loc1: { + inventaire_for: FilterLeaf; + inventaire_bio: FilterLeaf; + }; +}; + +export type APIError = { + status: number; + message: string; + cause: string; +}; diff --git a/webapp/src/shared/lib/coordo.ts b/webapp/src/shared/lib/coordo.ts index a41fb3f6..8343506b 100644 --- a/webapp/src/shared/lib/coordo.ts +++ b/webapp/src/shared/lib/coordo.ts @@ -1,5 +1,6 @@ // Façade file to export coordo content // This helps for local development where only one path has to be updated + export { createMap, EVENTS, @@ -10,3 +11,4 @@ export { type LayerMetadata, type PopupOptions, } from "coordo"; +export type { FilterSpecification, Map as MapInstance } from "maplibre-gl"; diff --git a/webapp/src/shared/lib/types.ts b/webapp/src/shared/lib/types.ts deleted file mode 100644 index c8246161..00000000 --- a/webapp/src/shared/lib/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type APIError = { - status: number; - message: string; - cause: string; -}; diff --git a/webapp/src/shared/ui/grid-selector.tsx b/webapp/src/shared/ui/grid-selector.tsx index 2a003451..cb03d695 100644 --- a/webapp/src/shared/ui/grid-selector.tsx +++ b/webapp/src/shared/ui/grid-selector.tsx @@ -5,6 +5,7 @@ import { cn } from "@shared/lib/utils"; type Option = { label: string; id: string; + disabled?: boolean; }; type GridSelectorProps = { @@ -36,6 +37,7 @@ export const GridSelector: FC = ({ "text-muted-foreground hover:text-foreground hover:cursor-pointer border-transparent": !isSelected, })} + disabled={option.disabled} key={`grid-selector-option-${option.id}`} onClick={() => onChange(option.id)} type="button" diff --git a/webapp/src/widgets/map-sidebar/main.tsx b/webapp/src/widgets/map-sidebar/main.tsx index 9b11d3b6..50cac3f7 100644 --- a/webapp/src/widgets/map-sidebar/main.tsx +++ b/webapp/src/widgets/map-sidebar/main.tsx @@ -1,8 +1,11 @@ import { ListFilterIcon } from "lucide-react"; -import { useState } from "react"; +import { Activity, useEffect, useRef, useState } from "react"; import { CategoriesFilters } from "@features/categories-filters"; +import { MapFilters } from "@features/map-filters"; +import type { Filters } from "@shared/api/types"; +import { useApi } from "@shared/hooks/useApi"; import { GridSelector } from "@shared/ui/grid-selector"; import { useTranslation } from "@i18n"; @@ -18,6 +21,22 @@ export function MapSidebar() { const [selectedFilterKind, setSelectedFilterKind] = useState( FILTER_KIND.category, ); + const [mapFilters, setMapFilters] = useState(null); + const isLoadingFilter = useRef(null); + + const client = useApi(); + + useEffect(() => { + const fetchFilters = async () => { + isLoadingFilter.current = true; + const filters = await client.getFilters(); + setMapFilters(filters); + isLoadingFilter.current = false; + }; + if (mapFilters == null && !isLoadingFilter.current) { + fetchFilters(); + } + }, [mapFilters, client.getFilters]); return (
@@ -37,6 +56,7 @@ export function MapSidebar() { label: t("filters.sidebarLayout.groupCategory"), }, { + disabled: mapFilters == null, id: FILTER_KIND.filtersPerCategory, label: t("filters.sidebarLayout.groupFilters"), }, @@ -52,6 +72,17 @@ export function MapSidebar() { }} > {selectedFilterKind === FILTER_KIND.category && } + + + +
);