diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 6a9ddbbd..df165008 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "^1.0.5", "@samvera/clover-iiif": "^2.3.2", + "@turf/turf": "^6.5.0", "i18next": "^23.8.2", "lucide-react": "^0.321.0", "react-instantsearch": "^7.5.4", @@ -44,4 +45,4 @@ "tailwindcss": "^3.4.1", "vite": "^5.1.4" } -} \ No newline at end of file +} diff --git a/packages/core-data/src/components/PlaceMarker.js b/packages/core-data/src/components/PlaceMarker.js deleted file mode 100644 index 9a0ba749..00000000 --- a/packages/core-data/src/components/PlaceMarker.js +++ /dev/null @@ -1,59 +0,0 @@ -// @flow - -import { LocationMarker } from '@performant-software/geospatial'; -import React, { useCallback, useEffect, useState } from 'react'; - -type Props = { - /** - * The URL of the Core Data place record. - */ - url: string -}; - -/** - * This component renders a map marker for a given Core Data Place record. - */ -const PlaceMarker = (props: Props) => { - const [place, setPlace] = useState(); - - /** - * Converts the passed data to a feature collection and sets it on the state. - * - * @type {(function(*): void)|*} - */ - const onLoad = useCallback((data) => { - const featureCollection = { - type: 'FeatureCollection', - features: [{ - ...data, - properties: { - ...data.properties, - record_id: data.record_id - } - }] - }; - - setPlace(featureCollection); - }, []); - - /** - * Fetch the place record from the passed URL. - */ - useEffect(() => { - fetch(props.url) - .then((res) => res.json()) - .then(onLoad); - }, [props.url]); - - if (!place) { - return null; - } - - return ( - - ); -}; - -export default PlaceMarker; diff --git a/packages/core-data/src/components/PlaceMarkers.js b/packages/core-data/src/components/PlaceMarkers.js new file mode 100644 index 00000000..fc738d4e --- /dev/null +++ b/packages/core-data/src/components/PlaceMarkers.js @@ -0,0 +1,77 @@ +// @flow + +import { LocationMarkers } from '@performant-software/geospatial'; +import { feature, featureCollection } from '@turf/turf'; +import React, { + useCallback, + useEffect, + useMemo, + useState +} from 'react'; +import _ from 'underscore'; + +type Props = { + animate?: boolean, + + /** + * The URL of the Core Data place record. + */ + urls: Array +}; + +/** + * This component renders a map marker for a given Core Data Place record. + */ +const PlaceMarkers = (props: Props) => { + const [places, setPlaces] = useState([]); + + /** + * Converts the set of places into a FeatureCollection. + * + * @type {FeatureCollection} + */ + const data = useMemo(() => featureCollection(places), [places]); + + /** + * Fetches the passed URLs and converts the response to JSON. + * + * @type {function(): *} + */ + const onFetch = useCallback(() => ( + _.map(props.urls, (url) => fetch(url).then((res) => res.json())) + ), [props.urls]); + + /** + * Converts the passed list of records to Features and sets them on the state. + * + * @type {function(*): *} + */ + const onLoad = useCallback((records) => ( + _.map(records, (record) => setPlaces((prevPlaces) => [ + ...prevPlaces, + feature(record.geometry, record.properties) + ])) + ), []); + + /** + * Fetch the place record from the passed URL. + */ + useEffect(() => { + Promise + .all(onFetch()) + .then(onLoad); + }, [onFetch, onLoad]); + + if (_.isEmpty(data?.features)) { + return null; + } + + return ( + + ); +}; + +export default PlaceMarkers; diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index 4b3b0b77..b66872e9 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -7,7 +7,7 @@ import './index.css'; export { default as LoadAnimation } from './components/LoadAnimation'; export { default as MediaGallery } from './components/MediaGallery'; export { default as PlaceDetailsPanel } from './components/PlaceDetailsPanel'; -export { default as PlaceMarker } from './components/PlaceMarker'; +export { default as PlaceMarkers } from './components/PlaceMarkers'; export { default as PlaceResultsList } from './components/PlaceResultsList'; export { default as RelatedItemsList } from './components/RelatedItemsList'; export { default as RelatedList } from './components/RelatedList'; diff --git a/packages/geospatial/src/components/LocationMarker.js b/packages/geospatial/src/components/LocationMarkers.js similarity index 82% rename from packages/geospatial/src/components/LocationMarker.js rename to packages/geospatial/src/components/LocationMarkers.js index 8331cccd..ba8d59e3 100644 --- a/packages/geospatial/src/components/LocationMarker.js +++ b/packages/geospatial/src/components/LocationMarkers.js @@ -6,6 +6,11 @@ import { DEFAULT_FILL_STYLE, DEFAULT_POINT_STYLE, DEFAULT_STROKE_STYLE } from '. import MapUtils from '../utils/Map'; type Props = { + /** + * If `true`, the point marker will display with a pulsing animation. + */ + animate?: boolean, + /** * The number of miles to buffer the GeoJSON data. */ @@ -37,7 +42,7 @@ const DEFAULT_BUFFER = 2; /** * This component renders a location marker to be used in a Peripleo context. */ -const LocationMarker = (props: Props) => { +const LocationMarkers = (props: Props) => { const map = useMap(); /** @@ -52,12 +57,12 @@ const LocationMarker = (props: Props) => { return ( <> - + { props.animate && ( + + )} { ); }; -LocationMarker.defaultProps = { +LocationMarkers.defaultProps = { buffer: DEFAULT_BUFFER, fillStyle: DEFAULT_FILL_STYLE, pointStyle: DEFAULT_POINT_STYLE, strokeStyle: DEFAULT_STROKE_STYLE }; -export default LocationMarker; +export default LocationMarkers; diff --git a/packages/geospatial/src/index.js b/packages/geospatial/src/index.js index 5b4bcf4f..6248dc3d 100644 --- a/packages/geospatial/src/index.js +++ b/packages/geospatial/src/index.js @@ -4,7 +4,7 @@ export { default as DrawControl } from './components/DrawControl'; export { default as GeoJsonLayer } from './components/GeoJsonLayer'; export { default as LayerMenu } from './components/LayerMenu'; -export { default as LocationMarker } from './components/LocationMarker'; +export { default as LocationMarkers } from './components/LocationMarkers'; 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/storybook/.storybook/middleware.js b/packages/storybook/.storybook/middleware.js index 05c3f251..a4208dd9 100644 --- a/packages/storybook/.storybook/middleware.js +++ b/packages/storybook/.storybook/middleware.js @@ -1,10 +1,11 @@ // @flow -const bodyParser = require('body-parser'); -const ControlledVocabulary = require('./routes/ControlledVocabulary'); -const dotenv = require('dotenv'); -const UserDefinedFields = require('./routes/UserDefinedFields'); -const ZoteroTranslate = require('./routes/ZoteroTranslate'); +import bodyParser from 'body-parser'; +import dotenv from 'dotenv'; +import ControlledVocabulary from './routes/ControlledVocabulary'; +import CoreData from './routes/CoreData'; +import UserDefinedFields from './routes/UserDefinedFields'; +import ZoteroTranslate from './routes/ZoteroTranslate'; // Configure environment variables dotenv.config(); @@ -14,8 +15,9 @@ const expressMiddleWare = (router) => { router.use(bodyParser.json()); ControlledVocabulary.addRoutes(router); + CoreData.addRoutes(router); UserDefinedFields.addRoutes(router); ZoteroTranslate.addRoutes(router); }; -module.exports = expressMiddleWare; +export default expressMiddleWare; diff --git a/packages/storybook/.storybook/routes/CoreData.js b/packages/storybook/.storybook/routes/CoreData.js new file mode 100644 index 00000000..18b537c8 --- /dev/null +++ b/packages/storybook/.storybook/routes/CoreData.js @@ -0,0 +1,132 @@ +// @flow + +import { faker } from '@faker-js/faker'; +import _ from 'underscore'; +import StateBoundaries from '../../src/data/StateBoundaries.json'; + +/** + * Returns the min/max latitude/longitude for the passed state. If no state is provided, we'll compute + * the boundary of all states. + * + * @param state + * + * @returns {{max_lat, min_lng, min_lat, max_lng}|*} + */ +const getBoundary = (state = null) => { + if (state) { + return StateBoundaries[state]; + } + + let minLatitude; + let maxLatitude; + let minLongitude; + let maxLongitude; + + const keys = _.keys(StateBoundaries); + _.each(keys, (key) => { + const data = StateBoundaries[key]; + + if (!minLatitude || data.min_lat < minLatitude) { + minLatitude = data.min_lat; + } + + if (!maxLatitude || data.max_lat > maxLatitude) { + maxLatitude = data.max_lat; + } + + if (!minLongitude || data.min_lng < minLongitude) { + minLongitude = data.min_lng; + } + + if (!maxLongitude || data.max_lng > maxLongitude) { + maxLongitude = data.max_lng; + } + }); + + return { + min_lat: minLatitude, + max_lat: maxLatitude, + min_lng: minLongitude, + max_lng: maxLongitude + }; +}; + +/** + * Creates a sample place record in the Linked Places format. + * + * @returns {{ + * names: [{ + * toponym: string + * }], + * geometry: { + * coordinates: number[], + * type: string + * }, + * '@id': string, + * type: string, + * properties: { + * ccode: *[], + * record_id: string, + * title: string, + * uuid: string + * } + * }} + */ +const createPlace = () => { + const uuid = faker.string.uuid(); + const title = faker.location.city(); + + const boundary = getBoundary(); + const latitude = faker.location.latitude({ min: boundary.min_lat, max: boundary.max_lat }); + const longitude = faker.location.longitude({ min: boundary.min_lng, max: boundary.max_lng }); + + return { + '@id': `https://example.com/${uuid}`, + type: 'Place', + properties: { + ccode: [], + record_id: faker.string.numeric(7), + title, + uuid + }, + geometry: { + type: 'Point', + coordinates: [longitude, latitude] + }, + names: [{ + toponym: title + }] + }; +}; + +/** + * Adds the `/core_data/places` and `/core_data/places/:id` routes. + * + * @param router + */ +const addRoutes = (router) => { + router.get('/core_data/places', (request, response) => { + const { query } = request; + + let number = 1; + if (query.number) { + number = query.number; + } + + const places = []; + + _.times(number, () => places.push(createPlace())); + + response.send(places); + response.end(); + }); + + router.get('/core_data/places/:id', (request, response) => { + response.send(createPlace()); + response.end(); + }); +}; + +export default { + addRoutes +}; diff --git a/packages/storybook/src/core-data/PlaceMarker.stories.js b/packages/storybook/src/core-data/PlaceMarker.stories.js index 2829d5e8..a91a4274 100644 --- a/packages/storybook/src/core-data/PlaceMarker.stories.js +++ b/packages/storybook/src/core-data/PlaceMarker.stories.js @@ -3,16 +3,15 @@ import { Map, Zoom } from '@peripleo/maplibre'; import { Controls, Peripleo } from '@peripleo/peripleo'; import React from 'react'; +import _ from 'underscore'; import mapStyle from '../data/MapStyles.json'; -import PlaceMarker from '../../../core-data/src/components/PlaceMarker'; +import PlaceMarkers from '../../../core-data/src/components/PlaceMarkers'; export default { title: 'Components/Core Data/PlaceMarker', - component: PlaceMarker + component: PlaceMarkers }; -const PLACE_URL = 'https://core-data-cloud-staging-2c51db0617a5.herokuapp.com/core_data/public/places/3aaf97a4-7052-4e2c-9056-4f4146ef0c87?project_ids=10'; - export const Default = () => ( ( height: '300px' }} > - + + + +); + +export const MultiplePlaces = () => ( + + + + + +
+ index), (index) => `/core_data/places/${index}`)} />
diff --git a/packages/storybook/src/data/GeometryCollection.json b/packages/storybook/src/data/GeometryCollection.json new file mode 100644 index 00000000..6796a200 --- /dev/null +++ b/packages/storybook/src/data/GeometryCollection.json @@ -0,0 +1,106 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [ + 0.7754102888268619, + 46.680527155197694 + ], + [ + -0.7143321261315805, + 47.03810834128467 + ], + [ + -0.3687402400222197, + 45.29285985154556 + ], + [ + 1.7117022472057783, + 44.71076017077044 + ], + [ + 3.9138327406320172, + 45.815242131256525 + ], + [ + 3.6308350314942572, + 47.41785065445441 + ], + [ + 0.7754102888268619, + 46.680527155197694 + ] + ] + ], + "type": "Polygon" + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + 2.7751103745984835, + 47.98172986149493 + ], + [ + 6.829375470262562, + 48.14587129044554 + ], + [ + 6.414900417682219, + 47.169510642817755 + ], + [ + 5.760540227314522, + 46.41341938732728 + ], + [ + 6.390732145726702, + 45.59058409048566 + ] + ], + "type": "LineString" + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + -1.0759198798764373, + 48.04154361939712 + ], + "type": "Point" + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + 0.790629166637757, + 48.96345132277722 + ], + "type": "Point" + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + 4.3607542958423835, + 44.63510909496094 + ], + "type": "Point" + } + } + ] +} \ No newline at end of file diff --git a/packages/storybook/src/data/StateBoundaries.json b/packages/storybook/src/data/StateBoundaries.json new file mode 100644 index 00000000..68a69faf --- /dev/null +++ b/packages/storybook/src/data/StateBoundaries.json @@ -0,0 +1,402 @@ +{ + "AK": + { + "name": "Alaska", + "min_lat": 52.5964, + "max_lat": 71.5232, + "min_lng": -169.9146, + "max_lng": -129.993 + }, + "AL": + { + "name": "Alabama", + "min_lat": 30.1463, + "max_lat": 35.0041, + "min_lng": -88.4743, + "max_lng": -84.8927 + }, + "AR": + { + "name": "Arkansas", + "min_lat": 33.0075, + "max_lat": 36.4997, + "min_lng": -94.6198, + "max_lng": -89.6594 + }, + "AZ": + { + "name": "Arizona", + "min_lat": 31.3325, + "max_lat": 37.0004, + "min_lng": -114.8126, + "max_lng": -109.0475 + }, + "CA": + { + "name": "California", + "min_lat": 32.5121, + "max_lat": 42.0126, + "min_lng": -124.6509, + "max_lng": -114.1315 + }, + "CO": + { + "name": "Colorado", + "min_lat": 36.9949, + "max_lat": 41.0006, + "min_lng": -109.0489, + "max_lng": -102.0424 + }, + "CT": + { + "name": "Connecticut", + "min_lat": 40.9509, + "max_lat": 42.0511, + "min_lng": -73.7272, + "max_lng": -71.7874 + }, + "DE": + { + "name": "Delaware", + "min_lat": 38.4482, + "max_lat": 39.8296, + "min_lng": -75.7919, + "max_lng": -74.8526 + }, + "FL": + { + "name": "Florida", + "min_lat": 24.3959, + "max_lat": 31.0035, + "min_lng": -87.6256, + "max_lng": -79.8198 + }, + "GA": + { + "name": "Georgia", + "min_lat": 30.3575, + "max_lat": 34.9996, + "min_lng": -85.6082, + "max_lng": -80.696 + }, + "HI": + { + "name": "Hawaii", + "min_lat": 18.71, + "max_lat": 22.3386, + "min_lng": -160.3922, + "max_lng": -154.6271 + }, + "IA": + { + "name": "Iowa", + "min_lat": 40.3622, + "max_lat": 43.5008, + "min_lng": -96.6357, + "max_lng": -90.1538 + }, + "ID": + { + "name": "Idaho", + "min_lat": 41.9871, + "max_lat": 49.0018, + "min_lng": -117.2372, + "max_lng": -111.0471 + }, + "IL": + { + "name": "Illinois", + "min_lat": 36.9894, + "max_lat": 42.5116, + "min_lng": -91.512, + "max_lng": -87.0213 + }, + "IN": + { + "name": "Indiana", + "min_lat": 37.7718, + "max_lat": 41.7611, + "min_lng": -88.098, + "max_lng": -84.809 + }, + "KS": + { + "name": "Kansas", + "min_lat": 36.9927, + "max_lat": 40.0087, + "min_lng": -102.0506, + "max_lng": -94.6046 + }, + "KY": + { + "name": "Kentucky", + "min_lat": 36.4931, + "max_lat": 39.1439, + "min_lng": -89.5372, + "max_lng": -82.0308 + }, + "LA": + { + "name": "Louisiana", + "min_lat": 28.8832, + "max_lat": 33.0225, + "min_lng": -94.043, + "max_lng": -88.7421 + }, + "MA": + { + "name": "Massachusetts", + "min_lat": 41.159, + "max_lat": 42.889, + "min_lng": -73.5081, + "max_lng": -69.7398 + }, + "MD": + { + "name": "Maryland", + "min_lat": 37.8889, + "max_lat": 39.722, + "min_lng": -79.4861, + "max_lng": -74.8581 + }, + "ME": + { + "name": "Maine", + "min_lat": 42.9182, + "max_lat": 47.455, + "min_lng": -71.0829, + "max_lng": -66.8628 + }, + "MI": + { + "name": "Michigan", + "min_lat": 41.6965, + "max_lat": 48.3042, + "min_lng": -90.4175, + "max_lng": -82.1221 + }, + "MN": + { + "name": "Minnesota", + "min_lat": 43.5008, + "max_lat": 49.3877, + "min_lng": -97.2304, + "max_lng": -89.4919 + }, + "MO": + { + "name": "Missouri", + "min_lat": 35.9958, + "max_lat": 40.6181, + "min_lng": -95.7527, + "max_lng": -89.1005 + }, + "MS": + { + "name": "Mississippi", + "min_lat": 30.0905, + "max_lat": 35.0075, + "min_lng": -91.6589, + "max_lng": -88.0994 + }, + "MT": + { + "name": "Montana", + "min_lat": 44.3563, + "max_lat": 48.9991, + "min_lng": -116.0458, + "max_lng": -104.0186 + }, + "NC": + { + "name": "North Carolina", + "min_lat": 33.7666, + "max_lat": 36.588, + "min_lng": -84.3201, + "max_lng": -75.4129 + }, + "ND": + { + "name": "North Dakota", + "min_lat": 45.934, + "max_lat": 48.9982, + "min_lng": -104.0501, + "max_lng": -96.5671 + }, + "NE": + { + "name": "Nebraska", + "min_lat": 39.9992, + "max_lat": 43.0006, + "min_lng": -104.0543, + "max_lng": -95.3091 + }, + "NH": + { + "name": "New Hampshire", + "min_lat": 42.6986, + "max_lat": 45.3058, + "min_lng": -72.5592, + "max_lng": -70.5583 + }, + "NJ": + { + "name": "New Jersey", + "min_lat": 38.8472, + "max_lat": 41.3593, + "min_lng": -75.5708, + "max_lng": -73.8885 + }, + "NM": + { + "name": "New Mexico", + "min_lat": 31.3337, + "max_lat": 36.9982, + "min_lng": -109.0489, + "max_lng": -103.0023 + }, + "NV": + { + "name": "Nevada", + "min_lat": 35.003, + "max_lat": 42.0003, + "min_lng": -120.0037, + "max_lng": -114.0436 + }, + "NY": + { + "name": "New York", + "min_lat": 40.4772, + "max_lat": 45.0153, + "min_lng": -79.7624, + "max_lng": -71.7517 + }, + "OH": + { + "name": "Ohio", + "min_lat": 38.3761, + "max_lat": 42.321, + "min_lng": -84.8172, + "max_lng": -80.5188 + }, + "OK": + { + "name": "Oklahoma", + "min_lat": 33.6386, + "max_lat": 37.0015, + "min_lng": -103.0064, + "max_lng": -94.4357 + }, + "OR": + { + "name": "Oregon", + "min_lat": 41.9952, + "max_lat": 46.2891, + "min_lng": -124.7305, + "max_lng": -116.4606 + }, + "PA": + { + "name": "Pennsylvania", + "min_lat": 39.7199, + "max_lat": 42.5167, + "min_lng": -80.5243, + "max_lng": -74.707 + }, + "RI": + { + "name": "Rhode Island", + "min_lat": 41.1849, + "max_lat": 42.0156, + "min_lng": -71.9041, + "max_lng": -71.0541 + }, + "SC": + { + "name": "South Carolina", + "min_lat": 32.0453, + "max_lat": 35.2075, + "min_lng": -83.3588, + "max_lng": -78.4836 + }, + "SD": + { + "name": "South Dakota", + "min_lat": 42.4772, + "max_lat": 45.9435, + "min_lng": -104.0529, + "max_lng": -96.438 + }, + "TN": + { + "name": "Tennessee", + "min_lat": 34.9884, + "max_lat": 36.6871, + "min_lng": -90.3131, + "max_lng": -81.6518 + }, + "TX": + { + "name": "Texas", + "min_lat": 25.8419, + "max_lat": 36.5008, + "min_lng": -106.6168, + "max_lng": -93.5074 + }, + "UT": + { + "name": "Utah", + "min_lat": 36.9982, + "max_lat": 41.9993, + "min_lng": -114.0504, + "max_lng": -109.0462 + }, + "VA": + { + "name": "Virginia", + "min_lat": 36.5427, + "max_lat": 39.4659, + "min_lng": -83.6753, + "max_lng": -74.9707 + }, + "VT": + { + "name": "Vermont", + "min_lat": 42.7289, + "max_lat": 45.0153, + "min_lng": -73.4381, + "max_lng": -71.4949 + }, + "WA": + { + "name": "Washington", + "min_lat": 45.5439, + "max_lat": 49.0027, + "min_lng": -124.8679, + "max_lng": -116.9165 + }, + "WI": + { + "name": "Wisconsin", + "min_lat": 42.4954, + "max_lat": 47.31, + "min_lng": -92.8564, + "max_lng": -86.2523 + }, + "WV": + { + "name": "West Virginia", + "min_lat": 37.1953, + "max_lat": 40.6338, + "min_lng": -82.6392, + "max_lng": -77.731 + }, + "WY": + { + "name": "Wyoming", + "min_lat": 40.9986, + "max_lat": 44.9998, + "min_lng": -111.0539, + "max_lng": -104.0556 + } +} \ No newline at end of file diff --git a/packages/storybook/src/geospatial/LocationMarker.stories.js b/packages/storybook/src/geospatial/LocationMarker.stories.js index 0e6c37a6..c4f6a027 100644 --- a/packages/storybook/src/geospatial/LocationMarker.stories.js +++ b/packages/storybook/src/geospatial/LocationMarker.stories.js @@ -3,12 +3,13 @@ import { Peripleo, Controls } from '@peripleo/peripleo'; import { Map, Zoom } from '@peripleo/maplibre'; import React from 'react'; -import LocationMarker from '../../../geospatial/src/components/LocationMarker'; +import geometryCollection from '../data/GeometryCollection.json'; +import LocationMarkers from '../../../geospatial/src/components/LocationMarkers'; import mapStyle from '../data/MapStyles.json'; export default { title: 'Components/Geospatial/LocationMarker', - component: LocationMarker + component: LocationMarkers }; export const Default = () => ( @@ -27,7 +28,38 @@ export const Default = () => ( height: '300px' }} > - + + +
+); + +export const Animation = () => ( + + + + + +
+ ( height: '300px' }} > - ( ); + +export const MultiplePoints = () => ( + + + + + +
+ +
+
+
+); diff --git a/packages/storybook/vite.config.js b/packages/storybook/vite.config.js index f2c0fcd8..ec66344a 100644 --- a/packages/storybook/vite.config.js +++ b/packages/storybook/vite.config.js @@ -12,6 +12,9 @@ export default defineConfig(() => ({ }, optimizeDeps: { esbuildOptions: { + loader: { + '.js': 'jsx', + }, plugins: [esbuildFlowPlugin()] } },