diff --git a/AGENTS.md b/AGENTS.md index 7dc6090..6b0b8e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ This file is a quick operational guide for coding agents working in this reposit - Polygon, box and cross section all end as one closed ring, so there is one filter kind. A cross section is a line plus a width; `crossSectionRing` in `geo/spatial-selection.ts` converts it. - Always send the bounding box of the polygon beside it, as two `MinMaxFilter`s on the latitude and longitude columns. The server can prune data with those, but not with the polygon test. `compileDraft` derives the box; never store it on a field. - `QueryDraft.spatialFilter` holds the area, because it applies to two columns and has no card of its own. `QueryWorkspace.updateActiveSpatialFilter` writes it, and also handles a block that has no draft (share link, JSON editor) by patching `compiled.filters`. +- The area carries the two columns it tests (`latitudeColumn` / `longitudeColumn`), because the user can pick another pair than the names say, for example `x` and `y`. Resolve the pair with `selectionColumns` (`geo/spatial-selection.ts`), never with `detectCoordinateColumns` alone: the names on the area win while the query still selects them, and detection is only the fallback for an older record. The draw tools rebuild the area on every shape change and drop the two names, so stamp them back with `withColumns` at the point of apply. - Terra Draw (`terra-draw` + `terra-draw-maplibre-gl-adapter`) draws the shape. After a shape is complete `MapDrawTools.svelte` clears Terra Draw and renders the ring in its own MapLibre source, so a loaded area and a new area look the same. ## Layer Rule (Important) @@ -170,7 +171,7 @@ Imports point one way only: ## Known Repo Facts - Static adapter outputs to `build/` and uses `fallback: 'index.html'`. -- Monaco editor and Perspective viewer are included and loaded client-side. +- Monaco editor is included and loaded client-side. - Project currently contains generated `build/` artifacts in repo; avoid editing generated files directly unless explicitly asked. ## Comment Rules diff --git a/src/lib/components/modals/Modal.svelte b/src/lib/components/modals/Modal.svelte index 32c7ca2..fd1692f 100644 --- a/src/lib/components/modals/Modal.svelte +++ b/src/lib/components/modals/Modal.svelte @@ -11,19 +11,17 @@ let shortRandomString = Utils.uuidv4().slice(0, 8); function closeModalOnEscape(event: KeyboardEvent) { - if (event.key === 'Escape') { + // The test reads the value of now. A caller can block the close while a + // task runs, and release it after. + if (event.key === 'Escape' && canCloseModal) { onClose(); } } onMount(() => { - if (canCloseModal) { - //also add escape key listener to close modal - document.addEventListener('keydown', closeModalOnEscape); - } + document.addEventListener('keydown', closeModalOnEscape); return () => { - // Cleanup: remove event listener if it was added document.removeEventListener('keydown', closeModalOnEscape); }; }); @@ -53,7 +51,7 @@ {/if} - diff --git a/src/lib/geo/spatial-selection.ts b/src/lib/geo/spatial-selection.ts index 1d696e5..7ca72c1 100644 --- a/src/lib/geo/spatial-selection.ts +++ b/src/lib/geo/spatial-selection.ts @@ -10,6 +10,7 @@ * columns. See `beacon-core/src/query/filter/geo_json.rs`. */ import type { GeoJsonFilter, GeoJsonPolygon, MinMaxFilter } from '@/beacon-api/types'; +import { detectCoordinateColumns } from '@/geo/coordinate-columns'; import { getSettings } from '@/stores/settings'; export type SpatialSelectionMode = 'polygon' | 'box' | 'cross-section'; @@ -25,6 +26,19 @@ export type SpatialSelection = { line?: LngLat[]; /** Cross section only: the full width of the band, in kilometres. */ widthKm?: number; + /** + * The columns the filter tests. The user picks them in the query builder. + * Both are absent on an area of an older record, and {@link selectionColumns} + * then falls back to {@link detectCoordinateColumns}. + */ + latitudeColumn?: string; + longitudeColumn?: string; +}; + +/** The pair of columns a spatial filter tests. */ +export type CoordinatePair = { + latitude: string; + longitude: string; }; export type Bounds = { @@ -223,6 +237,67 @@ export function makeCrossSectionSelection(line: LngLat[], widthKm: number): Spat }; } +/** Put the two column names on a selection. The draw tools drop them. */ +export function withColumns( + selection: SpatialSelection, + columns: CoordinatePair | null +): SpatialSelection { + if (!columns) return selection; + + return { + ...selection, + latitudeColumn: columns.latitude, + longitudeColumn: columns.longitude + }; +} + +/** + * The two columns a spatial filter must test, or null. + * + * The names on the selection win, because the user picked them. They only win + * while the query still selects both: a filter on a column that the query does + * not select is invalid. Detection then answers, which also serves an area of + * an older record, and an area that the map viewer drew. + */ +export function selectionColumns( + selection: SpatialSelection | null | undefined, + availableNames: string[] +): CoordinatePair | null { + const latitude = selection?.latitudeColumn; + const longitude = selection?.longitudeColumn; + + if ( + latitude && + longitude && + availableNames.includes(latitude) && + availableNames.includes(longitude) + ) { + return { latitude, longitude }; + } + + const detection = detectCoordinateColumns(availableNames); + if (!detection.latitude || !detection.longitude) return null; + + return { latitude: detection.latitude.name, longitude: detection.longitude.name }; +} + +/** + * The column of a selection that the query does not select, or null. + * + * The builder reports this. The area stays on the draft, so the user can pick + * the column again, or select it in the query. + */ +export function missingSelectionColumn( + selection: SpatialSelection | null | undefined, + availableNames: string[] +): string | null { + const wanted = [selection?.latitudeColumn, selection?.longitudeColumn].filter( + Boolean + ) as string[]; + + return wanted.find((name) => !availableNames.includes(name)) ?? null; +} + /** True when the selection has a usable area. */ export function isUsableSelection(selection: SpatialSelection | null | undefined): boolean { return !!selection && selection.ring.length >= 4; @@ -284,7 +359,9 @@ export function fromGeoJsonFilter(filter: GeoJsonFilter): SpatialSelection | nul return { mode: 'polygon', - ring: ring.map((point) => [Number(point[0]), Number(point[1])] as LngLat) + ring: ring.map((point) => [Number(point[0]), Number(point[1])] as LngLat), + latitudeColumn: filter.latitude_query_parameter, + longitudeColumn: filter.longitude_query_parameter }; } diff --git a/src/lib/query/draft.ts b/src/lib/query/draft.ts index ab22b15..9f79200 100644 --- a/src/lib/query/draft.ts +++ b/src/lib/query/draft.ts @@ -15,11 +15,11 @@ import { getSettings } from '@/stores/settings'; import { Utils } from '@/utils'; import { isUsableSelection, + selectionColumns, toBboxFilters, toGeoJsonFilter, type SpatialSelection } from '@/geo/spatial-selection'; -import { detectCoordinateColumns } from '@/geo/coordinate-columns'; /** A single selected column plus the filters applied to it. */ export type SelectedField = { @@ -102,20 +102,21 @@ export function compileDraft(draft: QueryDraft | null | undefined): CompiledQuer * The box is always derived here, and is never stored on a field. So one delete * of `spatialFilter` removes every part of the area again. * - * The query keeps no filter when it does not select both a latitude and a - * longitude column. + * The two columns come from the area itself, because the user picks them in the + * builder. See {@link selectionColumns}. The query keeps no filter while it + * selects neither pair. */ function addSpatialFilters(builder: QueryBuilder, draft: QueryDraft): void { const selection = draft.spatialFilter; if (!isUsableSelection(selection)) return; const names = draft.selectedFields.map((field) => field.name); - const { latitude, longitude } = detectCoordinateColumns(names); - if (!latitude || !longitude) return; + const columns = selectionColumns(selection, names); + if (!columns) return; - builder.addFilter(toGeoJsonFilter(selection!, latitude.name, longitude.name)); + builder.addFilter(toGeoJsonFilter(selection!, columns.latitude, columns.longitude)); - for (const filter of toBboxFilters(selection!, latitude.name, longitude.name)) { + for (const filter of toBboxFilters(selection!, columns.latitude, columns.longitude)) { builder.addFilter(filter); } } diff --git a/src/lib/query/seed-hydration.ts b/src/lib/query/seed-hydration.ts index 8f11328..8e0c2f4 100644 --- a/src/lib/query/seed-hydration.ts +++ b/src/lib/query/seed-hydration.ts @@ -139,16 +139,18 @@ function isDerivedBoxFilter( return false; } - const name = filter.for_query_parameter.toLowerCase(); + const name = filter.for_query_parameter; const matches = (min: number, max: number) => { return Math.abs(Number(filter.min) - min) < 1e-9 && Math.abs(Number(filter.max) - max) < 1e-9; }; - if (name.includes('latitude')) { + // The geo filter names its own two columns, so an area on `x` and `y` also + // matches. A name test for "latitude" holds only for the default pair. + if (name === selection.latitudeColumn) { return matches(bounds.minLat, bounds.maxLat); } - if (name.includes('longitude')) { + if (name === selection.longitudeColumn) { return matches(bounds.minLon, bounds.maxLon); } diff --git a/src/routes/visualisations/map-viewer/+page.svelte b/src/routes/visualisations/map-viewer/+page.svelte index 975ec16..b51be76 100644 --- a/src/routes/visualisations/map-viewer/+page.svelte +++ b/src/routes/visualisations/map-viewer/+page.svelte @@ -16,7 +16,11 @@ import VisualisationTabs from '@/components/visualisation/VisualisationTabs.svelte'; import MapDrawTools from '@/components/visualisation/MapDrawTools.svelte'; import { MapViewController } from '@/components/visualisation/MapViewController.svelte'; - import type { SpatialSelection } from '@/geo/spatial-selection'; + import { + selectionColumns, + withColumns, + type SpatialSelection + } from '@/geo/spatial-selection'; import { addToast } from '@/stores/toasts'; import { runBlockReason } from '@/query/query-guard'; import { settings } from '@/stores/settings'; @@ -174,9 +178,23 @@ untrack(() => workspace.updateActiveMapView(state)); }); - /** Write the drawn area into the query. The effect above then re-runs it. */ + /** + * Write the drawn area into the query. The effect above then re-runs it. + * + * The area names the two columns it tests. The builder can pick another pair + * than the detection finds, for example `x` and `y`. A redraw here must keep + * that pair, so the columns come from the area of the block. + */ function applyAreaFilter() { - workspace.updateActiveSpatialFilter(selection); + if (!selection) { + workspace.updateActiveSpatialFilter(null); + return; + } + + const stored = workspace.activeBlock?.draft?.spatialFilter ?? selection; + const columns = selectionColumns(stored, map.availableColumnNames); + + workspace.updateActiveSpatialFilter(withColumns(selection, columns)); } diff --git a/src/tailwind.css b/src/tailwind.css index 487cff4..5a8fc5c 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -105,6 +105,8 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + /* `bg-background` needs this map. The dialog panel is transparent without it. */ + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground);