From a11d75082a1fa1a8d100d1a357151f790db2da1a Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Thu, 18 Jun 2026 21:36:55 +0545 Subject: [PATCH 1/2] fix(project): resolve map AOI from the same priority chain as download --- src/components/ProjectMap/index.tsx | 2 +- src/pages/[locale]/projects/[id].tsx | 110 ++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/components/ProjectMap/index.tsx b/src/components/ProjectMap/index.tsx index 4c33b69b..68d057ad 100644 --- a/src/components/ProjectMap/index.tsx +++ b/src/components/ProjectMap/index.tsx @@ -11,7 +11,7 @@ import GestureHandler from 'components/LeafletGestureHandler'; interface Props { className?: string; children?: React.ReactNode; - geoJSON: GeoJSON.FeatureCollection; + geoJSON: GeoJSON.FeatureCollection; } function ProjectMap(props: Props) { diff --git a/src/pages/[locale]/projects/[id].tsx b/src/pages/[locale]/projects/[id].tsx index 2c3c1886..e25dbb38 100644 --- a/src/pages/[locale]/projects/[id].tsx +++ b/src/pages/[locale]/projects/[id].tsx @@ -81,6 +81,80 @@ function transformAoiToGeoJson( }); } +const GEOJSON_GEOMETRY_TYPES = [ + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', + 'GeometryCollection', +]; + +// NOTE: The map needs parsed GeoJSON content (not just a URL like the AOI +// download), so we normalize whatever a file resolves to into a +// FeatureCollection: a FeatureCollection is used as-is, a Feature is wrapped, +// and a bare geometry / GeometryCollection (also valid GeoJSON) is wrapped in a +// feature. Anything that is not GeoJSON returns undefined so the caller can +// fall through to the next tier of the priority chain. +function normalizeGeoJson( + json: unknown, +): GeoJSON.FeatureCollection | undefined { + if (!json || typeof json !== 'object') { + return undefined; + } + const { type } = json as { type?: unknown }; + if (type === 'FeatureCollection') { + return json as GeoJSON.FeatureCollection; + } + if (type === 'Feature') { + return { + type: 'FeatureCollection', + features: [json as GeoJSON.Feature], + }; + } + if (typeof type === 'string' && GEOJSON_GEOMETRY_TYPES.includes(type)) { + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: json as GeoJSON.Geometry, + properties: {}, + }], + }; + } + return undefined; +} + +// NOTE: Fetches a file and parses it as GeoJSON for the AOI priority chain, +// returning undefined (instead of throwing) so the caller can fall through to +// the next tier. Network/HTTP failures are logged because the URL came from the +// API and is expected to resolve; a non-GeoJSON body is a normal outcome for +// aoiGeometryInputAsset (e.g. a shapefile/KML upload), so it falls through +// quietly. +async function tryFetchAoiGeoJson( + url: string, +): Promise | undefined> { + try { + const res = await fetch(url); + if (!res.ok) { + // eslint-disable-next-line no-console + console.error('Failed fetching AOI geometry', url, res.status); + return undefined; + } + const body = await res.text(); + try { + return normalizeGeoJson(JSON.parse(body)); + } catch { + return undefined; + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed fetching AOI geometry', url, err); + return undefined; + } +} + async function getProjectData(id: string) { const projects = ( data as AllDataQuery @@ -338,6 +412,38 @@ function Project(props: Props) { transformAoiToGeoJson(aoiGeometry) ), [aoiGeometry]); + // NOTE: The map AOI is resolved from the same priority chain as the AOI + // download (aoiGeometryInputAsset -> exportAreaOfInterest -> bbox). Unlike + // the download, the map needs parsed GeoJSON, so the file-based tiers are + // fetched and parsed here, falling through to the next tier when a file is + // missing or is not valid GeoJSON, and finally to the bbox-derived feature. + const [aoiMapGeoJSON, setAoiMapGeoJSON] = useState< + GeoJSON.FeatureCollection | undefined + >(); + + useEffect(() => { + let active = true; + + async function resolveAoiMapGeoJson() { + let resolved: GeoJSON.FeatureCollection | undefined; + if (aoiGeometryInputAsset?.file?.url) { + resolved = await tryFetchAoiGeoJson(aoiGeometryInputAsset.file.url); + } + if (!resolved && exportAreaOfInterest?.file?.url) { + resolved = await tryFetchAoiGeoJson(exportAreaOfInterest.file.url); + } + if (active) { + setAoiMapGeoJSON(resolved ?? aoiGeometryFeature); + } + } + + resolveAoiMapGeoJson(); + + return () => { + active = false; + }; + }, [aoiGeometryInputAsset, exportAreaOfInterest, aoiGeometryFeature]); + // NOTE: The AOI download is resolved from a priority chain: // 1. aoiGeometryInputAsset (the original uploaded geometry; not present // for projects sourced from a tasking manager id) @@ -503,11 +609,11 @@ function Project(props: Props) { }) )} > - {aoiGeometryFeature && ( + {aoiMapGeoJSON && (
)} From f1667b3fffd926f1842938edfce68f5c020f173c Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 19 Jun 2026 07:51:26 +0545 Subject: [PATCH 2/2] feat: add geojson url support in ProjectMap --- src/components/ProjectMap/index.tsx | 70 +++++++++++++---- src/pages/[locale]/projects/[id].tsx | 110 +-------------------------- 2 files changed, 56 insertions(+), 124 deletions(-) diff --git a/src/components/ProjectMap/index.tsx b/src/components/ProjectMap/index.tsx index 68d057ad..0ccfa27a 100644 --- a/src/components/ProjectMap/index.tsx +++ b/src/components/ProjectMap/index.tsx @@ -1,4 +1,9 @@ -import React, { useRef, useCallback } from 'react'; +import React, { + useRef, + useState, + useEffect, + useCallback, +} from 'react'; import { MapContainer, TileLayer, @@ -11,17 +16,45 @@ import GestureHandler from 'components/LeafletGestureHandler'; interface Props { className?: string; children?: React.ReactNode; - geoJSON: GeoJSON.FeatureCollection; + // NOTE: URL to a GeoJSON file which the map fetches and renders. fetch + // supports data: URLs too, so a client-built fallback can be passed the + // same way as a real file URL. + geoJsonUrl: string; } function ProjectMap(props: Props) { const { className, children, - geoJSON, + geoJsonUrl, } = props; const mapRef = useRef(null); + const [geoJson, setGeoJson] = useState(); + + useEffect(() => { + // NOTE: Handle component dismount gracefully + let active = true; + + async function loadGeoJson(url: string) { + try { + const res = await fetch(url); + const data = await res.json(); + if (active) { + setGeoJson(data); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed fetching map GeoJSON', url, err); + } + } + + loadGeoJson(geoJsonUrl); + + return () => { + active = false; + }; + }, [geoJsonUrl]); const handleGeoJSONAdd = useCallback( (layer: LayerEvent) => { @@ -43,19 +76,24 @@ function ProjectMap(props: Props) { minZoom={1} worldCopyJump > - + {geoJson && ( + + )} | undefined { - if (!json || typeof json !== 'object') { - return undefined; - } - const { type } = json as { type?: unknown }; - if (type === 'FeatureCollection') { - return json as GeoJSON.FeatureCollection; - } - if (type === 'Feature') { - return { - type: 'FeatureCollection', - features: [json as GeoJSON.Feature], - }; - } - if (typeof type === 'string' && GEOJSON_GEOMETRY_TYPES.includes(type)) { - return { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: json as GeoJSON.Geometry, - properties: {}, - }], - }; - } - return undefined; -} - -// NOTE: Fetches a file and parses it as GeoJSON for the AOI priority chain, -// returning undefined (instead of throwing) so the caller can fall through to -// the next tier. Network/HTTP failures are logged because the URL came from the -// API and is expected to resolve; a non-GeoJSON body is a normal outcome for -// aoiGeometryInputAsset (e.g. a shapefile/KML upload), so it falls through -// quietly. -async function tryFetchAoiGeoJson( - url: string, -): Promise | undefined> { - try { - const res = await fetch(url); - if (!res.ok) { - // eslint-disable-next-line no-console - console.error('Failed fetching AOI geometry', url, res.status); - return undefined; - } - const body = await res.text(); - try { - return normalizeGeoJson(JSON.parse(body)); - } catch { - return undefined; - } - } catch (err) { - // eslint-disable-next-line no-console - console.error('Failed fetching AOI geometry', url, err); - return undefined; - } -} - async function getProjectData(id: string) { const projects = ( data as AllDataQuery @@ -412,38 +338,6 @@ function Project(props: Props) { transformAoiToGeoJson(aoiGeometry) ), [aoiGeometry]); - // NOTE: The map AOI is resolved from the same priority chain as the AOI - // download (aoiGeometryInputAsset -> exportAreaOfInterest -> bbox). Unlike - // the download, the map needs parsed GeoJSON, so the file-based tiers are - // fetched and parsed here, falling through to the next tier when a file is - // missing or is not valid GeoJSON, and finally to the bbox-derived feature. - const [aoiMapGeoJSON, setAoiMapGeoJSON] = useState< - GeoJSON.FeatureCollection | undefined - >(); - - useEffect(() => { - let active = true; - - async function resolveAoiMapGeoJson() { - let resolved: GeoJSON.FeatureCollection | undefined; - if (aoiGeometryInputAsset?.file?.url) { - resolved = await tryFetchAoiGeoJson(aoiGeometryInputAsset.file.url); - } - if (!resolved && exportAreaOfInterest?.file?.url) { - resolved = await tryFetchAoiGeoJson(exportAreaOfInterest.file.url); - } - if (active) { - setAoiMapGeoJSON(resolved ?? aoiGeometryFeature); - } - } - - resolveAoiMapGeoJson(); - - return () => { - active = false; - }; - }, [aoiGeometryInputAsset, exportAreaOfInterest, aoiGeometryFeature]); - // NOTE: The AOI download is resolved from a priority chain: // 1. aoiGeometryInputAsset (the original uploaded geometry; not present // for projects sourced from a tasking manager id) @@ -609,11 +503,11 @@ function Project(props: Props) { }) )} > - {aoiMapGeoJSON && ( + {aoiDownload && (
)}