From b37431344c4d5683d9d3da2d7c37dbc505653ddd Mon Sep 17 00:00:00 2001 From: Camden Mecklem Date: Tue, 7 Apr 2026 14:18:54 -0400 Subject: [PATCH 1/3] certainty features --- .../src/components/CertaintyLayer.js | 66 +++++++++++++++++ .../src/components/MapCertaintyControl.css | 21 ++++++ .../src/components/MapCertaintyControl.js | 71 +++++++++++++++++++ packages/geospatial/src/index.js | 2 + 4 files changed, 160 insertions(+) create mode 100644 packages/geospatial/src/components/CertaintyLayer.js create mode 100644 packages/geospatial/src/components/MapCertaintyControl.css create mode 100644 packages/geospatial/src/components/MapCertaintyControl.js diff --git a/packages/geospatial/src/components/CertaintyLayer.js b/packages/geospatial/src/components/CertaintyLayer.js new file mode 100644 index 000000000..45fdf72d5 --- /dev/null +++ b/packages/geospatial/src/components/CertaintyLayer.js @@ -0,0 +1,66 @@ +// @flow + +import circle from '@turf/circle'; +import { featureCollection } from '@turf/helpers'; +import React, { useCallback, useMemo } from 'react'; +import { Layer, Source } from 'react-map-gl/maplibre'; +import _ from 'underscore'; +import MapStyles from '../utils/MapStyles'; + +type Props = { + geometry?: any, + certaintyRadius: number +}; + +/** + * Renders circles with the given certainty_radius circumference around all points in a new layer. + */ +const CertaintyLayer = (props: Props) => { + const buildCircle = useCallback((point) => ( + circle(point.coordinates, props.certaintyRadius, { units: 'kilometers', steps: 32 }) + ), [props.certaintyRadius]); + + const circles = useMemo(() => { + const features = []; + + if (props.certaintyRadius > 0 && props.geometry) { + if (props.geometry.type === 'FeatureCollection') { + _.each(props.geometry.features, (feature) => { + if (feature.geometry?.type === 'Point') { + features.push(buildCircle(feature.geometry)); + } + }); + } else if (props.geometry.type === 'GeometryCollection') { + _.each(props.geometry.geometries, (geometry) => { + if (geometry.type === 'Point') { + features.push(buildCircle(geometry)); + } + }); + } else if (props.geometry?.type === 'Point') { + features.push(buildCircle(props.geometry)); + } + } + + return featureCollection(features); + }, [props.geometry, props.certaintyRadius]); + + console.log(circles); + + return ( + + + + ); +}; + +export default CertaintyLayer; + diff --git a/packages/geospatial/src/components/MapCertaintyControl.css b/packages/geospatial/src/components/MapCertaintyControl.css new file mode 100644 index 000000000..3dece1cbe --- /dev/null +++ b/packages/geospatial/src/components/MapCertaintyControl.css @@ -0,0 +1,21 @@ +.certaintyRadiusControl { + display: inline-flex; + justify-content: center; + align-items: center; + margin: 0; + padding: 0; +} + +.certaintyRadiusControl .certaintyRadiusInputContainer { + margin: 0; + height: 20px; + position: absolute; + left: 40px; + top: -2px; + width: 100px; + background-color: white; +} + +.certaintyRadiusControl > .ui.input { + margin: 0; +} diff --git a/packages/geospatial/src/components/MapCertaintyControl.js b/packages/geospatial/src/components/MapCertaintyControl.js new file mode 100644 index 000000000..79cd4b5cd --- /dev/null +++ b/packages/geospatial/src/components/MapCertaintyControl.js @@ -0,0 +1,71 @@ +// @flow + +import cx from 'classnames'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TbCircleDashed } from 'react-icons/tb'; +import MapControl from './MapControl'; +import './MapCertaintyControl.css'; + +type Props = { + data: any, + onChange: (data: any) => void +}; + +const MapCertaintyControl = (props: Props) => { + const { t } = useTranslation(); + const [showCertaintyRadiusInput, setShowCertaintyRadiusInput] = useState(false); + + const certaintyRadius = useMemo(() => ( + props.data?.certainty_radius || 0 + ), [props.data]); + + const onCertaintyRadiusChange = useCallback((e) => { + const radius = parseInt(e.target.value, 10) || 0; + + props.onChange({ + ...props.data, + certainty_radius: radius + }); + }, [props.data, props.onChange]); + + return ( + +
+ + { showCertaintyRadiusInput && ( +
+ +
+ )} +
+
+ ); +}; + +export default MapCertaintyControl; + diff --git a/packages/geospatial/src/index.js b/packages/geospatial/src/index.js index e6ab844a9..b0a432528 100644 --- a/packages/geospatial/src/index.js +++ b/packages/geospatial/src/index.js @@ -1,11 +1,13 @@ // @flow // Components +export { default as CertaintyLayer } from './components/CertaintyLayer'; export { default as DrawControl } from './components/DrawControl'; export { default as GeoJsonLayer } from './components/GeoJsonLayer'; export { default as GeocodingControl } from './components/GeocodingControl'; export { default as LayerMenu } from './components/LayerMenu'; export { default as LocationMarkers } from './components/LocationMarkers'; +export { default as MapCertaintyControl } from './components/MapCertaintyControl'; export { default as MapControl } from './components/MapControl'; export { default as MapDraw } from './components/MapDraw'; export { default as RasterLayer } from './components/RasterLayer'; From b40afdd2f15efee50457fe5599f9ff4098c919a2 Mon Sep 17 00:00:00 2001 From: Camden Mecklem Date: Wed, 8 Apr 2026 16:13:41 -0400 Subject: [PATCH 2/3] WIP --- packages/core-data/src/utils/Typesense.js | 42 +++++++++-- .../src/components/CertaintyLayer.js | 2 - .../src/components/MapCertaintyControl.css | 21 ------ .../src/components/MapCertaintyControl.js | 71 ------------------- packages/geospatial/src/index.js | 1 - packages/geospatial/src/utils/Map.js | 50 ++++++++++++- 6 files changed, 83 insertions(+), 104 deletions(-) delete mode 100644 packages/geospatial/src/components/MapCertaintyControl.css delete mode 100644 packages/geospatial/src/components/MapCertaintyControl.js diff --git a/packages/core-data/src/utils/Typesense.js b/packages/core-data/src/utils/Typesense.js index 77af2bd7d..51a31e904 100644 --- a/packages/core-data/src/utils/Typesense.js +++ b/packages/core-data/src/utils/Typesense.js @@ -2,7 +2,7 @@ import { ObjectJs as ObjectUtils } from '@performant-software/shared-components'; import { Map as MapUtils } from '@performant-software/geospatial'; -import { feature, featureCollection, truncate } from '@turf/turf'; +import { feature, featureCollection } from '@turf/turf'; import { history } from 'instantsearch.js/es/lib/routers'; import TypesenseInstantsearchAdapter from 'typesense-instantsearch-adapter'; import _ from 'underscore'; @@ -179,6 +179,16 @@ const getGeometry = (place, path) => { return _.get(place, path); }; +/** + * Returns the properties object for the passed place/path. + * + * @param place + * @param path + */ +const getProperties = (place, path) => { + return _.get(place, path) || {}; +}; + /** * Returns the geometry URL for the passed place. * @@ -230,7 +240,8 @@ const toFeature = (record: any, item: any, geometry: any) => { type: record.type, items: [item], url: record.url, - layerId: record.layerId + layerId: record.layerId, + originalProperties: record.properties }; const id = parseInt(record.record_id, 10); @@ -310,11 +321,20 @@ const toFeatureCollection = (results: Array, path: string, options: Options * * @returns {*} */ -const getFeatures = (features, results, path, options = {}) => { +const getFeatures = ( + features, + results, + geometryPath, + propertiesPath, + options = {} +) => { const newFeatures = [...features]; - const objectPath = path.substring(0, path.lastIndexOf(ATTRIBUTE_DELIMITER)); - const geometryPath = path.substring(path.lastIndexOf(ATTRIBUTE_DELIMITER) + 1, path.length); + const objectPath = geometryPath.substring(0, geometryPath.lastIndexOf(ATTRIBUTE_DELIMITER)); + const geoJsonPath = geometryPath.substring(geometryPath.lastIndexOf(ATTRIBUTE_DELIMITER) + 1, geometryPath.length); + const originalPropertiesPath = propertiesPath + ? propertiesPath.substring(propertiesPath.lastIndexOf(ATTRIBUTE_DELIMITER) + 1, propertiesPath.length) + : null; const placeIds = []; const recordIds = []; @@ -334,9 +354,13 @@ const getFeatures = (features, results, path, options = {}) => { if (options.geometries) { geometryUrl = getGeometryUrl(place, options.geometries); } else { - geometry = getGeometry(place, geometryPath); + geometry = getGeometry(place, geoJsonPath); } + const properties = originalPropertiesPath + ? getProperties(place, originalPropertiesPath) + : null; + const include = geometryUrl || (geometry && (!options.type || geometry.type === options.type)); if (include) { @@ -350,7 +374,11 @@ const getFeatures = (features, results, path, options = {}) => { record.properties?.items.push(trimmedResult); } } else { - newFeatures.push(MapUtils.toFeature({ ...place, layerId, url: geometryUrl }, trimmedResult, geometry)); + newFeatures.push(MapUtils.toFeature({ + ...place, + layerId, + url: geometryUrl + }, trimmedResult, geometry, properties)); } } }); diff --git a/packages/geospatial/src/components/CertaintyLayer.js b/packages/geospatial/src/components/CertaintyLayer.js index 45fdf72d5..c4e9462ad 100644 --- a/packages/geospatial/src/components/CertaintyLayer.js +++ b/packages/geospatial/src/components/CertaintyLayer.js @@ -44,8 +44,6 @@ const CertaintyLayer = (props: Props) => { return featureCollection(features); }, [props.geometry, props.certaintyRadius]); - console.log(circles); - return ( .ui.input { - margin: 0; -} diff --git a/packages/geospatial/src/components/MapCertaintyControl.js b/packages/geospatial/src/components/MapCertaintyControl.js deleted file mode 100644 index 79cd4b5cd..000000000 --- a/packages/geospatial/src/components/MapCertaintyControl.js +++ /dev/null @@ -1,71 +0,0 @@ -// @flow - -import cx from 'classnames'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { TbCircleDashed } from 'react-icons/tb'; -import MapControl from './MapControl'; -import './MapCertaintyControl.css'; - -type Props = { - data: any, - onChange: (data: any) => void -}; - -const MapCertaintyControl = (props: Props) => { - const { t } = useTranslation(); - const [showCertaintyRadiusInput, setShowCertaintyRadiusInput] = useState(false); - - const certaintyRadius = useMemo(() => ( - props.data?.certainty_radius || 0 - ), [props.data]); - - const onCertaintyRadiusChange = useCallback((e) => { - const radius = parseInt(e.target.value, 10) || 0; - - props.onChange({ - ...props.data, - certainty_radius: radius - }); - }, [props.data, props.onChange]); - - return ( - -
- - { showCertaintyRadiusInput && ( -
- -
- )} -
-
- ); -}; - -export default MapCertaintyControl; - diff --git a/packages/geospatial/src/index.js b/packages/geospatial/src/index.js index b0a432528..60e4ff86e 100644 --- a/packages/geospatial/src/index.js +++ b/packages/geospatial/src/index.js @@ -7,7 +7,6 @@ export { default as GeoJsonLayer } from './components/GeoJsonLayer'; export { default as GeocodingControl } from './components/GeocodingControl'; export { default as LayerMenu } from './components/LayerMenu'; export { default as LocationMarkers } from './components/LocationMarkers'; -export { default as MapCertaintyControl } from './components/MapCertaintyControl'; export { default as MapControl } from './components/MapControl'; export { default as MapDraw } from './components/MapDraw'; export { default as RasterLayer } from './components/RasterLayer'; diff --git a/packages/geospatial/src/utils/Map.js b/packages/geospatial/src/utils/Map.js index add9aa438..cdfb91672 100644 --- a/packages/geospatial/src/utils/Map.js +++ b/packages/geospatial/src/utils/Map.js @@ -9,12 +9,55 @@ import { featureCollection } from '@turf/turf'; import _ from 'underscore'; +import circle from '@turf/circle'; const MIN_LATITUDE = -90; const MAX_LATITUDE = 90; const MIN_LONGITUDE = -180; const MAX_LONGITUDE = 180; +/** + * Returns a GeoJSON circle feature with the given center point and radius. + * @param point - The center point of the circle. + * @param radius - The radius of the circle in kilometers. + * @returns {Feature} - The GeoJSON circle feature. + */ +const buildCircle = (point, radius) => ( + circle(point.coordinates, radius, { units: 'kilometers', steps: 32 }) +); + +/** + * Returns a GeoJSON feature collection containing circles for each item in the given array. + */ +const getCertaintyCircles = ( + items, + getCertaintyRadius: (item: any) => number | undefined +) => { + const features = []; + + for (const item of items) { + if (getCertaintyRadius(item)) { + if (item.geometry?.type === 'FeatureCollection') { + for (const childFeature of item.geometry.features) { + if (childFeature.geometry?.type === 'Point') { + features.push(buildCircle(childFeature.geometry, getCertaintyRadius(item))); + } + } + } else if (item.geometry.type === 'GeometryCollection') { + for (const geometry of item.geometry.geometries) { + if (geometry.type === 'Point') { + features.push(buildCircle(geometry, getCertaintyRadius(item))); + } + } + } else if (item.geometry?.type === 'Point') { + features.push(buildCircle(item.geometry, getCertaintyRadius(item))); + } + } + } + + return featureCollection(features); +}; + /** * Adds the geo-referenced image layer to the passed map. * @@ -85,6 +128,7 @@ const removeLayer = (map, layerId) => map && map.removeLayer(layerId); * @param record * @param item * @param geometry + * @param originalProperties * * @returns {Feature map && map.removeLayer(layerId); * url: * * }>} */ -const toFeature = (record: any, item: any, geometry: any) => { +const toFeature = (record: any, item: any, geometry: any, originalProperties?: any) => { const properties = { id: record.record_id, ccode: [], @@ -110,7 +154,8 @@ const toFeature = (record: any, item: any, geometry: any) => { names: record.names?.map((toponym: string) => ({ toponym })), type: record.type, items: [item], - url: record.url + url: record.url, + originalProperties: originalProperties || {} }; const id = parseInt(record.record_id, 10); @@ -159,6 +204,7 @@ const validateCoordinates = (coordinates) => { export default { addGeoreferenceLayer, + getCertaintyCircles, getBoundingBox, removeLayer, toFeature, From f24200a43c58c27f51a23e25b81b1967cbf47a55 Mon Sep 17 00:00:00 2001 From: Camden Mecklem Date: Thu, 9 Apr 2026 14:33:37 -0400 Subject: [PATCH 3/3] DRY --- .../src/components/CertaintyLayer.js | 34 ++----------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/packages/geospatial/src/components/CertaintyLayer.js b/packages/geospatial/src/components/CertaintyLayer.js index c4e9462ad..10e503a57 100644 --- a/packages/geospatial/src/components/CertaintyLayer.js +++ b/packages/geospatial/src/components/CertaintyLayer.js @@ -1,11 +1,9 @@ // @flow -import circle from '@turf/circle'; -import { featureCollection } from '@turf/helpers'; -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { Layer, Source } from 'react-map-gl/maplibre'; -import _ from 'underscore'; import MapStyles from '../utils/MapStyles'; +import MapUtils from '../utils/Map'; type Props = { geometry?: any, @@ -16,33 +14,7 @@ type Props = { * Renders circles with the given certainty_radius circumference around all points in a new layer. */ const CertaintyLayer = (props: Props) => { - const buildCircle = useCallback((point) => ( - circle(point.coordinates, props.certaintyRadius, { units: 'kilometers', steps: 32 }) - ), [props.certaintyRadius]); - - const circles = useMemo(() => { - const features = []; - - if (props.certaintyRadius > 0 && props.geometry) { - if (props.geometry.type === 'FeatureCollection') { - _.each(props.geometry.features, (feature) => { - if (feature.geometry?.type === 'Point') { - features.push(buildCircle(feature.geometry)); - } - }); - } else if (props.geometry.type === 'GeometryCollection') { - _.each(props.geometry.geometries, (geometry) => { - if (geometry.type === 'Point') { - features.push(buildCircle(geometry)); - } - }); - } else if (props.geometry?.type === 'Point') { - features.push(buildCircle(props.geometry)); - } - } - - return featureCollection(features); - }, [props.geometry, props.certaintyRadius]); + const circles = MapUtils.getCertaintyCircles([props.geometry], () => props.certaintyRadius); return (