diff --git a/config.json b/config.json index 76c695d7..55c675fe 100644 --- a/config.json +++ b/config.json @@ -1,23 +1,25 @@ { "base": { "municipality": "purmerend", + "assets_url": "https://meldingen.demo.meierijstad.delta10.cloud", "style": { "primaryColor": "#24578f" }, "map": { "find_address_in_distance": 30, + "minimal_zoom": 17, "center": [ - 52.3731081, - 4.8932945 + 51.6045656, + 5.5342026 ], "maxBounds": [ [ - 4.8339104, - 52.4703485 + 3.31497114423, + 50.803721015 ], [ - 5.0247001, - 52.6141075 + 7.09205325687, + 53.5104033474 ] ] }, diff --git a/src/app/[locale]/incident/add/components/FeatureListItem.tsx b/src/app/[locale]/incident/add/components/FeatureListItem.tsx new file mode 100644 index 00000000..9cd4480d --- /dev/null +++ b/src/app/[locale]/incident/add/components/FeatureListItem.tsx @@ -0,0 +1,116 @@ +import React, { Dispatch, RefObject, SetStateAction, useMemo } from 'react' +import { getFeatureType } from '@/lib/utils/map' +import { Feature, FeatureCollection } from 'geojson' +import { PublicQuestion } from '@/types/form' +import { MapRef } from 'react-map-gl/maplibre' +import { FormField, FormFieldCheckbox, Icon } from '@/components/index' +import { useTranslations } from 'next-intl' +import { FeatureWithDescription } from '@/types/map' +import { useFormStore } from '@/store/form_store' + +type FeatureListItemProps = { + feature: FeatureWithDescription + field: PublicQuestion + map: MapRef | undefined + setError: Dispatch> + dialogRef: RefObject + configUrl?: string +} + +export const FeatureListItem = ({ + feature, + field, + map, + setError, + dialogRef, + configUrl, +}: FeatureListItemProps) => { + const t = useTranslations('describe-add.map') + const { formState, updateForm } = useFormStore() + + const featureId = feature.id + const featureDescription = feature.description + const maxNumberOfAssets = field + ? field.meta.maxNumberOfAssets + ? field.meta.maxNumberOfAssets + : 1 + : 1 + + // Get feature type of asset + const featureType = useMemo(() => { + return getFeatureType(field.meta.featureTypes, feature.properties) + }, [field.meta.featureTypes, feature.properties]) + + // Add or remove feature to / from the newSelectedFeature state declared in DialogMap + const addOrRemoveFeature = (checked: boolean) => { + const newSelectedFeatureArray = Array.from( + formState.selectedFeatures ? formState.selectedFeatures : [] + ) + + if (checked) { + if (newSelectedFeatureArray.length >= maxNumberOfAssets) { + setError(t('max_number_of_assets_error', { max: maxNumberOfAssets })) + dialogRef.current?.showModal() + + return + } + + newSelectedFeatureArray.push(feature) + + if (map && feature && feature.geometry) { + map.flyTo({ + center: [ + // @ts-ignore + feature.geometry.coordinates[0], + // @ts-ignore + feature.geometry.coordinates[1], + ], + speed: 0.5, + zoom: 18, + }) + } + } else { + const index = newSelectedFeatureArray.findIndex( + (feature) => feature.id === featureId + ) + + newSelectedFeatureArray.splice(index, 1) // Remove the feature at the found index + } + + updateForm({ + ...formState, + selectedFeatures: newSelectedFeatureArray, + }) + } + + // TODO: iets van een label toevoegen zodat voor een SR duidelijk wordt om welke lantaarnpaal, adres etc het gaat? + return featureDescription ? ( +
  • + + {!formState.selectedFeatures.some( + (featureItem) => featureItem.id === featureId + ) ? ( + + + + ) : ( + + + + )} + featureItem.id === featureId + )} + id={featureId.toString()} + // @ts-ignore + onChange={(e) => addOrRemoveFeature(e.target.checked)} + /> + +
  • + ) : null +} diff --git a/src/app/[locale]/incident/add/components/IncidentQuestionsLocationForm.tsx b/src/app/[locale]/incident/add/components/IncidentQuestionsLocationForm.tsx index 3206f395..670ad853 100644 --- a/src/app/[locale]/incident/add/components/IncidentQuestionsLocationForm.tsx +++ b/src/app/[locale]/incident/add/components/IncidentQuestionsLocationForm.tsx @@ -84,9 +84,14 @@ export const IncidentQuestionsLocationForm = () => { ? id.filter((value: any) => value !== false && value !== 'empty') : [] - // If checkboxAnswers has a length, map over them to return a list of answer objects - const answer = - checkboxAnswers.length > 0 + const isFeature = checkboxAnswers.some( + (answer: any) => answer.type === 'Feature' + ) + + // If checkboxAnswers has a length, map over them to return a list of answer objects. If isFeature return list of already made answers + const answer = isFeature + ? checkboxAnswers + : checkboxAnswers.length > 0 ? checkboxAnswers.map((answerId) => ({ id: answerId, label: question.meta.values[answerId], @@ -102,7 +107,9 @@ export const IncidentQuestionsLocationForm = () => { return { id: question.key, - label: question.meta.label, + label: question.meta.shortLabel + ? question.meta.shortLabel + : question.meta.label, category_url: `/signals/v1/public/terms/categories/${formStoreState.sub_category}/sub_categories/${formStoreState.main_category}`, answer, } diff --git a/src/app/[locale]/incident/add/components/MapDialog.tsx b/src/app/[locale]/incident/add/components/MapDialog.tsx index 0f08acdb..c3e9c3f9 100644 --- a/src/app/[locale]/incident/add/components/MapDialog.tsx +++ b/src/app/[locale]/incident/add/components/MapDialog.tsx @@ -2,7 +2,9 @@ import * as Dialog from '@radix-ui/react-dialog' import React, { useEffect, useRef, useState } from 'react' import Map, { MapLayerMouseEvent, + MapRef, Marker, + MarkerEvent, useMap, ViewState, } from 'react-map-gl/maplibre' @@ -14,10 +16,8 @@ import { Icon, AlertDialog, Paragraph, + Alert, Button, - FormField, - ListboxOptionProps, - // SelectCombobox, } from '@/components' import { useConfig } from '@/hooks/useConfig' import { @@ -27,25 +27,44 @@ import { IconPlus, } from '@tabler/icons-react' import { ButtonGroup } from '@/components' -import { isCoordinateInsideMaxBound } from '@/lib/utils/map' -import { getSuggestedAddresses } from '@/services/location/address' -import { getServerConfig } from '@/services/config/config' +import { + formatAddressToSignalenInput, + getFeatureDescription, + getFeatureId, + getFeatureType, + isCoordinateInsideMaxBound, +} from '@/lib/utils/map' +import { getNearestAddressByCoordinate } from '@/services/location/address' +import { Feature, FeatureCollection } from 'geojson' +import { PublicQuestion } from '@/types/form' +import { FeatureListItem } from '@/app/[locale]/incident/add/components/FeatureListItem' +import { useFormContext } from 'react-hook-form' type MapDialogProps = { trigger: React.ReactElement + onMapReady?: (map: MapRef) => void + features?: FeatureCollection | null + field?: PublicQuestion + isAssetSelect?: boolean } & React.HTMLAttributes -const MapDialog = ({ trigger }: MapDialogProps) => { +const MapDialog = ({ + trigger, + onMapReady, + features, + field, + isAssetSelect = false, +}: MapDialogProps) => { const t = useTranslations('describe-add.map') const [marker, setMarker] = useState<[number, number] | []>([]) - const [outsideMaxBoundError, setOutsideMaxBoundError] = useState< - string | null - >(null) - const [addressOptions, setAddressOptions] = useState([]) + const [error, setError] = useState(null) const { formState, updateForm } = useFormStore() const { dialogMap } = useMap() const { loading, config } = useConfig() const dialogRef = useRef(null) + const [isMapSelected, setIsMapSelected] = useState(false) + const [mapFeatures, setMapFeatures] = useState() + const { setValue } = useFormContext() const [viewState, setViewState] = useState({ latitude: 0, @@ -96,9 +115,48 @@ const MapDialog = ({ trigger }: MapDialogProps) => { setMarker([lat, lng]) } - // Handle click on map + // Handle click on map, setIsMapSelected to true + // TODO: Reset selectedFeatures if click was right on map? (open for discussion) const handleMapClick = (event: MapLayerMouseEvent) => { updatePosition(event.lngLat.lat, event.lngLat.lng) + + setIsMapSelected(true) + } + + // Handle click on feature marker, set selectedFeatures and show error if maxNumberOfAssets is reached + const handleFeatureMarkerClick = (event: MarkerEvent, feature: Feature) => { + // @ts-ignore + const featureId = feature.id as number + const maxNumberOfAssets = field?.meta.maxNumberOfAssets || 1 + + if (dialogMap && featureId) { + const newSelectedFeatureArray = Array.from( + formState.selectedFeatures ? formState.selectedFeatures : [] + ) + + const index = newSelectedFeatureArray.findIndex( + (feature) => feature.id === featureId + ) + + if (index !== -1) { + newSelectedFeatureArray.splice(index, 1) // Remove the feature at the found index + } else { + if (newSelectedFeatureArray.length >= maxNumberOfAssets) { + setError(t('max_number_of_assets_error', { max: maxNumberOfAssets })) + dialogRef.current?.showModal() + + return + } + + newSelectedFeatureArray.push(feature) + } + + updateForm({ + ...formState, + selectedFeatures: newSelectedFeatureArray, + }) + setTimeout(() => setIsMapSelected(false), 0) + } } // set current location of user @@ -118,20 +176,88 @@ const MapDialog = ({ trigger }: MapDialogProps) => { if (isInsideMaxBound) { updatePosition(position.coords.latitude, position.coords.longitude) - setOutsideMaxBoundError(null) + setError(null) return } - setOutsideMaxBoundError(t('outside_max_bound_error')) + setError(t('outside_max_bound_error')) dialogRef.current?.showModal() }, (locationError) => { - setOutsideMaxBoundError(locationError.message) + setError(locationError.message) dialogRef.current?.showModal() } ) } + // Set dialog map in parent component + useEffect(() => { + if (dialogMap && onMapReady) { + onMapReady(dialogMap) + } + }, [dialogMap, onMapReady]) + + // Set new map features with ID + useEffect(() => { + if (features && field) { + const featuresWithId = features.features.map((feature) => { + const featureType = getFeatureType( + field.meta.featureTypes, + feature.properties + ) + + return { + ...feature, + // @ts-ignore + id: getFeatureId(featureType, feature.properties), + description: getFeatureDescription(featureType, feature.properties), + } + }) + + setMapFeatures({ ...features, features: featuresWithId }) + } + }, [features]) + + const closeMapDialog = async () => { + updateForm({ ...formState, coordinates: marker }) + + if (isAssetSelect && field) { + const formValues = await Promise.all( + formState.selectedFeatures.map(async (feature) => { + const address = await getNearestAddressByCoordinate( + // @ts-ignore + feature.geometry.coordinates[1], + // @ts-ignore + feature.geometry.coordinates[0], + config ? config.base.map.find_address_in_distance : 30 + ) + + return { + address: { + ...formatAddressToSignalenInput( + address ? address.weergavenaam : '' + ), + }, + id: feature.id?.toString(), + coordinates: { + // @ts-ignore + lat: feature.geometry.coordinates[1], + // @ts-ignore + lng: feature.geometry.coordinates[0], + }, + // @ts-ignore + description: feature.description, + // @ts-ignore + label: feature.description, + type: 'Feature', + } + }) + ) + + setValue(field.key, formValues) + } + } + return ( {trigger} @@ -140,12 +266,16 @@ const MapDialog = ({ trigger }: MapDialogProps) => { {/* TODO: Overleggen welke titel hier het meest vriendelijk is voor de gebruiker, multi-language support integreren */} - {t('dialog_title')} + + {field?.meta.language.title + ? field.meta.language.title + : t('dialog_title')} + {t('dialog_description')}
    - {outsideMaxBoundError} + {error} @@ -214,7 +363,7 @@ const MapDialog = ({ trigger }: MapDialogProps) => { attributionControl={false} maxBounds={config.base.map.maxBounds} > - {marker.length && ( + {marker.length && isMapSelected && ( { )} + {onMapReady && + dialogMap && + dialogMap.getZoom() > config.base.map.minimal_zoom && + mapFeatures?.features.map((feature) => { + const id = feature.id as number + + return ( + handleFeatureMarkerClick(e, feature)} + > + {!formState.selectedFeatures.some( + (featureItem) => featureItem.id === feature.id + ) ? ( + + + + ) : ( + + + + )} + + ) + })}
    + ) : ( + + Wijzig locatie + + ) + } + /> + +
    + + ) } diff --git a/src/app/[locale]/incident/add/components/questions/PlainText.tsx b/src/app/[locale]/incident/add/components/questions/PlainText.tsx index a95913e4..1eaa1999 100644 --- a/src/app/[locale]/incident/add/components/questions/PlainText.tsx +++ b/src/app/[locale]/incident/add/components/questions/PlainText.tsx @@ -5,7 +5,6 @@ import { Alert } from '@/components' interface PlainTextProps extends QuestionField {} export const PlainText = ({ field }: PlainTextProps) => { - // TODO: Discuss if alert is the only used PlainText type in Signalen, style Markdown return field.meta.value ? ( {field.meta.value} diff --git a/src/app/[locale]/incident/add/components/questions/RenderSingleField.tsx b/src/app/[locale]/incident/add/components/questions/RenderSingleField.tsx index b7a44e03..a2ece6ce 100644 --- a/src/app/[locale]/incident/add/components/questions/RenderSingleField.tsx +++ b/src/app/[locale]/incident/add/components/questions/RenderSingleField.tsx @@ -9,6 +9,7 @@ import { CheckboxInput } from '@/app/[locale]/incident/add/components/questions/ import { TextAreaInput } from '@/app/[locale]/incident/add/components/questions/TextAreaInput' import { LocationSelect } from '@/app/[locale]/incident/add/components/questions/LocationSelect' import { evaluateConditions } from '@/lib/utils/check-visibility' +import { AssetSelect } from '@/app/[locale]/incident/add/components/questions/AssetSelect' export const RenderSingleField = ({ field }: { field: PublicQuestion }) => { const [shouldRender, setShouldRender] = useState(false) @@ -34,8 +35,7 @@ export const RenderSingleField = ({ field }: { field: PublicQuestion }) => { ), [FieldTypes.ASSET_SELECT]: (field: PublicQuestion) => ( - // TODO: Implement Asset Select - <> + ), [FieldTypes.LOCATION_SELECT]: (field: PublicQuestion) => ( diff --git a/src/lib/utils/map.ts b/src/lib/utils/map.ts index 07719e9f..328ff46d 100644 --- a/src/lib/utils/map.ts +++ b/src/lib/utils/map.ts @@ -1,3 +1,9 @@ +import { FeatureType } from '@/types/form' +import { GeoJsonProperties } from 'geojson' + +// Validates if the argument is a coordinate pair [longitude, latitude] +// @param {unknown} arg - Input to validate +// @returns {arg is [number, number]} - Type predicate for coordinate tuple export const isCoordinates = (arg: unknown): arg is [number, number] => { return ( Array.isArray(arg) && @@ -7,6 +13,11 @@ export const isCoordinates = (arg: unknown): arg is [number, number] => { ) } +// Checks if coordinates are within specified geographical bounds +// @param {number} lat - Latitude to check +// @param {number} lng - Longitude to check +// @param {[[number, number], [number, number]]} maxBounds - Geographical boundary coordinates +// @returns {boolean} - Whether coordinates are inside bounds export const isCoordinateInsideMaxBound = ( lat: number, lng: number, @@ -17,3 +28,106 @@ export const isCoordinateInsideMaxBound = ( return lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng } + +// Matches a GeoJSON feature with its corresponding feature type +// @param {FeatureType[]} featureType - Array of possible feature types +// @param {GeoJsonProperties} properties - Properties of the GeoJSON feature +// @returns {FeatureType | null} - Matching feature type or null +export const getFeatureType = ( + featureType: FeatureType[], + properties: GeoJsonProperties +): FeatureType | null => { + if (properties) { + const featureTypes = featureType.filter((feature: FeatureType) => + properties.hasOwnProperty(feature.idField) + ) + + if (featureTypes.length) return featureTypes[0] + } + + return null +} + +// Generates a human-readable description for a geographic feature +// @param {FeatureType | null} featureType - Feature type definition +// @param {GeoJsonProperties} properties - Feature properties +// @returns {string | null} - Formatted feature description +export const getFeatureDescription = ( + featureType: FeatureType | null, + properties: GeoJsonProperties +): string | null => { + if (properties && featureType) { + const match = featureType.description.match(/{{(.*?)}}/) + + const propertyToReplace = match ? match[0] : null + const propertyInProperties = match ? match[1] : null + + if (propertyToReplace && propertyInProperties) { + const string: string = properties[propertyInProperties.trim()] + + return string + ? featureType.description.replace(propertyToReplace, string) + : `${featureType.description} - ${getFeatureId(featureType, properties)}` + } + + return `${featureType.description} - ${getFeatureId(featureType, properties)}` + } + + return null +} + +// Extracts the unique identifier for a geographic feature +// @param {FeatureType | null} featureType - Feature type definition +// @param {GeoJsonProperties} properties - Feature properties +// @returns {number | undefined} - Feature ID or undefined +export const getFeatureId = ( + featureType: FeatureType | null, + properties: GeoJsonProperties +): number | undefined => { + if (featureType) { + const idField = featureType.idField || null + + if (idField && properties && properties[idField]) { + return properties[idField] + } + + return undefined + } + + return undefined +} + +// Parses a PDOK address format into separate components +// @param {string} input - Full address string +// @returns {Object} - Parsed address components +export const formatAddressToSignalenInput = ( + input: string +): { + huisnummer?: string | null + openbare_ruimte?: string | null + postcode?: string | null + woonplaats?: string | null +} => { + if (input === '') { + return {} + } + + const [streetAndNumber, postcodeAndCity] = input + .split(',') + .map((part) => part.trim()) + + const streetMatch = streetAndNumber.match(/(.+)\s(\d+)$/) + const openbare_ruimte = streetMatch ? streetMatch[1] : null + const huisnummer = streetMatch ? streetMatch[2] : null + + const postcodeMatch = postcodeAndCity.match(/^([0-9A-Z]+)\s(.+)$/) + const postcode = postcodeMatch ? postcodeMatch[1] : null + const woonplaats = postcodeMatch ? postcodeMatch[2] : null + + return { + huisnummer, + openbare_ruimte, + postcode, + woonplaats, + } +} diff --git a/src/services/location/address.ts b/src/services/location/address.ts index ab52bda0..b9038f0d 100644 --- a/src/services/location/address.ts +++ b/src/services/location/address.ts @@ -2,6 +2,11 @@ import { axiosInstance } from '@/services/client/api-client' import { AxiosResponse } from 'axios' import { AddressCoordinateResponse, AddressSuggestResponse } from '@/types/pdok' +// Fetches suggested addresses from PDOK API based on search query and municipality +// @param {string} searchQuery - Text to search for addresses +// @param {string} municipality - Name of the municipality to filter results +// @returns {Promise} - Promise resolving to suggested addresses +// @throws {Error} - Throws an error if the request fails export const getSuggestedAddresses = async ( searchQuery: string, municipality: string @@ -19,6 +24,12 @@ export const getSuggestedAddresses = async ( } } +// Finds the nearest address to given coordinates within a specified distance +// @param {number} lat - Latitude of the reference point +// @param {number} lng - Longitude of the reference point +// @param {number} distance - Search radius in meters +// @returns {Promise} - Nearest address or null if not found +// @throws {Error} - Returns null if the request fails export const getNearestAddressByCoordinate = async ( lat: number, lng: number, @@ -35,6 +46,6 @@ export const getNearestAddressByCoordinate = async ( (docA, docB) => docA.afstand - docB.afstand )[0] } catch (error) { - throw new Error('Could not fetch address by coordinate. Please try again.') + return null } } diff --git a/src/services/location/features.ts b/src/services/location/features.ts new file mode 100644 index 00000000..acd3272d --- /dev/null +++ b/src/services/location/features.ts @@ -0,0 +1,21 @@ +import { axiosInstance } from '@/services/client/api-client' +import { AxiosResponse } from 'axios' +import { FeatureCollection } from 'geojson' + +// Fetches GeoJSON feature collection from a specified URL +// @param {string} url - Base URL for the GeoJSON endpoint +// @returns {Promise} - Promise resolving to a GeoJSON feature collection +// @throws {Error} - Throws an error if the request fails +export const getGeoJsonFeatures = async ( + url: string +): Promise => { + const axios = axiosInstance(url) + + try { + const response: AxiosResponse = await axios.get('') + + return response.data + } catch (error) { + throw new Error('Could not fetch suggested features. Please try again.') + } +} diff --git a/src/store/form_store.ts b/src/store/form_store.ts index 3fc97094..8caf9661 100644 --- a/src/store/form_store.ts +++ b/src/store/form_store.ts @@ -14,6 +14,7 @@ const initialFormState: FormStoreState = { extra_properties: [], attachments: [], isBlocking: false, + selectedFeatures: [], sig_number: '', } diff --git a/src/types/config.ts b/src/types/config.ts index 02534f12..2986ebcb 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,11 +1,13 @@ export type AppConfig = { base: { municipality: string + assets_url: string style: { primaryColor: string } map: { find_address_in_distance: number + minimal_zoom: number center: [number, number] maxBounds: [[number, number], [number, number]] } diff --git a/src/types/form.ts b/src/types/form.ts index b352c9e7..336bf3fd 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -19,6 +19,15 @@ export interface PublicQuestion field_type: FieldTypes } +export type FeatureType = { + icon: { + iconUrl: string + } + label: string + idField: string + description: string +} + export enum FormStep { STEP_1_DESCRIPTION = 1, STEP_2_ADD = 2, diff --git a/src/types/map.ts b/src/types/map.ts new file mode 100644 index 00000000..1c8cb6f4 --- /dev/null +++ b/src/types/map.ts @@ -0,0 +1,6 @@ +import { Feature } from 'geojson' + +export interface FeatureWithDescription extends Feature { + description: string + id: number +} diff --git a/src/types/stores.ts b/src/types/stores.ts index b5240f90..0a14a5eb 100644 --- a/src/types/stores.ts +++ b/src/types/stores.ts @@ -1,3 +1,5 @@ +import { Feature } from 'geojson' + type FormStoreState = { description: string main_category: string @@ -5,6 +7,7 @@ type FormStoreState = { coordinates: number[] email?: string | null phone?: string | null + selectedFeatures: Feature[] sharing_allowed?: boolean extra_properties: Array<{ answer: diff --git a/translations/en.json b/translations/en.json index 3fcec8be..a4bf8cb2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -43,7 +43,10 @@ "current_location": "My location", "close_alert_notification": "Close notification", "address_search_label": "Search by address", - "outside_max_bound_error": "Your location is outside the map bounds and is therefore not visible." + "outside_max_bound_error": "Your location is outside the map bounds and is therefore not visible.", + "max_number_of_assets_error": "You can select at most {max, plural, one {# object} other {# objects}}.", + "go_further_without_selected_object": "Continue without object", + "zoom_for_object": "Zoom in to see the objects" } }, "describe-contact": { diff --git a/translations/nl.json b/translations/nl.json index 7426df6a..1201ddce 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -43,7 +43,10 @@ "current_location": "Mijn locatie", "outside_max_bound_error": "Uw locatie valt buiten de kaart en is daardoor niet te zien.", "close_alert_notification": "Sluit melding", - "address_search_label": "Zoek op adres" + "address_search_label": "Zoek op adres", + "max_number_of_assets_error": "U kunt maximaal {max, plural, one {# object} other {# objecten}} selecteren.", + "go_further_without_selected_object": "Ga verder zonder object", + "zoom_for_object": "Zoom in om de objecten te zien" } }, "describe-contact": {