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