Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ VITE_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_ENV=production
VITE_APP_TIMEZONE=America/Denver

# Optional: base URL for well detail links in map CSV export (defaults to https://ocotillo.newmexicowaterdata.org)
# VITE_OCOTILLO_PUBLIC_APP_URL=https://ocotillo.newmexicowaterdata.org

VITE_AUTHENTIK_CLIENT_ID=
VITE_AUTHENTIK_URL=
VITE_AUTHENTIK_REDIRECT_URI=
27 changes: 27 additions & 0 deletions src/components/WellShow/Contacts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ const ContactBlock = ({ contact }: { contact: IContact }) => {
{roleType}
</Typography>
)}
{contact.name && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600, display: 'block', letterSpacing: 0.3 }}
>
Contact name
</Typography>
)}
{contact.name && contact.id && (
<Typography
variant="body2"
Expand All @@ -69,6 +78,15 @@ const ContactBlock = ({ contact }: { contact: IContact }) => {
<Typography variant="body2" color="text.secondary" component="div">
{contact.organization || 'No organization listed'}
</Typography>
{emails.length > 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600, display: 'block', letterSpacing: 0.3, mt: 0.25 }}
>
Email
</Typography>
)}
{emails.map((email, idx) => (
<Typography
key={idx}
Expand All @@ -84,6 +102,15 @@ const ContactBlock = ({ contact }: { contact: IContact }) => {
{email}
</Typography>
))}
{phones.length > 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600, display: 'block', letterSpacing: 0.3, mt: 0.25 }}
>
Phone
</Typography>
)}
{phones.map((phone, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography
Expand Down
67 changes: 49 additions & 18 deletions src/pages/ocotillo/map/list.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { captureEvent } from '@/analytics/posthog'
import { Layer, Source } from 'react-map-gl'
import { useGo } from '@refinedev/core'
import { useDataProvider, useGo } from '@refinedev/core'
import type { CustomParams } from '@refinedev/core'
import { useLocation } from 'react-router'
import {
Box,
Expand Down Expand Up @@ -44,13 +45,20 @@ import {
filterLayerFeaturesBySelection,
sanitizeLayerExportFilename,
} from '@/utils/layerExport'
import { enrichMapFeaturesWithWellDetails } from '@/utils/wellMapExport'
import { buildMapExportPreferredColumnOrder } from '@/well-export/wellMapCsvExport'
import {
getSelectedPointColumnLabel,
getFeatureId,
getSelectedPointDisplayValue,
getSelectedPointColumns,
} from '@/utils/mapSelection'

function localDateStampForExport(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}

const DEFAULT_VISIBLE_LAYERS = ['ogc-latest-depth-to-water']
const VISIBLE_FEATURES_DRAWER_WIDTH = 360
const VISIBLE_FEATURES_PAGE_SIZE = 10
Expand Down Expand Up @@ -227,6 +235,9 @@ export const MapView: React.FC = () => {
captureEvent('feature_used', { feature: 'map' })
}, [])

const dataProvider = useDataProvider()
const [exportVisibleBusy, setExportVisibleBusy] = useState(false)

const location = useLocation()
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<any>(null)
Expand Down Expand Up @@ -514,7 +525,11 @@ export const MapView: React.FC = () => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${sanitizeLayerExportFilename(label)}.${suffix}`
const base = sanitizeLayerExportFilename(label)
link.download =
suffix === 'csv'
? `${base}-${localDateStampForExport()}.csv`
: `${base}.${suffix}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
Expand Down Expand Up @@ -544,26 +559,41 @@ export const MapView: React.FC = () => {
}

downloadLayerBlob(
buildLayerCsv(features),
buildLayerCsv(features, {
preferredPropertyColumnOrder: buildMapExportPreferredColumnOrder(),
}),
'text/csv;charset=utf-8;',
'csv',
label,
index
)
}

const onExportVisiblePoints = () => {
visiblePointFeaturesByLayer.forEach(({ label, features }, index) => {
exportLayerCollection(
{
type: 'FeatureCollection',
const onExportVisiblePoints = async () => {
setExportVisibleBusy(true)
try {
const ocotillo = dataProvider('ocotillo')
const customRequest = (args: CustomParams) => ocotillo.custom(args)

for (let index = 0; index < visiblePointFeaturesByLayer.length; index++) {
const { label, features } = visiblePointFeaturesByLayer[index]
const enriched = await enrichMapFeaturesWithWellDetails(
features,
},
label,
exportFormat,
index
)
})
customRequest
)
exportLayerCollection(
{
type: 'FeatureCollection',
features: enriched,
},
label,
exportFormat,
index
)
}
} finally {
setExportVisibleBusy(false)
}
}

const onExportLayer = () => {
Expand Down Expand Up @@ -1536,12 +1566,13 @@ export const MapView: React.FC = () => {
<MapExportControls
value={exportFormat}
onChange={setExportFormat}
onExport={onExportVisiblePoints}
onExport={() => {
void onExportVisiblePoints()
}}
buttonLabel="Export Visible"
selectorWidth={142}
tooltip={`Click to download the visible features for each active dataset as separate ${
exportFormat === 'csv' ? 'CSV' : 'GeoJSON'
} files.`}
disabled={exportVisibleBusy}
tooltip="Downloads one file per visible dataset. CSV and GeoJSON merge OGC properties with Ocotillo well details (full well JSON, contacts, location, and a public well detail link). Fetches the details API for each visible well id (batched)."
/>
<Typography variant="caption" color="text.secondary">
{`${paginatedVisibleFeatureGroups.start + 1}-${paginatedVisibleFeatureGroups.end} of ${paginatedVisibleFeatureGroups.total}`}
Expand Down
35 changes: 18 additions & 17 deletions src/pages/ocotillo/thing/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PictureAsPdf } from '@mui/icons-material'
import { ListPage } from '@/components/ListPage'
import { ISpring, IWell } from '@/interfaces/ocotillo'
import { displayWellSiteName, formatAppDate, formatAppDateTime } from '@/utils'
import { WellListColumnLabels } from '@/well-list/wellListColumnLabels'

export const SpringList: React.FC = () => {
const { dataGridProps } = useDataGrid<ISpring>({
Expand Down Expand Up @@ -115,7 +116,7 @@ export const WellList: React.FC = () => {
() => [
{
field: 'name',
headerName: 'Name',
headerName: WellListColumnLabels.name,
description:
'Official well identifier used in bureau records (for example county prefix and local ID).',
type: 'string',
Expand All @@ -124,7 +125,7 @@ export const WellList: React.FC = () => {
},
{
field: 'site_name',
headerName: 'Site name',
headerName: WellListColumnLabels.siteName,
description:
'Name of the monitoring site or facility associated with this well when one is recorded (NMBGMR alternate ID when present).',
type: 'string',
Expand All @@ -134,38 +135,38 @@ export const WellList: React.FC = () => {
},
{
field: 'monitoring_status',
headerName: 'Monitoring',
headerName: WellListColumnLabels.monitoring,
description:
'Whether the well is actively monitored or how monitoring is categorized in the current record.',
type: 'string',
width: 160,
},
{
field: 'created_at',
headerName: 'Created At',
headerName: WellListColumnLabels.createdAt,
description:
'Calendar date when this well record was first added to Ocotillo.',
width: 130,
valueGetter: (v: string) => formatAppDate(v),
},
{
field: 'well_status',
headerName: 'Well Status',
headerName: WellListColumnLabels.wellStatus,
description: 'Operational or administrative status of the well.',
type: 'string',
width: 150,
},
{
field: 'thing_type',
headerName: 'Type',
headerName: WellListColumnLabels.type,
description:
'Infrastructure type from the controlled vocabulary (for example water well or geothermal well).',
type: 'string',
width: 130,
},
{
field: 'aquifers',
headerName: 'Aquifers',
headerName: WellListColumnLabels.aquifers,
description:
'Aquifer systems linked to this well, summarized from association data. Sort uses the first aquifer name alphabetically among linked systems.',
minWidth: 180,
Expand All @@ -180,15 +181,15 @@ export const WellList: React.FC = () => {
},
{
field: 'release_status',
headerName: 'Release Status',
headerName: WellListColumnLabels.releaseStatus,
description:
'Whether the record is released for public viewing under data release rules.',
type: 'string',
width: 130,
},
{
field: 'well_depth',
headerName: 'Well Depth (ft)',
headerName: WellListColumnLabels.wellDepthFt,
description:
'Completed well depth from ground surface to bottom of the well in feet.',
type: 'number',
Expand All @@ -198,7 +199,7 @@ export const WellList: React.FC = () => {
},
{
field: 'hole_depth',
headerName: 'Hole Depth (ft)',
headerName: WellListColumnLabels.holeDepthFt,
description:
'Total drilled hole depth from ground surface to bottom of the borehole in feet.',
type: 'number',
Expand All @@ -208,15 +209,15 @@ export const WellList: React.FC = () => {
},
{
field: 'first_visit_date',
headerName: 'First Visit',
headerName: WellListColumnLabels.firstVisit,
description:
'Date of the bureau first recorded visit to this well when available.',
width: 130,
valueGetter: (v: string) => formatAppDate(v),
},
{
field: 'contacts',
headerName: 'Contacts',
headerName: WellListColumnLabels.contacts,
description:
'People or organizations linked to this well; open a contact from the link. Sort uses the alphabetically first linked contact name.',
minWidth: 180,
Expand Down Expand Up @@ -256,22 +257,22 @@ export const WellList: React.FC = () => {
},
{
field: 'well_completion_date',
headerName: 'Completed',
headerName: WellListColumnLabels.completed,
description: 'Reported date the well construction was completed.',
width: 130,
valueGetter: (v: string) => formatAppDate(v),
},
{
field: 'well_driller_name',
headerName: 'Driller',
headerName: WellListColumnLabels.driller,
description: 'Drilling company name when it was recorded for this well.',
type: 'string',
minWidth: 150,
flex: 1,
},
{
field: 'latitude',
headerName: 'Latitude',
headerName: WellListColumnLabels.latitude,
description:
'Latitude of the current mapped location in decimal degrees (WGS84).',
type: 'number',
Expand All @@ -284,7 +285,7 @@ export const WellList: React.FC = () => {
},
{
field: 'longitude',
headerName: 'Longitude',
headerName: WellListColumnLabels.longitude,
description:
'Longitude of the current mapped location in decimal degrees (WGS84).',
type: 'number',
Expand All @@ -297,7 +298,7 @@ export const WellList: React.FC = () => {
},
{
field: 'alternate_ids',
headerName: 'Alternate IDs',
headerName: WellListColumnLabels.alternateIds,
description:
'Identifiers from other agencies or programs that cross reference this well.',
minWidth: 160,
Expand Down
27 changes: 24 additions & 3 deletions src/utils/layerExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,37 @@ export const filterLayerFeaturesBySelection = (
})
}

export const buildLayerCsv = (features: any[]): string => {
const propertyKeys = [
export type BuildLayerCsvOptions = {
/**
* Property keys to emit first when present; remaining keys are sorted alphabetically.
* Used so map exports match the preferred Wells list / map CSV column order.
*/
preferredPropertyColumnOrder?: string[]
}

export const buildLayerCsv = (
features: any[],
options?: BuildLayerCsvOptions
): string => {
const allPropertyKeys = [
...new Set<string>(
features.flatMap((feature: any) =>
(Object.keys(feature?.properties || {}) as string[]).filter(
(key) => !key.startsWith('__')
)
)
),
].sort()
]

const preferred = (options?.preferredPropertyColumnOrder ?? []).filter((key) =>
allPropertyKeys.includes(key)
)
const preferredSet = new Set(preferred)
const rest = allPropertyKeys
.filter((key) => !preferredSet.has(key))
.sort()

const propertyKeys = [...preferred, ...rest]

const headers: string[] = [
...propertyKeys,
Expand Down
Loading
Loading