diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d43a4bb..d3cffe95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ # Changelog +## Sprint 224 Governed GRB evolution (2026-07-16) + +- Enabled fail-closed object evolution between the retained regional GRB + snapshots by validating official OGC feature identities, approved operator + provenance, completeness and collection-specific prefixes. +- Removed geometry-hash identity fallbacks from future regional GRB imports; + a provider feature without an official identity now aborts acquisition. +- Added explicit identity contracts to future buildings, roads, water and + parcel snapshots while retaining compatibility with the two already + persisted governed editions. +- Improved daily temporal labels and added a clear distinction between an + official registration change and the unknown date of a physical change. +- Documented a source-wide refresh-readiness matrix without enabling browser + fetches, schedulers, migrations or automatic Dataset replacement. + ## Sprint 223 Governed regional GRB refresh (2026-07-16) - Added a canonical, read-only regional GRB refresh plan for buildings, roads, diff --git a/backend/app/services/temporal_analysis_service.py b/backend/app/services/temporal_analysis_service.py index 974ba3de..77506be0 100644 --- a/backend/app/services/temporal_analysis_service.py +++ b/backend/app/services/temporal_analysis_service.py @@ -27,6 +27,10 @@ from app.services.vector_feature_service import VectorFeatureService class TemporalAnalysisService: IDENTITY_COMPARISON_LIMIT = 5_000 + GOVERNED_GRB_IDENTITY_OPERATORS = { + "provision_regional_grb_buildings.py", + "provision_regional_grb_context.py", + } @staticmethod def list_series(db: Session, project_id: UUID) -> list[TemporalSeriesRead]: @@ -345,7 +349,9 @@ class TemporalAnalysisService: ) -> tuple[TemporalObjectChanges, dict[str, Any], list[str]]: earlier_config = earlier.source_metadata if isinstance(earlier.source_metadata, dict) else {} later_config = later.source_metadata if isinstance(later.source_metadata, dict) else {} - if not earlier_config.get("identity_stable") or not later_config.get("identity_stable"): + earlier_identity = TemporalAnalysisService._identity_contract(earlier) + later_identity = TemporalAnalysisService._identity_contract(later) + if earlier_identity is None or later_identity is None or earlier_identity != later_identity: return ( TemporalObjectChanges(available=False), {"type": "FeatureCollection", "features": []}, @@ -368,8 +374,7 @@ class TemporalAnalysisService: if not full_dataset_area: query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape)) return ( - query.filter(VectorFeature.source_feature_id.isnot(None)) - .order_by(VectorFeature.source_feature_id.asc()) + query.order_by(VectorFeature.source_feature_id.asc()) .limit(TemporalAnalysisService.IDENTITY_COMPARISON_LIMIT + 1) .all() ) @@ -386,6 +391,16 @@ class TemporalAnalysisService: ["Object-level preview was skipped because the selection exceeds the 5,000 feature safety limit."], ) + identity_prefixes = earlier_identity[1] + if not TemporalAnalysisService._rows_match_identity_contract(earlier_rows, identity_prefixes) or not ( + TemporalAnalysisService._rows_match_identity_contract(later_rows, identity_prefixes) + ): + return ( + TemporalObjectChanges(available=False), + {"type": "FeatureCollection", "features": []}, + ["De geselecteerde objecten bevatten geen volledig verifieerbare stabiele bronidentiteit."], + ) + earlier_by_id = {str(row.source_feature_id): row for row in earlier_rows if row.source_feature_id} later_by_id = {str(row.source_feature_id): row for row in later_rows if row.source_feature_id} earlier_ids = set(earlier_by_id) @@ -459,3 +474,50 @@ class TemporalAnalysisService: {"type": "FeatureCollection", "features": features}, warnings, ) + + @staticmethod + def _identity_contract(dataset: Dataset) -> tuple[str, tuple[str, ...]] | None: + source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {} + declared_stable = source_metadata.get("identity_stable") + if declared_stable is False: + return None + + configured_prefixes = source_metadata.get("identity_prefixes") + prefixes = tuple( + sorted( + { + str(value).strip() + for value in configured_prefixes + if str(value).strip() + } + ) + ) if isinstance(configured_prefixes, list) else () + if declared_stable is True: + return str(source_metadata.get("identity_scheme") or "declared_source_feature_id"), prefixes + + provenance = dataset.provenance_metadata if isinstance(dataset.provenance_metadata, dict) else {} + if ( + dataset.source_name != "grb" + or not str(dataset.temporal_series_key or "").startswith("grb:") + or source_metadata.get("authority_level") != "authoritative" + or source_metadata.get("geometry_clipped_to_area") is not True + or provenance.get("operator_tool") not in TemporalAnalysisService.GOVERNED_GRB_IDENTITY_OPERATORS + or provenance.get("reference_truncated") is not False + ): + return None + + if dataset.reference_layer_name == "buildings" and source_metadata.get("collection") == "GRB/GBG": + prefixes = ("GBG.",) + else: + collections = source_metadata.get("collections") + if not isinstance(collections, list) or not collections: + return None + prefixes = tuple(sorted(f"{str(collection)}:{str(collection)}." for collection in collections)) + return "grb_ogc_feature_id", prefixes + + @staticmethod + def _rows_match_identity_contract(rows: list[VectorFeature], prefixes: tuple[str, ...]) -> bool: + identities = [str(row.source_feature_id or "").strip() for row in rows] + if any(not identity for identity in identities) or len(set(identities)) != len(identities): + return False + return not prefixes or all(identity.startswith(prefixes) for identity in identities) diff --git a/backend/tests/test_sprint187_temporal_map_foundation.py b/backend/tests/test_sprint187_temporal_map_foundation.py index 2adbab6b..92cbc16d 100644 --- a/backend/tests/test_sprint187_temporal_map_foundation.py +++ b/backend/tests/test_sprint187_temporal_map_foundation.py @@ -6,6 +6,8 @@ from types import SimpleNamespace from uuid import uuid4 import pytest +from geoalchemy2.shape import from_shape +from shapely.geometry import Polygon from app.core.errors import AppError from app.models import Dataset, DatasetVersion @@ -46,6 +48,33 @@ class SequenceScalarSession: return ScalarQuery(next(self.values)) +class FeatureRowsQuery: + def __init__(self, rows: list[object]): + self.rows = rows + self.row_limit: int | None = None + + def filter(self, *args): # noqa: ANN002, ARG002 + return self + + def order_by(self, *args): # noqa: ANN002, ARG002 + return self + + def limit(self, value: int): + self.row_limit = value + return self + + def all(self): + return self.rows[: self.row_limit] + + +class SequentialFeatureSession: + def __init__(self, row_sets: list[list[object]]): + self.row_sets = iter(row_sets) + + def query(self, _model): + return FeatureRowsQuery(next(self.row_sets)) + + class VersionQuery: def __init__(self, latest: DatasetVersion | None): self.latest = latest @@ -104,6 +133,40 @@ def temporal_dataset(*, project_id, observed_year: int, metric_method: str = "fe ) +def governed_grb_dataset(*, project_id, observed_day: int) -> Dataset: + dataset = temporal_dataset(project_id=project_id, observed_year=2026, metric_method="intersection_area") + dataset.observed_at = datetime(2026, 7, observed_day, tzinfo=timezone.utc) + dataset.source_version = f"2026-07-{observed_day:02d}" + dataset.source_name = "grb" + dataset.reference_layer_name = "buildings" + dataset.temporal_series_key = "grb:buildings:kempen-transport-region" + dataset.source_metadata = { + "authority_level": "authoritative", + "geometry_clipped_to_area": True, + "collection": "GRB/GBG", + "selection_aggregation": { + "method": "intersection_area", + "label": "Bebouwde grondoppervlakte", + "unit": "ha", + }, + } + dataset.provenance_metadata = { + "operator_tool": "provision_regional_grb_buildings.py", + "reference_truncated": False, + } + return dataset + + +def persisted_feature(dataset_id, source_feature_id: str | None, polygon: Polygon): + return SimpleNamespace( + id=uuid4(), + dataset_id=dataset_id, + source_feature_id=source_feature_id, + properties_json={}, + geometry=from_shape(polygon, srid=4326), + ) + + def test_temporal_migration_and_models_align() -> None: migration = (ROOT / "backend/alembic/versions/202607140001_temporal_dataset_foundation.py").read_text(encoding="utf-8") for field in ( @@ -294,6 +357,63 @@ def test_unstable_temporal_identity_returns_clear_end_user_warning() -> None: assert warnings == ["Wijzigingen van individuele objecten kunnen voor deze bron niet betrouwbaar worden gevolgd."] +def test_governed_legacy_grb_snapshots_use_verified_official_feature_identity() -> None: + project_id = uuid4() + earlier = governed_grb_dataset(project_id=project_id, observed_day=14) + later = governed_grb_dataset(project_id=project_id, observed_day=15) + original = Polygon([(5.1, 51.1), (5.101, 51.1), (5.101, 51.101), (5.1, 51.101)]) + changed = Polygon([(5.1, 51.1), (5.102, 51.1), (5.102, 51.101), (5.1, 51.101)]) + added = Polygon([(5.11, 51.11), (5.111, 51.11), (5.111, 51.111), (5.11, 51.111)]) + session = SequentialFeatureSession( + [ + [persisted_feature(earlier.id, "GBG.1", original)], + [persisted_feature(later.id, "GBG.1", changed), persisted_feature(later.id, "GBG.2", added)], + ] + ) + + changes, geojson, warnings = TemporalAnalysisService._compare_identity_features( + session, + earlier=earlier, + later=later, + bbox={"min_x": 5.0, "min_y": 51.0, "max_x": 5.2, "max_y": 51.2}, + preview_limit=100, + ) + + assert changes.available is True + assert changes.added_count == 1 + assert changes.removed_count == 0 + assert changes.modified_count == 1 + assert changes.unchanged_count == 0 + assert {feature["properties"]["change_type"] for feature in geojson["features"]} == {"added", "modified"} + assert warnings == [] + + +def test_governed_grb_object_history_fails_closed_for_unverified_identity() -> None: + project_id = uuid4() + earlier = governed_grb_dataset(project_id=project_id, observed_day=14) + later = governed_grb_dataset(project_id=project_id, observed_day=15) + polygon = Polygon([(5.1, 51.1), (5.101, 51.1), (5.101, 51.101), (5.1, 51.101)]) + fallback_hash = "a" * 64 + session = SequentialFeatureSession( + [ + [persisted_feature(earlier.id, fallback_hash, polygon)], + [persisted_feature(later.id, fallback_hash, polygon)], + ] + ) + + changes, geojson, warnings = TemporalAnalysisService._compare_identity_features( + session, + earlier=earlier, + later=later, + bbox={"min_x": 5.0, "min_y": 51.0, "max_x": 5.2, "max_y": 51.2}, + preview_limit=100, + ) + + assert changes.available is False + assert geojson["features"] == [] + assert warnings == ["De geselecteerde objecten bevatten geen volledig verifieerbare stabiele bronidentiteit."] + + def test_temporal_frontend_and_official_operator_contracts_exist() -> None: workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") temporal_api = (ROOT / "frontend/src/services/api/temporal.ts").read_text(encoding="utf-8") @@ -304,6 +424,8 @@ def test_temporal_frontend_and_official_operator_contracts_exist() -> None: assert "Laatste toestand" in workspace assert "Evolutie" in workspace assert "Vergelijk periode" in workspace + assert "temporalRangeLabel" in workspace + assert "Dagelijkse GRB-edities tonen wijzigingen in de officiële registratie" in workspace assert "/temporal/compare" in temporal_api assert "Statbel" in population and "area_weighted_sum" in population assert '"identity_stable": False' in population diff --git a/backend/tests/test_sprint190_regional_grb_buildings.py b/backend/tests/test_sprint190_regional_grb_buildings.py index 2b5ed95d..5e1ff4a9 100644 --- a/backend/tests/test_sprint190_regional_grb_buildings.py +++ b/backend/tests/test_sprint190_regional_grb_buildings.py @@ -119,6 +119,36 @@ def test_interior_buildings_skip_regional_owner_scan(monkeypatch) -> None: assert [item["id"] for item in features] == ["GBG.inside"] +def test_building_partition_rejects_missing_official_source_identity() -> None: + operator = load_operator() + scopes = importlib.import_module("geographic_scopes") + member = scopes.ScopeMember("Alpha", "10001") + boundary = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + scope = scopes.GeographicScope( + key="single", + display_name="Single", + project_name="Single", + project_region="Single", + area_name="Single boundary", + authority_name="Test", + authority_url="https://example.test", + scope_type="municipality", + limitation_message="Test.", + members=(member,), + ) + missing_identity = feature("", Polygon([(0.1, 0.1), (0.2, 0.1), (0.2, 0.2), (0.1, 0.2)])) + + with pytest.raises(RuntimeError, match="missing an official source identity"): + operator.build_partition_features( + [({"type": "FeatureCollection", "features": [missing_identity]}, "https://example.test/grb")], + member=member, + members=[(member, boundary)], + regional_boundary=boundary, + scope=scope, + max_features=10, + ) + + def test_combined_artifact_streams_partitions_and_rejects_duplicate_source_ids(tmp_path: Path) -> None: operator = load_operator() scope = importlib.import_module("geographic_scopes").KEMPEN_TRANSPORT_REGION_SCOPE @@ -225,6 +255,8 @@ def test_regional_operator_is_packaged_documented_and_uses_service_boundaries() assert "provision_regional_grb_buildings.py" in dockerfile assert "py_compile scripts/provision_regional_grb_buildings.py" in readiness assert "DatasetService.import_partitioned_vector_artifact" in operator + assert '"identity_stable": True' in operator + assert '"identity_scheme": "grb_ogc_feature_id"' in operator assert "VectorFeatureService.persist_geojson_partitions" in dataset_service assert "insert into vector_features" not in operator.lower() assert "db.add(VectorFeature" not in operator diff --git a/backend/tests/test_sprint191_regional_grb_context.py b/backend/tests/test_sprint191_regional_grb_context.py index 58088804..2ba01149 100644 --- a/backend/tests/test_sprint191_regional_grb_context.py +++ b/backend/tests/test_sprint191_regional_grb_context.py @@ -147,6 +147,38 @@ def test_mixed_water_partition_preserves_dimensions_and_source_identity() -> Non assert summary["reference_truncated"] is False +def test_context_partition_rejects_missing_official_source_identity() -> None: + operator = load_operator() + scopes = __import__("geographic_scopes") + member = scopes.ScopeMember("Alpha", "10001") + scope = scopes.GeographicScope( + key="test-region", + display_name="Test region", + project_name="Test project", + project_region="Test", + area_name="Test boundary", + authority_name="Test", + authority_url="https://example.test/scope", + scope_type="policy_region", + limitation_message="Test only.", + members=(member,), + ) + boundary = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + definition = operator.LAYER_BY_KEY["roads"] + missing_identity = source_feature("", LineString([(0.1, 0.1), (0.2, 0.2)])) + + with pytest.raises(RuntimeError, match="missing an official source identity"): + operator.build_partition_features( + [(definition.collections[0], {"type": "FeatureCollection", "features": [missing_identity]}, "https://example.test/roads")], + definition=definition, + member=member, + members=[(member, boundary)], + regional_boundary=boundary, + scope=scope, + max_features=10, + ) + + def test_polygon_partition_assignment_does_not_duplicate_cross_boundary_parcel() -> None: operator = load_operator() scopes = __import__("geographic_scopes") @@ -224,5 +256,7 @@ def test_context_operator_is_packaged_and_uses_existing_service_boundary() -> No assert "provision_regional_grb_context.py" in dockerfile assert "py_compile scripts/provision_regional_grb_context.py" in readiness assert "DatasetService.import_partitioned_vector_artifact" in operator + assert '"identity_stable": True' in operator + assert '"identity_scheme": "grb_ogc_feature_id"' in operator assert "insert into vector_features" not in operator.lower() assert "db.add(VectorFeature" not in operator diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 1b203cee..9252fa51 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -1850,6 +1850,17 @@ Added/removed/modified object changes are calculated only when source provenance declares stable feature identities; otherwise `object_changes.available=false` and no object history is inferred. +Governed regional GRB snapshots use the official OGC feature identifier as +their object identity. New imports declare the identity scheme and accepted +collection prefixes explicitly. Existing operator-managed GRB snapshots are +accepted only when their authoritative, complete and area-clipped provenance +matches an approved regional operator and every selected identifier is +present, unique and uses the expected prefix. Missing, duplicate or unexpected +identifiers fail closed to metric-only comparison. The existing 5,000-feature +selection limit also remains in force. Differences between daily GRB editions +describe changes in the official registration; they do not prove that a +physical change happened on the exact publication date. + For `reference_layer_name=agriculture`, the primary map metric is exact intersected declared-use area in hectares. Supplemental metrics use server-owned filters on the normalized official main-crop group. Annual ALZ diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 4c6e7229..e9f34fd0 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -9387,3 +9387,35 @@ Validation so far: Next: - Run the full release gate, deploy to Tower, execute a live read-only plan and then stage/apply only the exact confirmed official edition. + +## Sprint 224 - Governed GRB evolution (2026-07-16) + +Implemented: +- Audited the two retained regional editions for buildings, roads, water and + parcels. All 2,108,425 persisted VectorFeatures have an official + `source_feature_id`; none uses the former 64-character geometry-hash + fallback. +- Added a fail-closed GRB identity contract to temporal object comparison. + Legacy snapshots require authoritative, area-clipped, non-truncated + provenance from an approved regional operator and every selected identifier + must be present, unique and match its collection prefix. +- Made future regional GRB operators abort when an official OGC feature ID is + missing and persist their identity scheme/prefixes explicitly. +- Replaced ambiguous same-year labels such as `2026-2026` with daily edition + ranges and explained that registration changes do not date physical change. +- Documented an honest refresh-readiness matrix for all major source families; + no additional provider fetch or automatic refresh was enabled. + +Boundaries: +- No migration, new persistence model, scheduler, browser-triggered import or + parallel temporal engine was added. +- Object comparison remains bounded to 5,000 selected features and falls back + to metric-only output whenever source identity cannot be proven. + +Validation so far: +- Focused GRB operator and temporal tests pass: 39 tests. +- Frontend typecheck and production build pass. + +Next: +- Run the full release gate, deploy, verify a real bounded GRB edition delta + through the canonical API and inspect the daily Evolution UI on Tower. diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md index f1cbbc76..5eb1e86a 100644 --- a/docs/DATA_SOURCES.md +++ b/docs/DATA_SOURCES.md @@ -151,6 +151,38 @@ Collectiecatalogus en contract: - https://geo.api.vlaanderen.be/GRB/ogc/features/v1/collections?f=text%2Fhtml - https://geo.api.vlaanderen.be/GRB/ogc/features/v1/openapi?f=text%2Fhtml +### Beheerde GRB-edities en objectevolutie + +Elke regionale GRB-import blijft een onveranderlijke Dataset in dezelfde +`temporal_series_key`. De operators bewaren uitsluitend de officiële OGC +feature-ID als `source_feature_id`; een ontbrekende ID wordt niet vervangen +door een geometriehash. Nieuwe snapshots declareren +`identity_scheme=grb_ogc_feature_id` en de toegelaten collectieprefixen. + +De tijdelijke vergelijkingsservice toont toegevoegde, verwijderde en gewijzigde +objecten alleen wanneer beide snapshots hetzelfde gecontroleerde +identiteitscontract hebben en alle geselecteerde ID's aanwezig en uniek zijn. +Oudere, reeds beheerde snapshots worden alleen onder hun volledige +authoritatieve operatorprovenance aanvaard. Dagelijkse editieverschillen zijn +wijzigingen in de officiële registratie; de fysieke wijziging kan eerder zijn +gebeurd. + +### Refreshgereedheid van officiële bronnen + +| bronfamilie | huidige behandeling | volgende veilige stap | +| --- | --- | --- | +| GRB gebouwen/wegen/water/percelen | operationele, expliciete plan-stage-apply refresh met onveranderlijke snapshots | alleen een nieuw officieel gedateerd cataloguseditie na operatorbevestiging ophalen | +| Statbel bevolking | jaarlijkse, expliciete edities in één tijdreeks | een nieuwe publicatie alleen na schema-, sectorgeometrie- en totalencontrole toevoegen | +| ALZ landbouwgebruikspercelen | definitieve jaarlijkse edities 2008-2025; metricvergelijking zonder objectlineage | eerst een stabiele machineleesbare editiebron verifiëren, daarna dezelfde begrensde operatorflow toepassen | +| orthofoto | vaste lokale opname per expliciete analysezone; catalogusprobe is alleen een signaal | vluchtjaar, productvariant en dekking vergelijken voordat nieuwe pixels worden opgehaald | +| landgebruik, thematische rasters, DHMV en VMM-scenario's | vaste product-/scenario-edities, geen rolling snapshot | alleen een nieuwe gedocumenteerde producteditie als afzonderlijke Dataset verwerven | +| bodemkaart en historische kaarten | historische referentie-editie | niet als verouderde actuele bron labelen; alleen vervangen bij een officiële inhoudelijke heruitgave | +| BWK/Natura 2000 en gebouwen-/adressenregister | expliciete actuele snapshot met eigen methodologische betekenis | eerst een stabiele officiële editieprobe en bron-specifieke reconciliatiecontrole toevoegen | + +Geen van deze regels activeert een browserfetch, scheduler of automatische +vervanging. Een bron wordt pas `refreshable` wanneer versie, dekking, schema, +provenance en een begrensde acquisitieroute afzonderlijk verifieerbaar zijn. + ### Mol population history `scripts/provision_mol_population_history.py` imports official Statbel diff --git a/docs/TODO.md b/docs/TODO.md index edba95e2..e6e5355d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -670,3 +670,20 @@ This file now starts with the current implementation status. Older preparation/b - [x] Replace the water-first recommendation with a cross-domain implementation wave. - [ ] Implement the governed Flemish thematic-raster registry for space occupation, open space, population density, node value and service level. - [x] Add the digital soil map through DatasetService and VectorFeatureService. + +# Sprint 224 - Governed GRB evolution + +- [x] Audit all eight retained regional GRB snapshots for complete official + source identities and geometry-hash fallbacks. +- [x] Validate old governed snapshots through authoritative, complete and + approved operator provenance before enabling object-level comparison. +- [x] Make future regional GRB imports reject missing official OGC identities. +- [x] Persist explicit identity schemes and collection prefixes on new GRB + snapshots. +- [x] Show precise daily edition ranges and explain registration-date nuance in + the Evolution workspace. +- [x] Document refresh readiness across the complete official-source portfolio. +- [ ] Add the next governed catalog probe only after an official stable version + contract is verified; ALZ annual releases are the first candidate. +- [ ] Keep orthophoto refresh manual until product variant, flight year and + complete selected-area coverage can be compared deterministically. diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 364a3c62..549a84a7 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -322,10 +322,8 @@ function temporalSeriesLabel(items: DatasetCreateResponse[]): string { } const first = items[0] const source = first ? getDatasetDisplayName(first) : 'Tijdreeks' - const firstYear = first?.observed_at ? new Date(first.observed_at).getUTCFullYear() : null - const last = items[items.length - 1] - const lastYear = last?.observed_at ? new Date(last.observed_at).getUTCFullYear() : null - return firstYear && lastYear ? `${source} (${firstYear}-${lastYear})` : source + const range = temporalRangeLabel(items) + return range ? `${source} (${range})` : source } function listThemeTemporalSeries(datasets: DatasetCreateResponse[], theme: DataTheme): TemporalSeriesGroup[] { @@ -362,6 +360,27 @@ function formatObservationDate(value: string | null | undefined): string { return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value)) } +function temporalRangeLabel(items: DatasetCreateResponse[]): string | null { + const firstValue = items[0]?.observed_at + const lastValue = items[items.length - 1]?.observed_at + if (!firstValue || !lastValue) { + return null + } + const first = new Date(firstValue) + const last = new Date(lastValue) + if (first.getTime() === last.getTime()) { + return formatObservationDate(firstValue) + } + if (first.getUTCFullYear() !== last.getUTCFullYear()) { + return `${first.getUTCFullYear()}-${last.getUTCFullYear()}` + } + if (first.getUTCMonth() === last.getUTCMonth()) { + const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last) + return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}` + } + return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}` +} + function formatDatasetObservation(dataset: DatasetCreateResponse): string { if (dataset.source_name === 'vmm_flood_hazard') { return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}` @@ -983,6 +1002,10 @@ export function MapWorkspace({ const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey) ?? activeTemporalSeriesGroups[0] const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES + const activeSeriesIsDailyGrb = activeTemporalSeries.length >= 2 + && activeTemporalSeries.every((dataset) => dataset.source_name === 'grb') + && new Date(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at ?? 0).getTime() + - new Date(activeTemporalSeries[0].observed_at ?? 0).getTime() <= 7 * 24 * 60 * 60 * 1000 useEffect(() => { onSetContextSourceLabel( @@ -1416,10 +1439,7 @@ export function MapWorkspace({ const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2) const available = Boolean(dataset) && (analysisMode === 'current' || evolutionAvailable) const active = activeThemeId === theme.id - const firstObservation = temporalGroup?.items[0]?.observed_at - const lastObservation = temporalGroup?.items[temporalGroup.items.length - 1]?.observed_at - const firstYear = firstObservation ? new Date(firstObservation).getUTCFullYear() : null - const lastYear = lastObservation ? new Date(lastObservation).getUTCFullYear() : null + const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null return ( + + {activeSeriesIsDailyGrb ? ( +

+ Dagelijkse GRB-edities tonen wijzigingen in de officiële registratie. Ze bewijzen niet dat een fysieke verandering exact tussen deze twee kalenderdagen plaatsvond. +

) : null} - - - - + ) : null}