From bab9948f9850af2241ffb93d27dffe9f14e0411f Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Wed, 13 Nov 2024 15:09:13 -0800 Subject: [PATCH 1/4] property map for project details --- react-app/src/App.tsx | 10 +++- .../src/components/map/InventoryLayer.tsx | 12 +++- react-app/src/components/map/ParcelMap.tsx | 53 ++++++++++++------ .../map/clusterPopup/ClusterPopup.tsx | 55 +++++++++++++------ .../src/components/projects/ProjectDetail.tsx | 25 +++++++++ .../src/components/projects/ProjectDialog.tsx | 2 - .../components/property/PropertyDetail.tsx | 2 +- .../convertProjectPropertyToPropertyGeo.ts | 36 ++++++++++++ 8 files changed, 155 insertions(+), 40 deletions(-) create mode 100644 react-app/src/utilities/convertProjectPropertyToPropertyGeo.ts diff --git a/react-app/src/App.tsx b/react-app/src/App.tsx index 3b21b2e4e..809d27fe6 100644 --- a/react-app/src/App.tsx +++ b/react-app/src/App.tsx @@ -49,7 +49,15 @@ const Router = () => { const showMap = () => ( - + ); diff --git a/react-app/src/components/map/InventoryLayer.tsx b/react-app/src/components/map/InventoryLayer.tsx index 6b0e21297..76466f5f2 100644 --- a/react-app/src/components/map/InventoryLayer.tsx +++ b/react-app/src/components/map/InventoryLayer.tsx @@ -194,6 +194,14 @@ export const InventoryLayer = (props: InventoryLayerProps) => { }), }); + // Converts mouse event on clusters to a usable Point + const convertEventToPoint = (e: MouseEvent): Point => { + return { + x: e.clientX, + y: e.clientY, + } as Point; + }; + return ( <> {/* For all cluster objects */} @@ -208,7 +216,7 @@ export const InventoryLayer = (props: InventoryLayerProps) => { eventHandlers={{ click: () => zoomOnCluster(property), mouseover: (e) => { - openClusterPopup(property, e.containerPoint); + openClusterPopup(property, convertEventToPoint(e.originalEvent)); }, mouseout: cancelOpenPopup, }} @@ -232,7 +240,7 @@ export const InventoryLayer = (props: InventoryLayerProps) => { }, ); }, - mouseover: (e) => openClusterPopup(property, e.containerPoint), + mouseover: (e) => openClusterPopup(property, convertEventToPoint(e.originalEvent)), mouseout: cancelOpenPopup, }} /> diff --git a/react-app/src/components/map/ParcelMap.tsx b/react-app/src/components/map/ParcelMap.tsx index 04cc668ba..9e97db563 100644 --- a/react-app/src/components/map/ParcelMap.tsx +++ b/react-app/src/components/map/ParcelMap.tsx @@ -34,6 +34,9 @@ type ParcelMapProps = { hideControls?: boolean; defaultZoom?: number; defaultLocation?: LatLngExpression; + overrideProperties?: PropertyGeo[]; + showClusterPopup?: boolean; + showSideBar?: boolean; } & PropsWithChildren; export const SelectedMarkerContext = createContext(null); @@ -130,10 +133,13 @@ const ParcelMap = (props: ParcelMapProps) => { loadProperties = false, popupSize, scrollOnClick, - zoomOnScroll = true, + zoomOnScroll = false, hideControls = false, defaultLocation, defaultZoom, + overrideProperties, + showClusterPopup = false, + showSideBar = false, } = props; // To access map outside of MapContainer @@ -167,6 +173,13 @@ const ParcelMap = (props: ParcelMapProps) => { } }, [data, isLoading]); + // If override properties were supplied, set them. + useEffect(() => { + if (overrideProperties) { + setProperties(overrideProperties); + } + }, [overrideProperties]); + // Loops through any array and pairs it down to a flat list of its base elements // Used here for breaking shape geography down to bounds coordinates const extractLowestElements: (arr: any[]) => [number, number][] = (arr) => { @@ -292,14 +305,16 @@ const ParcelMap = (props: ParcelMapProps) => { ], ), { - paddingBottomRight: [500, 0], // Padding for map sidebar + paddingBottomRight: showSideBar ? [500, 0] : [0, 0], // Padding for map sidebar }, ); } }, [properties]); + // Reference to containing div to help centre cluster popups + const mapBoxRef = useRef(); return ( - + {loadProperties ? : <>} { mapEventsDisabled={mapEventsDisabled} /> - {loadProperties ? ( + {loadProperties || overrideProperties ? ( { ))} {props.children} - {loadProperties ? ( - <> - - - + {loadProperties && showSideBar ? ( + + ) : ( + <> + )} + {(loadProperties || overrideProperties) && showClusterPopup ? ( + ) : ( <> )} diff --git a/react-app/src/components/map/clusterPopup/ClusterPopup.tsx b/react-app/src/components/map/clusterPopup/ClusterPopup.tsx index 9d646f3a6..89eb9f858 100644 --- a/react-app/src/components/map/clusterPopup/ClusterPopup.tsx +++ b/react-app/src/components/map/clusterPopup/ClusterPopup.tsx @@ -7,7 +7,7 @@ import { formatNumber, pidFormatter } from '@/utilities/formatters'; import { ArrowCircleLeft, ArrowCircleRight } from '@mui/icons-material'; import { Box, Grid, IconButton, Typography } from '@mui/material'; import { Point } from 'leaflet'; -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; export interface PopupState { open: boolean; @@ -23,6 +23,7 @@ export interface PopupState { interface ClusterPopupProps { popupState: PopupState; setPopupState: (stateUpdates: Partial) => void; + boundingBox?: DOMRect; } /** @@ -34,46 +35,60 @@ interface ClusterPopupProps { * @returns {JSX.Element} A React component representing the ClusterPopup. */ const ClusterPopup = (props: ClusterPopupProps) => { - const { popupState, setPopupState } = props; + const { popupState, setPopupState, boundingBox } = props; const { getLookupValueById } = useContext(LookupContext); + // Backups for when surrounding box isn't initialized + const within = boundingBox ?? { + x: 0, + y: 0, + height: 0, + width: 0, + right: 0, + left: 0, + top: 0, + bottom: 0, + }; + /** * The following block of code determines which direction and position the popup should open with. - * Depending on the screen size, it determines the quadrant of the mouse event and choses a position and offset. + * Depending on the map size, it determines the quadrant of the mouse event and choses a position and offset. */ - const screenCentre = { x: window.innerWidth / 2 - 100, y: window.innerHeight / 2 }; // -100 to account for the side menu being open + const mapCentre = { x: within.width / 2 - 100, y: within.height / 2 }; // -100 to account for the side menu being open + const mousePositionOnMap = popupState.position; + let offset: { x: number; y: number } = { x: 0, y: 0 }; - // Depending on how many properties are available, y displacement changes. 1 = -60, 2 = -180, else -220 + // Depending on how many properties are available, y displacement changes. 1 = -170, 2 = -260, else -300 const bottomYOffset = - popupState.properties.length < 3 ? (popupState.properties.length === 2 ? -180 : -60) : -220; + popupState.properties.length < 3 ? (popupState.properties.length === 2 ? -260 : -170) : -300; // Determine quadrant and set offset - const leftXOffset = 5; - const rightXOffset = -415; - const topYOffset = 80; + const leftXOffset = 0; + const rightXOffset = -400; + const topYOffset = 0; switch (true) { // Top-left quadant - case popupState.position.x <= screenCentre.x && popupState.position.y <= screenCentre.y: + case mousePositionOnMap.x <= mapCentre.x && mousePositionOnMap.y <= mapCentre.y: offset = { x: leftXOffset, y: topYOffset, }; break; // Top-right quadrant - case popupState.position.x > screenCentre.x && popupState.position.y <= screenCentre.y: + case mousePositionOnMap.x > mapCentre.x && mousePositionOnMap.y <= mapCentre.y: offset = { x: rightXOffset, y: topYOffset, }; break; // Bottom-left quadrant - case popupState.position.x <= screenCentre.x && popupState.position.y > screenCentre.y: + case mousePositionOnMap.x <= mapCentre.x && mousePositionOnMap.y > mapCentre.y: offset = { x: leftXOffset, y: bottomYOffset, }; break; // Bottom-right quadrant - case popupState.position.x > screenCentre.x && popupState.position.y > screenCentre.y: + case mousePositionOnMap.x > mapCentre.x && mousePositionOnMap.y > mapCentre.y: offset = { x: rightXOffset, y: bottomYOffset, @@ -95,16 +110,18 @@ const ClusterPopup = (props: ClusterPopupProps) => { } }, [popupState.pageIndex]); + const popupRef = useRef(); return ( { ? property.properties.Name.match(/^\d*$/) || property.properties.Name == '' ? property.properties.Address1 : property.properties.Name - : pidFormatter(property.properties.PID) ?? String(property.properties.PIN) + : property.properties.PID != null && property.properties.PID != 0 + ? pidFormatter(property.properties.PID) + : String(property.properties.PIN) } content={[ property.properties.Address1, diff --git a/react-app/src/components/projects/ProjectDetail.tsx b/react-app/src/components/projects/ProjectDetail.tsx index 068d3a0d6..fe08f77f1 100644 --- a/react-app/src/components/projects/ProjectDetail.tsx +++ b/react-app/src/components/projects/ProjectDetail.tsx @@ -52,6 +52,9 @@ import { getStatusString } from '@/constants/chesNotificationStatus'; import { NoteTypes } from '@/constants/noteTypes'; import EnhancedReferralDates from './EnhancedReferralDates'; import { NotificationType } from '@/constants/notificationTypes'; +import ParcelMap from '@/components/map/ParcelMap'; +import { PropertyGeo } from '@/hooks/api/usePropertiesApi'; +import { convertProjectPropertyToPropertyGeo } from '@/utilities/convertProjectPropertyToPropertyGeo'; interface IProjectDetail { onClose: () => void; @@ -117,6 +120,19 @@ const ProjectDetail = (props: IProjectDetail) => { return notificationItems.some((n) => types.includes(n.TemplateId)); }, [notifications]); + // Store the map properties for this project + const [mapProperties, setMapProperties] = useState([]); + // When data changes, refresh the map properties + useEffect(() => { + if (data?.parsedBody) { + setMapProperties( + data.parsedBody.ProjectProperties.map((property) => + convertProjectPropertyToPropertyGeo(property, data.parsedBody), + ), + ); + } + }, [data]); + const { ungroupedAgencies, agencyOptions } = useGroupedAgenciesApi(); interface IStatusHistoryStruct { Notes: Array; @@ -351,6 +367,15 @@ const ProjectDetail = (props: IProjectDetail) => { /> )} + { const requireNotificationAcknowledge = approvedStatus == status && status !== initialValues?.StatusId; const isAdmin = pimsUser.hasOneOfRoles([Roles.ADMIN]); - console.log('project form values', projectFormMethods.getValues()); return ( { }} onConfirm={async () => { const isValid = await projectFormMethods.trigger(); - console.log('lookupData and isValid', lookupData, isValid); if (lookupData && isValid) { const values = projectFormMethods.getValues(); submit(+initialValues.Id, { diff --git a/react-app/src/components/property/PropertyDetail.tsx b/react-app/src/components/property/PropertyDetail.tsx index e353169ee..c73df96a3 100644 --- a/react-app/src/components/property/PropertyDetail.tsx +++ b/react-app/src/components/property/PropertyDetail.tsx @@ -354,7 +354,7 @@ const PropertyDetail = (props: IPropertyDetail) => { height={'500px'} mapRef={map} movable={false} - zoomable={false} + zoomable={true} zoomOnScroll={false} popupSize="small" hideControls diff --git a/react-app/src/utilities/convertProjectPropertyToPropertyGeo.ts b/react-app/src/utilities/convertProjectPropertyToPropertyGeo.ts new file mode 100644 index 000000000..9a751ccb1 --- /dev/null +++ b/react-app/src/utilities/convertProjectPropertyToPropertyGeo.ts @@ -0,0 +1,36 @@ +import { ProjectProperty, Project } from '@/hooks/api/useProjectsApi'; +import { PropertyGeo } from '@/hooks/api/usePropertiesApi'; + +/** + * Converts a ProjectProperty object into a PropertyGeo object. + * + * @param projectProperty - The ProjectProperty object containing details about the property. + * @param project - Optional Project object providing additional project-related information. + * @returns A PropertyGeo object representing the geographical and property details. + */ +export const convertProjectPropertyToPropertyGeo = ( + projectProperty: ProjectProperty, + project?: Project, +): PropertyGeo => { + const property = projectProperty.Parcel ?? projectProperty.Building; + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [property.Location.x, property.Location.y], + }, + properties: { + Id: property.Id, + Location: property.Location, + PropertyTypeId: property.PropertyTypeId, + ClassificationId: property.ClassificationId, + Name: property.Name, + AdministrativeAreaId: property.AdministrativeAreaId, + AgencyId: property.AgencyId, + PID: property.PID, + PIN: property.PIN, + Address1: property.Address1, + ProjectStatusId: project?.StatusId, + }, + } as PropertyGeo; +}; From c11bc627d285e4c1cefe56cf5c2b4a20747b8cf7 Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Wed, 13 Nov 2024 15:09:22 -0800 Subject: [PATCH 2/4] parcel boundaries layer by default --- react-app/src/components/map/MapLayers.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/react-app/src/components/map/MapLayers.tsx b/react-app/src/components/map/MapLayers.tsx index 3ba55db56..f1b0d7fda 100644 --- a/react-app/src/components/map/MapLayers.tsx +++ b/react-app/src/components/map/MapLayers.tsx @@ -97,12 +97,25 @@ interface MapLayersProps { const MapLayers = (props: MapLayersProps) => { const { hideControls } = props; // If layer control is hidden, must still return a default tileset to use + // Also showing parcel boundaries if (hideControls) { + const parcelBoundaryLayer = LAYER_CONFIGS.landOwnership.find( + (layer) => layer.name === 'Parcel Boundaries', + ); return ( - + <> + + + ); } return ( From 2578aedb14c0eb9ced70229f6d4a5c22d088a050 Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Wed, 13 Nov 2024 15:09:45 -0800 Subject: [PATCH 3/4] clickable links for properties in table on project details --- .../DisposalPropertiesSimpleTable.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/react-app/src/components/projects/DisposalPropertiesSimpleTable.tsx b/react-app/src/components/projects/DisposalPropertiesSimpleTable.tsx index be78f1638..3e2dce11b 100644 --- a/react-app/src/components/projects/DisposalPropertiesSimpleTable.tsx +++ b/react-app/src/components/projects/DisposalPropertiesSimpleTable.tsx @@ -1,15 +1,17 @@ import { PropertyTypes } from '@/constants/propertyTypes'; import { Agency } from '@/hooks/api/useAgencyApi'; import { formatMoney, pidFormatter } from '@/utilities/formatters'; -import { Box, Typography } from '@mui/material'; -import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { Box, Typography, useTheme } from '@mui/material'; +import { DataGrid, GridCellParams, GridColDef } from '@mui/x-data-grid'; import React from 'react'; +import { Link } from 'react-router-dom'; interface IDisposalPropertiesTable { rows: Record[]; } const DisposalPropertiesTable = (props: IDisposalPropertiesTable) => { + const theme = useTheme(); const columns: GridColDef[] = [ { field: 'PropertyType', @@ -23,6 +25,19 @@ const DisposalPropertiesTable = (props: IDisposalPropertiesTable) => { row.PropertyTypeId === PropertyTypes.BUILDING && row.Address1 ? row.Address1 : pidFormatter(row.PID) ?? row.PIN, + renderCell: (params: GridCellParams) => { + const urlType = params.row.PropertyTypeId === 0 ? 'parcel' : 'building'; + return ( + + {String(params.value)} + + ); + }, }, { field: 'Agency', From 5e1284fe5fd9a14002775c7aa08f16ad15ad7856 Mon Sep 17 00:00:00 2001 From: dbarkowsky Date: Wed, 13 Nov 2024 15:16:29 -0800 Subject: [PATCH 4/4] map sidebar content fix --- react-app/src/App.tsx | 1 + react-app/src/components/map/sidebar/MapSidebar.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/react-app/src/App.tsx b/react-app/src/App.tsx index 809d27fe6..121bd9ac8 100644 --- a/react-app/src/App.tsx +++ b/react-app/src/App.tsx @@ -57,6 +57,7 @@ const Router = () => { hideControls={false} showClusterPopup showSideBar + zoomOnScroll={true} /> diff --git a/react-app/src/components/map/sidebar/MapSidebar.tsx b/react-app/src/components/map/sidebar/MapSidebar.tsx index b1f21434d..31095cb08 100644 --- a/react-app/src/components/map/sidebar/MapSidebar.tsx +++ b/react-app/src/components/map/sidebar/MapSidebar.tsx @@ -153,7 +153,9 @@ const MapSidebar = (props: MapSidebarProps) => { ? property.properties.Name.match(/^\d*$/) || property.properties.Name == '' ? property.properties.Address1 : property.properties.Name - : pidFormatter(property.properties.PID) ?? String(property.properties.PIN) + : property.properties.PID != null && property.properties.PID != 0 + ? pidFormatter(property.properties.PID) + : String(property.properties.PIN) } content={[ property.properties.Address1,