Improve operational GIS map workflow
This commit is contained in:
@@ -8,6 +8,10 @@ The shell has a calmer V1 workbench density pass: the duplicated workspace comma
|
||||
|
||||
Data and Map have an additional usability layout pass for the core daily workflow. The Data workspace uses compact catalog cards, shorter action buttons and tighter role summaries. The Map workspace prioritizes the MapLibre frame before dense controls, gives the map more height on desktop and compresses context/provenance/bbox extraction surfaces while preserving the existing selection, export and QA actions.
|
||||
|
||||
The Map workspace defaults to an OpenStreetMap road basemap with visible attribution so uploaded vectors, AOIs and QA overlays appear on a real street context. Set `VITE_MAP_STYLE_URL` to a managed MapLibre style URL to override this for production or high-volume deployments.
|
||||
|
||||
Operational GIS testing is now available directly in the Map workspace. Users can choose a persisted vector database layer, load it on the map, reuse the selected AOI or active layer extent, and run the existing persisted `vector_features` bbox query without creating fake data or a parallel backend path.
|
||||
|
||||
QA/QC and Exports follow the same calmer density model. QA/QC keeps metric evidence, feature ids and raw findings available but compresses provenance and history surfaces so review starts from the selected check and map evidence actions. Exports uses denser handoff cards, latest-artifact cards and history filters so artifact creation and download paths are easier to scan.
|
||||
|
||||
When project data loads and no dataset is selected yet, the workbench auto-opens the first ready vector dataset. This gives Data, Map and Exports an immediately usable default context while preserving explicit user selection once the user picks another dataset.
|
||||
|
||||
@@ -868,6 +868,7 @@ function App(): JSX.Element {
|
||||
mapSelectionQaResult={mapSelectionQaResult}
|
||||
latestMapSelectionQualityCheckId={latestMapSelectionQualityCheckId}
|
||||
availableMapDatasets={availableMapDatasets}
|
||||
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
|
||||
selectedFeature={selectedMapFeature}
|
||||
onSelectMapArea={setSelectedMapAreaId}
|
||||
onOpenDatasetInMap={openDatasetInMap}
|
||||
|
||||
@@ -23,6 +23,30 @@ const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
|
||||
features: [],
|
||||
}
|
||||
|
||||
const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm-standard': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxzoom: 19,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-standard',
|
||||
type: 'raster',
|
||||
source: 'osm-standard',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function defaultMapStyle(): string | maplibregl.StyleSpecification {
|
||||
return import.meta.env.VITE_MAP_STYLE_URL || DEFAULT_ROAD_BASEMAP_STYLE
|
||||
}
|
||||
|
||||
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
|
||||
const coordinates: [number, number][] = []
|
||||
const walk = (coords: unknown) => {
|
||||
@@ -137,11 +161,19 @@ function GeoMap({
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: import.meta.env.VITE_MAP_STYLE_URL || 'https://demotiles.maplibre.org/style.json',
|
||||
style: defaultMapStyle(),
|
||||
center: [5.3, 51.3],
|
||||
zoom: 9,
|
||||
attributionControl: false,
|
||||
})
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
map.addControl(
|
||||
new maplibregl.AttributionControl({
|
||||
compact: true,
|
||||
customAttribution: 'Basemap © OpenStreetMap contributors',
|
||||
}),
|
||||
'bottom-right',
|
||||
)
|
||||
map.on('load', () => {
|
||||
setMapStyleReady(true)
|
||||
})
|
||||
|
||||
@@ -209,6 +209,7 @@ interface MapWorkspaceProps {
|
||||
mapSelectionQaResult: QaComparisonResult | null
|
||||
latestMapSelectionQualityCheckId: string | null
|
||||
availableMapDatasets: DatasetCreateResponse[]
|
||||
selectedMapDatasetId: string
|
||||
onSelectMapArea: (areaId: string) => void
|
||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||
onSetAreaLayerVisible: (visible: boolean) => void
|
||||
@@ -265,6 +266,7 @@ export function MapWorkspace({
|
||||
mapSelectionQaResult,
|
||||
latestMapSelectionQualityCheckId,
|
||||
availableMapDatasets,
|
||||
selectedMapDatasetId,
|
||||
onSelectMapArea,
|
||||
onOpenDatasetInMap,
|
||||
onSetAreaLayerVisible,
|
||||
@@ -305,6 +307,7 @@ export function MapWorkspace({
|
||||
featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature',
|
||||
)
|
||||
const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson`
|
||||
const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
setBboxInput(bboxToInputState(mapSelectionBbox))
|
||||
@@ -384,6 +387,22 @@ export function MapWorkspace({
|
||||
onDeriveMapSelectionDataset(bbox)
|
||||
}
|
||||
|
||||
const openSelectedDatabaseLayer = (datasetId: string) => {
|
||||
const dataset = availableMapDatasets.find((item) => item.id === datasetId)
|
||||
if (dataset) {
|
||||
onOpenDatasetInMap(dataset)
|
||||
}
|
||||
}
|
||||
|
||||
const runQuickAoiExtract = () => {
|
||||
const bbox = selectedAreaBbox ?? activeLayerBbox
|
||||
if (!bbox) {
|
||||
return
|
||||
}
|
||||
setSelectionBbox(bbox)
|
||||
onRunMapSelectionExtract(bbox)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="map-workspace-shell" data-testid="map-workspace">
|
||||
<div className="panel-title-row">
|
||||
@@ -419,6 +438,22 @@ export function MapWorkspace({
|
||||
|
||||
<div className="map-control-surface" aria-label="Map workspace controls">
|
||||
<div className="map-toolbar">
|
||||
<label>
|
||||
Database layer
|
||||
<select
|
||||
value={selectedMapDatasetId}
|
||||
onChange={(event) => openSelectedDatabaseLayer(event.target.value)}
|
||||
disabled={availableMapDatasets.length === 0}
|
||||
data-testid="map-database-layer-select"
|
||||
>
|
||||
<option value="">Select persisted vector layer</option>
|
||||
{availableMapDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{dataset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Area
|
||||
<select
|
||||
@@ -483,6 +518,7 @@ export function MapWorkspace({
|
||||
</div>
|
||||
<div className="map-status">
|
||||
<strong>{mapLayerLabel}</strong>
|
||||
<span>{selectedMapDataset ? `DB layer: ${selectedMapDataset.name}` : 'No database layer selected'}</span>
|
||||
<span>{areaFeatureCollection ? `${areaFeatureCount} AOI loaded` : 'No AOI loaded'}</span>
|
||||
<span>{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector/result layer loaded'}</span>
|
||||
</div>
|
||||
@@ -572,6 +608,63 @@ export function MapWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="map-inspection-surface">
|
||||
<div className="gis-test-run-surface" aria-label="Operational GIS test run">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<p className="eyebrow">Operational GIS run</p>
|
||||
<h3>Database selection test</h3>
|
||||
</div>
|
||||
<span className="count-pill">{mapSelectionResult ? `${mapSelectionResult.feature_count} hits` : 'ready'}</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Select a persisted vector layer, then query stored vector_features from PostGIS with the AOI or layer extent.
|
||||
</p>
|
||||
<div className="gis-test-run-grid">
|
||||
<div>
|
||||
<span>Database layer</span>
|
||||
<strong>{selectedMapDataset?.name ?? 'Select a layer'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>AOI bbox</span>
|
||||
<strong>{selectedAreaBbox ? 'available' : 'missing'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Layer bbox</span>
|
||||
<strong>{activeLayerBbox ? 'available' : 'missing'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Result</span>
|
||||
<strong>{mapSelectionResult ? `${mapSelectionResult.feature_count} features` : 'not run'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="feature-extract-actions">
|
||||
<button
|
||||
className="primary-action"
|
||||
disabled={!selectedMapDataset || !mapFeatureCollection || (!selectedAreaBbox && !activeLayerBbox) || mapSelectionLoading}
|
||||
type="button"
|
||||
onClick={runQuickAoiExtract}
|
||||
>
|
||||
{mapSelectionLoading ? 'Running test...' : 'Run AOI/layer query'}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!selectedAreaBbox}
|
||||
type="button"
|
||||
onClick={() => setSelectionBbox(selectedAreaBbox)}
|
||||
>
|
||||
Use AOI extent
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!activeLayerBbox}
|
||||
type="button"
|
||||
onClick={() => setSelectionBbox(activeLayerBbox)}
|
||||
>
|
||||
Use layer extent
|
||||
</button>
|
||||
</div>
|
||||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||||
</div>
|
||||
<div className="bbox-select-surface" aria-label="Area selection and extract">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
|
||||
@@ -4658,7 +4658,7 @@ section {
|
||||
}
|
||||
|
||||
.map-workspace-shell .map-toolbar {
|
||||
grid-template-columns: minmax(11rem, 1.1fr) repeat(2, minmax(9rem, 1fr)) minmax(11rem, 1.1fr);
|
||||
grid-template-columns: minmax(13rem, 1.25fr) minmax(10rem, 1fr) repeat(2, minmax(8rem, 0.85fr)) minmax(11rem, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -4714,12 +4714,13 @@ section {
|
||||
.map-inspection-surface {
|
||||
order: 6;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18rem, 1fr) minmax(18rem, 1fr);
|
||||
grid-template-columns: minmax(18rem, 0.85fr) minmax(18rem, 1fr) minmax(18rem, 1fr);
|
||||
gap: 0.65rem;
|
||||
padding: 0.62rem;
|
||||
}
|
||||
|
||||
.bbox-select-surface,
|
||||
.gis-test-run-surface,
|
||||
.feature-extract-surface,
|
||||
.map-inspection-surface .feature-inspector {
|
||||
margin: 0;
|
||||
@@ -4727,6 +4728,47 @@ section {
|
||||
padding: 0.62rem;
|
||||
}
|
||||
|
||||
.gis-test-run-surface {
|
||||
display: grid;
|
||||
gap: 0.58rem;
|
||||
border: 1px solid rgba(15, 118, 110, 0.24);
|
||||
border-left: 3px solid var(--accent);
|
||||
background: #f8fbf9;
|
||||
}
|
||||
|
||||
.gis-test-run-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.gis-test-run-grid > div {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
padding: 0.42rem 0.48rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.gis-test-run-grid span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.055em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gis-test-run-grid strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 0.16rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bbox-select-surface {
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user