diff --git a/app/README.md b/app/README.md index 03b9a4911..abe59ac92 100644 --- a/app/README.md +++ b/app/README.md @@ -72,7 +72,13 @@ npm run dev - Change log: change log works normally with details modal. But it is not collapseable (yet). - Sidebar - is exapandable and open/closed state is preserved in localstorage - +- Locations list + - filter using search input and selecting a field to search on + - reset filters + - filters are preserved (in local storage) when refreshing the page or navigating back to it +- Location details page + - Location polygon map + - Location data card displays the location ID ## Todo @@ -101,7 +107,12 @@ npm run dev - navigration crash search - profile menu with signout - locations + - export records - location details + - data card: crash counts and comp costs + - cr3 crashes list + - noncr3 crashes list + - crash charts and widgets - create crash record - upload non-cr3 - dashboard diff --git a/app/app/locations/[location_id]/page.tsx b/app/app/locations/[location_id]/page.tsx new file mode 100644 index 000000000..9579c7bc9 --- /dev/null +++ b/app/app/locations/[location_id]/page.tsx @@ -0,0 +1,66 @@ +"use client"; +import { notFound } from "next/navigation"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import DataCard from "@/components/DataCard"; +import LocationMapCard from "@/components/LocationMapCard"; +import { useQuery } from "@/utils/graphql"; +import { GET_LOCATION } from "@/queries/location"; +import { locationSchema } from "@/schema/locationSchema"; +import { locationCardColumns } from "@/configs/locationDataCard"; + +const typename = "atd_txdot_locations"; + +export default function LocationDetailsPage({ + params, +}: { + params: { location_id: string }; +}) { + const locationId = params.location_id; + + const { data, error } = useQuery({ + query: locationId ? GET_LOCATION : null, + variables: { locationId }, + schema: locationSchema, + typename, + }); + + if (error) { + console.error(error); + } + + if (!data) { + // todo: loading spinner (would be nice to use a spinner inside cards) + return; + } + + if (data.length === 0) { + // 404 + notFound(); + } + + const location = data[0]; + + return ( + <> + + {location.description} + + + + + + Promise.resolve()} + record={location} + /> + + + + ); +} diff --git a/app/app/locations/page.tsx b/app/app/locations/page.tsx index bf6846ff6..fe787fe6f 100644 --- a/app/app/locations/page.tsx +++ b/app/app/locations/page.tsx @@ -1,4 +1,28 @@ +"use client"; +import Card from "react-bootstrap/Card"; +import AppBreadCrumb from "@/components/AppBreadCrumb"; +import { locationsListViewColumns } from "@/configs/locationsListViewColumns"; +import { locationsListViewQueryConfig } from "@/configs/locationsListViewTable"; +import TableWrapper from "@/components/TableWrapper"; +import { locationsListSchema } from "@/schema/locationsList"; + +const localStorageKey = "locationsListViewQueryConfig"; + export default function Locations() { - return

This is the locations page

; - } - \ No newline at end of file + return ( + <> + + + Locations + + + + + + ); +} diff --git a/app/components/LocationMap.tsx b/app/components/LocationMap.tsx new file mode 100644 index 000000000..6f7ecba43 --- /dev/null +++ b/app/components/LocationMap.tsx @@ -0,0 +1,79 @@ +import { useRef, useMemo } from "react"; +import MapGL, { + FullscreenControl, + NavigationControl, + MapRef, + Source, + Layer, +} from "react-map-gl"; +import { center } from "@turf/center"; +import { DEFAULT_MAP_PAN_ZOOM, DEFAULT_MAP_PARAMS } from "@/configs/map"; +import "mapbox-gl/dist/mapbox-gl.css"; +import { MapAerialSourceAndLayer } from "./MapAerialSourceAndLayer"; +import { MultiPolygon } from "@/types/geojson"; +import { LineLayerSpecification } from "mapbox-gl"; + +interface LocationMapProps { + polygon: MultiPolygon; + locationId: string; +} + +const polygonLayer: LineLayerSpecification = { + id: "location-polygon", + source: "location-polygon", + type: "line", + paint: { + "line-color": "orange", + "line-width": 4, + }, +}; + +const usePolygonFeature = (polygon: MultiPolygon, locationId: string) => + useMemo( + () => [ + { + type: "Feature", + properties: { + id: locationId, + }, + geometry: polygon, + }, + center(polygon), + ], + [polygon, locationId] + ); + +/** + * Map component which renders an editable point marker + */ +export const LocationMap = ({ polygon, locationId }: LocationMapProps) => { + const mapRef = useRef(null); + const [polygonFeature, centerFeature] = usePolygonFeature( + polygon, + locationId + ); + + return ( + e.target.resize()} + maxZoom={21} + > + + + + + + {/* add nearmap raster source and style */} + + + ); +}; diff --git a/app/components/LocationMapCard.tsx b/app/components/LocationMapCard.tsx new file mode 100644 index 000000000..6a231f229 --- /dev/null +++ b/app/components/LocationMapCard.tsx @@ -0,0 +1,21 @@ +import Card from "react-bootstrap/Card"; +import { LocationMap } from "./LocationMap"; +import { Location } from "@/types/locations"; +/** + * Card component that renders the crash map and edit controls + */ +export default function LocationMapCard({ location }: { location: Location }) { + return ( + + Location + + {location.geometry && ( + + )} + + + ); +} diff --git a/app/components/SidebarLayout.tsx b/app/components/SidebarLayout.tsx index aaca10349..abc02f4cf 100644 --- a/app/components/SidebarLayout.tsx +++ b/app/components/SidebarLayout.tsx @@ -10,7 +10,7 @@ import ListGroup from "react-bootstrap/ListGroup"; import { FaShieldHeart, FaGaugeHigh, - FaMap, + FaLocationDot, FaAngleRight, FaRightFromBracket, } from "react-icons/fa6"; @@ -110,7 +110,7 @@ export default function SidebarLayout({ children }: { children: ReactNode }) { diff --git a/app/components/TableSearchFieldSelector.tsx b/app/components/TableSearchFieldSelector.tsx index a214b7065..f4b0453d5 100644 --- a/app/components/TableSearchFieldSelector.tsx +++ b/app/components/TableSearchFieldSelector.tsx @@ -1,22 +1,16 @@ import Form from "react-bootstrap/Form"; import { TableSearchProps } from "./TableSearch"; -// todo: move to prop -const fields = [ - { label: "Crash ID", value: "record_locator" }, - { label: "Case ID", value: "case_id" }, - { label: "Address", value: "address_primary" }, -]; - export default function TableSearchFieldSelector({ searchSettings, setSearchSettings, + queryConfig, }: TableSearchProps) { // todo: bug here where changing the search field clears the search input if it hasn't been searched return ( <> Search by - {fields.map((field) => { + {queryConfig.searchFields.map((field) => { return ( >({ - + {queryConfig.filterCards?.length > 0 && ( + + )} ; +} = { + location_id: { + name: "location_id", + label: "Location ID", + }, + // todo: need to support non-lookup table relationships + // cr3_crash_count: { + // name: "cr3_crash_count", + // relationshipName: "locations_list_view" + // } +}; diff --git a/app/configs/locationDataCard.ts b/app/configs/locationDataCard.ts new file mode 100644 index 000000000..0054e585f --- /dev/null +++ b/app/configs/locationDataCard.ts @@ -0,0 +1,3 @@ +import { locationColumns } from "./locationColumns"; + +export const locationCardColumns = [locationColumns.location_id]; diff --git a/app/configs/locationsListViewColumns.tsx b/app/configs/locationsListViewColumns.tsx new file mode 100644 index 000000000..c4e768c91 --- /dev/null +++ b/app/configs/locationsListViewColumns.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { ColDataCardDef } from "@/types/types"; +import { LocationsListLocation } from "@/types/locationsList"; +import { renderNumber } from "@/utils/formHelpers"; + +export const locationsListViewColumns: ColDataCardDef[] = + [ + { + name: "location_id", + label: "Location ID", + sortable: true, + valueRenderer: (record: LocationsListLocation) => ( + + {record.location_id} + + ), + }, + { + name: "description", + label: "Location", + sortable: true, + }, + { + name: "cr3_crash_count", + label: "CR3 crashes", + sortable: true, + valueFormatter: renderNumber, + }, + { + name: "non_cr3_crash_count", + label: "Non-CR3 crashes", + sortable: true, + valueFormatter: renderNumber, + }, + ]; diff --git a/app/configs/locationsListViewTable.ts b/app/configs/locationsListViewTable.ts new file mode 100644 index 000000000..5348f542a --- /dev/null +++ b/app/configs/locationsListViewTable.ts @@ -0,0 +1,66 @@ +import { locationsListViewColumns } from "./locationsListViewColumns"; +import { QueryConfig, FilterGroup } from "@/utils/queryBuilder"; +import { DEFAULT_QUERY_LIMIT } from "@/utils/constants"; + +const columns = locationsListViewColumns.map((col) => String(col.name)); + +const locationsListViewFiltercards: FilterGroup[] = [ + { + id: "geography_filter_card", + label: "Jurisdiction", + groupOperator: "_or", + filterGroups: [ + { + id: "in_austin_full_purpose", + label: "Include outside Austin Full Purpose", + groupOperator: "_and", + enabled: true, + inverted: true, + filters: [ + { + id: "in_austin_full_purpose", + column: "council_district", + operator: "_gt", + value: 0, + }, + ], + }, + { + id: "location_group", + label: 'Include "Level 5" polygons', + groupOperator: "_and", + enabled: true, + inverted: true, + filters: [ + { + id: "location_group", + column: "location_group", + operator: "_eq", + value: 1, + }, + ], + }, + ], + }, +]; + +export const locationsListViewQueryConfig: QueryConfig = { + columns, + tableName: "locations_list_view", + limit: DEFAULT_QUERY_LIMIT, + offset: 0, + sortColName: "cr3_crash_count", + sortAsc: false, + searchFilter: { + id: "search", + value: "", + column: "location_id", + operator: "_ilike", + wildcard: true, + }, + searchFields: [ + { label: "Location ID", value: "location_id" }, + { label: "Location", value: "description" }, + ], + filterCards: locationsListViewFiltercards, +}; diff --git a/app/package-lock.json b/app/package-lock.json index 7c8ae4d7b..7fd8ef4d0 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@auth0/auth0-react": "^2.2.4", + "@turf/center": "^7.1.0", "@types/lodash": "^4.17.13", "@types/mapbox-gl": "^3.4.0", "bootstrap": "^5.3.3", @@ -740,6 +741,62 @@ "tslib": "^2.4.0" } }, + "node_modules/@turf/bbox": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.1.0.tgz", + "integrity": "sha512-PdWPz9tW86PD78vSZj2fiRaB8JhUHy6piSa/QXb83lucxPK+HTAdzlDQMTKj5okRCU8Ox/25IR2ep9T8NdopRA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.1.0", + "@turf/meta": "^7.1.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/center/-/center-7.1.0.tgz", + "integrity": "sha512-p9AvBMwNZmRg65kU27cGKHAUQnEcdz8Y7f/i5DvaMfm4e8zmawr+hzPKXaUpUfiTyLs8Xt2W9vlOmNGyH+6X3w==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "^7.1.0", + "@turf/helpers": "^7.1.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz", + "integrity": "sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.1.0.tgz", + "integrity": "sha512-ZgGpWWiKz797Fe8lfRj7HKCkGR+nSJ/5aKXMyofCvLSc2PuYJs/qyyifDPWjASQQCzseJ7AlF2Pc/XQ/3XkkuA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.1.0", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@types/geojson": { "version": "7946.0.14", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", diff --git a/app/package.json b/app/package.json index 0f3ade55a..c7c285db1 100644 --- a/app/package.json +++ b/app/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@auth0/auth0-react": "^2.2.4", + "@turf/center": "^7.1.0", "@types/lodash": "^4.17.13", "@types/mapbox-gl": "^3.4.0", "bootstrap": "^5.3.3", diff --git a/app/queries/location.ts b/app/queries/location.ts new file mode 100644 index 000000000..b9ffb4cff --- /dev/null +++ b/app/queries/location.ts @@ -0,0 +1,19 @@ +import { gql } from "graphql-request"; + +export const GET_LOCATION = gql` + query GetLocation($locationId: String!) { + atd_txdot_locations(where: { location_id: { _eq: $locationId } }) { + location_id + street_level + description + geometry + latitude + longitude + locations_list_view { + cr3_crash_count + non_cr3_crash_count + total_est_comp_cost + } + } + } +`; diff --git a/app/schema/geojsonSchema.ts b/app/schema/geojsonSchema.ts new file mode 100644 index 000000000..e03a60f2f --- /dev/null +++ b/app/schema/geojsonSchema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +const coordinateSchema = z.array(z.number()).length(2); + +export const multiPolygonSchema = z.object({ + type: z.literal("MultiPolygon"), + crs: z + .object({ + type: z.string(), + properties: z.object({}), + }) + .optional(), + coordinates: z.array(z.array(z.array(coordinateSchema))), +}); diff --git a/app/schema/locationSchema.ts b/app/schema/locationSchema.ts new file mode 100644 index 000000000..c7573e2f6 --- /dev/null +++ b/app/schema/locationSchema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { multiPolygonSchema } from "./geojsonSchema"; +import { locationsListSchema } from "./locationsList"; + +export const locationSchema = z.object({ + location_id: z.string(), + description: z.string().nullable(), + latitude: z.number().nullable(), + longitude: z.number().nullable(), + geometry: multiPolygonSchema.nullable(), + street_level: z.string().nullable(), + locations_list_view: locationsListSchema.partial().nullable(), +}); diff --git a/app/schema/locationsList.ts b/app/schema/locationsList.ts new file mode 100644 index 000000000..6131387c7 --- /dev/null +++ b/app/schema/locationsList.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const locationsListSchema = z.object({ + location_id: z.string(), + description: z.string().nullable(), + cr3_crash_count: z.number().nullable(), + non_cr3_crash_count: z.number().nullable(), +}); diff --git a/app/schema/units.ts b/app/schema/units.ts index 7e8898ad8..2848ee4e1 100644 --- a/app/schema/units.ts +++ b/app/schema/units.ts @@ -10,7 +10,7 @@ export const unitSchema = z.object({ veh_body_styl_id: z.number().nullable(), veh_make: lookupOptionSchema.nullable(), veh_make_id: z.number().nullable(), - veh_mod: lookupOptionSchema, + veh_mod: lookupOptionSchema.nullable(), veh_mod_id: z.number().nullable(), veh_mod_year: z.number().nullable(), unit_desc: lookupOptionSchema.nullable(), diff --git a/app/types/geojson.ts b/app/types/geojson.ts new file mode 100644 index 000000000..f2658292b --- /dev/null +++ b/app/types/geojson.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +import { multiPolygonSchema } from "@/schema/geojsonSchema"; + +export type MultiPolygon = z.infer; diff --git a/app/types/locations.ts b/app/types/locations.ts new file mode 100644 index 000000000..b30e19471 --- /dev/null +++ b/app/types/locations.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +import { locationSchema } from "@/schema/locationSchema"; + +export type Location = z.infer; diff --git a/app/types/locationsList.ts b/app/types/locationsList.ts new file mode 100644 index 000000000..7f8cf18c0 --- /dev/null +++ b/app/types/locationsList.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; +import { locationsListSchema } from "@/schema/locationsList"; + +export type LocationsListLocation = z.infer; diff --git a/app/utils/formHelpers.ts b/app/utils/formHelpers.ts index f65f17a49..0d3466a8b 100644 --- a/app/utils/formHelpers.ts +++ b/app/utils/formHelpers.ts @@ -54,6 +54,17 @@ const stringToNumberNullable = (value: string): number | null => { return num; }; +/** + * + * Stringify a number and coerce nulls to empty strings + */ +export const renderNumber = (value: number | null): string => { + if (value === null) { + return ""; + } + return String(value); +}; + /** * Convert truthy values to 'Yes', `null` and `undefined` to "", and * any other falsey value to "No" diff --git a/app/utils/queryBuilder.ts b/app/utils/queryBuilder.ts index 9f86e8d03..ceb58c06d 100644 --- a/app/utils/queryBuilder.ts +++ b/app/utils/queryBuilder.ts @@ -103,6 +103,14 @@ interface FilterGroupWithFilterGroups extends FilterGroupBase { // todo: actually, make filters a union of FilterGroup[] or Filter[]? seems easier to grok export type FilterGroup = FilterGroupWithFilterGroups | FilterGroupWithFilters; +/** + * Defines the fields available to be selected from the search field selector + */ +export interface SearchFilterField { + label: string; + value: string; +} + /** * Used by the date selector component to keep shorthand * `mode` buttons (YTD, 1Y, etc) in sync with the actual @@ -145,6 +153,10 @@ export interface QueryConfig { * filter when its value is not an empty string */ searchFilter: Filter; + /** + * The search fields that are available to select from when searching + */ + searchFields: SearchFilterField[]; /** * The filter settings for filtering by date. Designed to * be compatible with the DateSeletor component which uses @@ -269,7 +281,7 @@ const getWhereExp = (filterGroups: FilterGroup[]): string => { .map((filterGroup) => filterGroupToWhereExp(filterGroup)) // remove any null values, which are returned when a fitler group is empty .filter((x) => !!x); - return andExps.length > 0 ? `{ _and: [ ${andExps.join("\n")} ]}` : ""; + return andExps.length > 0 ? `{ _and: [ ${andExps.join("\n")} ]}` : "{}"; }; /** @@ -353,6 +365,7 @@ const buildQuery = ({ } const where = getWhereExp(allFilterGroups); + const queryString = BASE_QUERY_STRING.replace( "$queryName", "BuildQuery_" + tableName