Files
geointel/frontend/src/hooks/useProjectWorkspace.ts
T
Codex 1e527bf810
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
Add Belgium and North Sea coverage foundation
2026-07-18 01:45:00 +02:00

303 lines
10 KiB
TypeScript

import { FormEvent, useMemo, useRef, useState } from 'react'
import {
PRIMARY_FOCUS_AREA_GEOJSON,
PRIMARY_FOCUS_AREA_NAME,
PRIMARY_FOCUS_REGION,
NATIONAL_WORKSPACE_PROJECT_NAME,
REGIONAL_WORKSPACE_PROJECT_NAME,
isPrimaryFocusMunicipalityBoundaryDataset,
isPrimaryFocusMunicipalityProject,
isPrimaryFocusProject,
isPrimaryFocusProjectData,
} from '../config/primaryFocus'
import { areasApi } from '../services/api/areas'
import { datasetsApi } from '../services/api/datasets'
import { projectsApi } from '../services/api/projects'
import type {
AreaCreate,
AreaListResponse,
AreaRead,
DatasetCreateResponse,
DatasetListResponse,
ProjectCreate,
ProjectRead,
} from '../types'
const DEMO_PROJECT_NAME = 'GeoIntel Demo - Building QA'
function isDemoProject(project: ProjectRead): boolean {
return project.name === DEMO_PROJECT_NAME
}
function hasMappedAnalysisContext(
data: { areas: AreaRead[]; datasets: DatasetCreateResponse[] },
): boolean {
const hasRasterDataset = data.datasets.some((dataset) => dataset.dataset_type === 'raster')
const hasVectorDataset = data.datasets.some(
(dataset) => dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson',
)
return data.areas.length > 0 && hasRasterDataset && hasVectorDataset
}
export function useProjectWorkspace() {
const [projects, setProjects] = useState<ProjectRead[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [areas, setAreas] = useState<AreaRead[]>([])
const [datasets, setDatasets] = useState<DatasetCreateResponse[]>([])
const [loadingProjects, setLoadingProjects] = useState(false)
const [loadingAreas, setLoadingAreas] = useState(false)
const [loadingDatasets, setLoadingDatasets] = useState(false)
const [archivingProjectId, setArchivingProjectId] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const projectDataRequestSequence = useRef(0)
const [projectForm, setProjectForm] = useState<ProjectCreate>({
name: '',
description: '',
region: PRIMARY_FOCUS_REGION,
})
const [areaForm, setAreaForm] = useState({
name: PRIMARY_FOCUS_AREA_NAME,
geometry: PRIMARY_FOCUS_AREA_GEOJSON,
crs: 'EPSG:4326',
})
const selectedProject = useMemo(
() => projects.find((project) => project.id === selectedProjectId) ?? null,
[projects, selectedProjectId],
)
const fetchProjectData = async (projectId: string): Promise<{ areas: AreaRead[]; datasets: DatasetCreateResponse[] }> => {
const [areaResponse, datasetResponse]: [AreaListResponse, DatasetListResponse] = await Promise.all([
areasApi.list(projectId),
datasetsApi.list(projectId),
])
return { areas: areaResponse.items, datasets: datasetResponse.items }
}
const pickInitialProjectId = async (items: ProjectRead[], preferredProjectId?: string | null) => {
if (preferredProjectId && items.some((project) => project.id === preferredProjectId)) {
return preferredProjectId
}
if (selectedProjectId && items.some((project) => project.id === selectedProjectId)) {
return selectedProjectId
}
const nationalProject = items.find((project) => project.name === NATIONAL_WORKSPACE_PROJECT_NAME)
if (nationalProject) {
try {
const data = await fetchProjectData(nationalProject.id)
if (data.areas.length > 0 && data.datasets.some((dataset) => dataset.status === 'ready')) {
return nationalProject.id
}
} catch {
// Continue with regional and municipality fallbacks while national data is unavailable.
}
}
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
if (regionalProject) {
try {
const data = await fetchProjectData(regionalProject.id)
if (data.areas.length > 0 && data.datasets.some((dataset) => dataset.status === 'ready')) {
return regionalProject.id
}
} catch {
// Continue with the municipality and completeness fallbacks when regional data is unavailable.
}
}
const municipalityProject = items.find(isPrimaryFocusMunicipalityProject)
if (municipalityProject) {
try {
const data = await fetchProjectData(municipalityProject.id)
if (data.areas.length > 0 && data.datasets.some(isPrimaryFocusMunicipalityBoundaryDataset)) {
return municipalityProject.id
}
} catch {
// Continue with the normal completeness ranking if the canonical workspace is temporarily unavailable.
}
}
const primaryProjects = items.filter(isPrimaryFocusProject)
const demoProjects = items.filter(isDemoProject)
const candidates = Array.from(
new Map(
[...primaryProjects, ...demoProjects, ...items].map((project) => [project.id, project]),
).values(),
).slice(0, 12)
const inspectedCandidates: Array<{
project: ProjectRead
data: { areas: AreaRead[]; datasets: DatasetCreateResponse[] }
}> = []
for (const project of candidates) {
try {
const data = await fetchProjectData(project.id)
inspectedCandidates.push({ project, data })
if (
isPrimaryFocusProjectData(project, data.datasets) &&
hasMappedAnalysisContext(data)
) {
return project.id
}
} catch {
// Project list should still render if a candidate's detail endpoints are temporarily unavailable.
}
}
const primaryContext = inspectedCandidates.find(
({ project, data }) =>
data.datasets.length > 0 && isPrimaryFocusProjectData(project, data.datasets),
)
if (primaryContext) {
return primaryContext.project.id
}
const completeContext = inspectedCandidates.find(
({ data }) => data.areas.length > 0 && data.datasets.length > 0,
)
return completeContext?.project.id ?? primaryProjects[0]?.id ?? demoProjects[0]?.id ?? items[0]?.id ?? null
}
const loadProjects = async (preferredProjectId?: string | null) => {
setLoadingProjects(true)
setErrorMessage(null)
try {
const [response, nationalResponse, canonicalResponse] = await Promise.all([
projectsApi.list(),
projectsApi.list({ name: NATIONAL_WORKSPACE_PROJECT_NAME, limit: 1 }),
projectsApi.list({ name: REGIONAL_WORKSPACE_PROJECT_NAME, limit: 1 }),
])
const regionalItems = [...canonicalResponse.items, ...response.items]
const items = Array.from(
new Map(
[...nationalResponse.items, ...regionalItems].map((project) => [project.id, project]),
).values(),
)
setProjects(items)
const nextProjectId = await pickInitialProjectId(items, preferredProjectId)
if (nextProjectId && nextProjectId !== selectedProjectId) {
setSelectedProjectId(nextProjectId)
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Werkruimtes konden niet worden geladen')
} finally {
setLoadingProjects(false)
}
}
const loadProjectData = async (projectId: string) => {
const requestId = ++projectDataRequestSequence.current
setLoadingAreas(true)
setLoadingDatasets(true)
setErrorMessage(null)
try {
const projectData = await fetchProjectData(projectId)
if (requestId !== projectDataRequestSequence.current) {
return null
}
setAreas(projectData.areas)
setDatasets(projectData.datasets)
return projectData
} catch (error) {
if (requestId === projectDataRequestSequence.current) {
setErrorMessage(error instanceof Error ? error.message : 'De gegevens van de werkruimte konden niet worden geladen')
}
return null
} finally {
if (requestId === projectDataRequestSequence.current) {
setLoadingAreas(false)
setLoadingDatasets(false)
}
}
}
const createProject = async (event: FormEvent) => {
event.preventDefault()
if (!projectForm.name.trim()) {
setErrorMessage('Geef een naam voor de werkruimte op')
return
}
try {
const createdProject = await projectsApi.create({
name: projectForm.name.trim(),
description: projectForm.description?.trim() || undefined,
region: projectForm.region?.trim() || PRIMARY_FOCUS_REGION,
})
setProjectForm((previous) => ({ ...previous, name: '', description: '' }))
setSelectedProjectId(createdProject.id)
await loadProjects(createdProject.id)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden aangemaakt')
}
}
const createArea = async (event: FormEvent) => {
event.preventDefault()
if (!selectedProjectId) {
setErrorMessage('Kies eerst een werkruimte')
return
}
let geometry: AreaCreate['geometry']
try {
geometry = JSON.parse(areaForm.geometry) as AreaCreate['geometry']
} catch {
setErrorMessage('De opgegeven GeoJSON-geometrie is ongeldig')
return
}
try {
await areasApi.create(selectedProjectId, {
name: areaForm.name,
crs: areaForm.crs,
geometry,
})
await loadProjectData(selectedProjectId)
setAreaForm((previous) => ({ ...previous, name: '' }))
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Het gebied kon niet worden aangemaakt')
}
}
const archiveProject = async (projectId: string) => {
setArchivingProjectId(projectId)
setErrorMessage(null)
try {
await projectsApi.update(projectId, { status: 'archived' })
if (selectedProjectId === projectId) {
setSelectedProjectId(null)
resetProjectData()
}
await loadProjects()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden gearchiveerd')
} finally {
setArchivingProjectId(null)
}
}
const resetProjectData = () => {
projectDataRequestSequence.current += 1
setAreas([])
setDatasets([])
}
return {
projects,
selectedProject,
selectedProjectId,
areas,
datasets,
loadingProjects,
loadingAreas,
loadingDatasets,
archivingProjectId,
errorMessage,
projectForm,
areaForm,
loadProjects,
loadProjectData,
createProject,
createArea,
archiveProject,
resetProjectData,
setSelectedProjectId,
setErrorMessage,
setProjectForm,
setAreaForm,
}
}