diff --git a/lib/interviewer/containers/Interfaces/Geospatial.tsx b/lib/interviewer/containers/Interfaces/Geospatial.tsx index 43a82629..89eb70b6 100644 --- a/lib/interviewer/containers/Interfaces/Geospatial.tsx +++ b/lib/interviewer/containers/Interfaces/Geospatial.tsx @@ -1,96 +1,131 @@ import mapboxgl from 'mapbox-gl'; import 'mapbox-gl/dist/mapbox-gl.css'; import { useEffect, useRef, useState } from 'react'; -import { type StageProps } from '../Stage'; +import type { Protocol } from '~/lib/protocol-validation/schemas/src/8.zod'; const INITIAL_ZOOM = 10; // should this be configurable? -export default function GeospatialInterface({ stage }: { stage: StageProps }) { +type GeospatialStage = Extract< + Protocol['stages'][number], + { type: 'Geospatial' } +>; + +export default function GeospatialInterface({ + stage, +}: { + stage: GeospatialStage; +}) { const mapRef = useRef(); const mapContainerRef = useRef(); - const [selectedCensusTract, setSelectedCensusTract] = useState(null); - const { center, data, token } = stage; + const [selection, setSelection] = useState(null); + const { center, token, layers, prompts } = stage; + + const filterLayer = layers.find((layer) => layer.filter); + + const currentPrompt = prompts[0]; // only one prompt for now const handleReset = () => { mapRef.current.flyTo({ zoom: INITIAL_ZOOM, center, }); - setSelectedCensusTract(null); - mapRef.current.setFilter('selectedCensusTract', ['==', 'namelsad10', '']); + + setSelection(null); + + if (filterLayer) { + mapRef.current.setFilter(filterLayer.id, ['==', filterLayer.filter, '']); + } }; useEffect(() => { mapboxgl.accessToken = token; + const dataSources = [...new Set(layers.map((layer) => layer.data))]; + mapRef.current = new mapboxgl.Map({ container: mapContainerRef.current, center, zoom: INITIAL_ZOOM, - style: 'mapbox://styles/mapbox/light-v11', + style: 'mapbox://styles/mapbox/light-v11', // should this be configurable? }); mapRef.current.on('load', () => { - mapRef.current.addSource('geojson-data', { - type: 'geojson', - data, - }); + if (!layers) return; - // census tract outlines - mapRef.current.addLayer({ - id: 'censusTractsOutlineLayer', - type: 'line', - source: 'geojson-data', - paint: { - 'line-color': 'purple', - 'line-width': 2, - }, - }); - mapRef.current.addLayer({ - id: 'censusTractsFillLayer', - type: 'fill', - source: 'geojson-data', - paint: { - 'fill-color': 'purple', - 'fill-opacity': 0.1, - }, + dataSources.forEach((dataSource) => { + mapRef.current.addSource('geojson-data', { + // hardcoded source name for now + type: 'geojson', + data: dataSource, + }); }); - mapRef.current.addLayer({ - id: 'selectedCensusTract', - type: 'fill', - source: 'geojson-data', - paint: { - 'fill-color': 'green', - 'fill-opacity': 0.5, - }, - filter: ['==', 'namelsad10', ''], + // add layers based on the protocol + layers.forEach((layer) => { + if (layer.type === 'line') { + mapRef.current?.addLayer({ + id: layer.id, + type: 'line', + source: 'geojson-data', + paint: { + 'line-color': layer.color, + 'line-width': 2, + }, + }); + } else if (layer.type === 'fill' && !layer.filter) { + mapRef.current?.addLayer({ + id: layer.id, + type: 'fill', + source: 'geojson-data', + paint: { + 'fill-color': layer.color, + 'fill-opacity': layer.opacity, + }, + }); + } else if (layer.type === 'fill' && layer.filter) { + mapRef.current?.addLayer({ + id: layer.id, + type: 'fill', + source: 'geojson-data', + paint: { + 'fill-color': layer.color, + 'fill-opacity': layer.opacity, + }, + filter: ['==', layer.filter, ''], + }); + } }); - // handle click of census tracts - mapRef.current.on('click', 'censusTractsFillLayer', (e) => { - const feature = e.features[0]; - const tractId = feature.properties.namelsad10; // census tract name prop. comes from the geojson. this will need to be configured based on the geojson - setSelectedCensusTract(tractId); - - mapRef.current.setFilter('selectedCensusTract', [ - '==', - 'namelsad10', - tractId, - ]); - }); + // if there's a prompt, configure the click event + if (currentPrompt) { + mapRef.current.on('click', currentPrompt.layer, (e) => { + const feature = e.features[0]; + const propToSelect = currentPrompt.mapVariable; // Variable from geojson data + const selected = feature.properties[propToSelect]; + setSelection(selected); + + // Apply the filter to the selection layer if it exists + filterLayer && + mapRef.current.setFilter(filterLayer.id, [ + '==', + filterLayer.filter, + selected, + ]); + }); + } }); return () => { mapRef.current.remove(); }; - }, [center, data, token]); + }, [center, currentPrompt, filterLayer, filterLayer?.filter, layers, token]); return (

Geospatial Interface

+

{currentPrompt?.text}

-

Selected: {selectedCensusTract}

+

Selected: {selection}

diff --git a/lib/protocol-validation/schemas/src/8.zod.ts b/lib/protocol-validation/schemas/src/8.zod.ts index 8a62e967..c7fe5790 100644 --- a/lib/protocol-validation/schemas/src/8.zod.ts +++ b/lib/protocol-validation/schemas/src/8.zod.ts @@ -468,7 +468,26 @@ const geospatialStage = baseStageSchema.extend({ type: z.literal('Geospatial'), center: z.tuple([z.number(), z.number()]), token: z.string(), - data: z.string(), + layers: z.array( + z + .object({ + id: z.string(), + data: z.string(), + type: z.enum(['line', 'fill']), + color: z.string(), + opacity: z.number().optional(), + filter: z.string().optional(), + }) + .strict(), + ), + prompts: z + .array( + promptSchema.extend({ + layer: z.string(), + mapVariable: z.string(), + }), + ) + .min(1), }); // Combine all stage types diff --git a/lib/test-protocol.ts b/lib/test-protocol.ts index c0358930..f2c695b3 100644 --- a/lib/test-protocol.ts +++ b/lib/test-protocol.ts @@ -8,16 +8,77 @@ export const protocol: Protocol = { label: 'Chicago Geospatial Interface', type: 'Geospatial', center: [-87.6298, 41.8781], - data: '/interviewer/ChicagoCensusTracts.geojson', token: `${env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}`, + layers: [ + { + id: 'censusTractsOutlineLayer', + data: '/interviewer/ChicagoCensusTracts.geojson', + type: 'line', + color: 'purple', + }, + { + id: 'censusTractsFillLayer', + data: '/interviewer/ChicagoCensusTracts.geojson', + type: 'fill', + color: 'purple', + opacity: 0.1, + }, + { + id: 'selectedCensusTract', + data: '/interviewer/ChicagoCensusTracts.geojson', + type: 'fill', + color: 'green', + opacity: 0.5, + filter: 'namelsad10', + }, + ], + prompts: [ + { + id: 'censusTractPrompt', + layer: 'censusTractsFillLayer', + mapVariable: 'namelsad10', // variable from geojson data + text: 'Please select a census tract in Chicago', + // TODO: connect to an alter variable + }, + ], }, { id: 'geospatial-interface-2', label: 'New York Geospatial Interface', type: 'Geospatial', center: [-74.006, 40.7128], - data: '/interviewer/NewYorkCensusTracts.geojson', token: `${env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}`, + layers: [ + { + id: 'censusTractsOutlineLayer', + data: '/interviewer/NewYorkCensusTracts.geojson', + type: 'line', + color: 'blue', + }, + { + id: 'censusTractsFillLayer', + data: '/interviewer/NewYorkCensusTracts.geojson', + type: 'fill', + color: 'blue', + opacity: 0.1, + }, + { + id: 'selectedCensusTract', + data: '/interviewer/NewYorkCensusTracts.geojson', + type: 'fill', + color: 'orange', + filter: 'NTAName', + opacity: 0.5, + }, + ], + prompts: [ + { + id: 'censusTractPrompt', + layer: 'censusTractsFillLayer', + mapVariable: 'NTAName', // variable from geojson data + text: 'Please select a census tract in New York', + }, + ], }, { id: 'anonymisation-interface',