Skip to content

Commit 2c8ef73

Browse files
authored
feat(geolens): edit and save vector data back to a GeoLens dataset (#1430)
* feat(geolens): edit and save vector data back to a GeoLens dataset A GeoLens dataset added as GeoJSON is already an ordinary editable GeoLibre layer — the GeoEditor can redraw it in place and the attribute table can retype it — but nothing carried those edits back to the server. GeoLens does expose per-feature CRUD (`POST /api/datasets/{id}/features/`, and `PUT`/`PATCH`/ `DELETE` on `.../features/{gid}`), so the round trip only needed a client. The plugin now tracks a baseline for every dataset it loads, shows what changed in an Edits section, and writes the difference back: - Identity is the integer row id GeoLens already returns as the GeoJSON `id`, so it survives the store, the attribute table, and the GeoEditor's tag-and-restore round trip. Features it never held are inserts; baseline ids no current feature claims are deletes. - Attribute changes go out as PUT (full replacement) because GeoLens does not document whether PATCH merges the properties map, and a merge would silently keep an attribute the user cleared. A geometry-only move PATCHes. - There is no bulk/transaction endpoint, so a save is one request per changed feature, issued sequentially with progress. A rejected write is reported and the rest of the plan continues; the baseline then advances only for the writes that landed, so a partial save leaves exactly the failures pending rather than absorbing them. - Row ids assigned to inserted features are stamped back onto the layer, so a second save updates those rows instead of inserting duplicates. - Saving is offered only when the server's `enable_dataset_editing` flag is on (read from the public `/api/settings/feature-flags/`) and the connection carries an API key — the write endpoints reject anonymous requests. The panel says which of the two is missing instead of showing a dead button. - A restored project has no in-memory baseline, so the first save reads the dataset back from GeoLens and diffs against what it holds now. Reload discards local changes the same way. Verified in the browser against a live GeoLens (datasets.geolibre.app, editing flag off → Save correctly disabled, counts still track a real attribute-table edit) and against a local stand-in with editing enabled: a GeoEditor-drawn feature POSTs, an attribute edit PUTs, and the row settles back to no pending changes. * feat(geolens): add a Sample server dropdown to the panel Trying the plugin previously meant already knowing a GeoLens URL. The panel now offers the two public deployments — datasets.geolibre.app and demo.getgeolens.com — above the URL field. Picking one fills the field and connects (that is the whole intent of the choice; leaving Connect to a second click would only add a step), then resets to the placeholder, because the URL field stays the source of truth and the user can edit it afterwards. Also makes a blocked request legible. demo.getgeolens.com serves its catalog to curl but sends no `Access-Control-Allow-Origin` at all, so no browser can reach it — where datasets.geolibre.app allowlists the requesting origin and works. `fetch` reports that as a bare TypeError ("Failed to fetch") with no detail by design, which read as "GeoLibre is broken" rather than "this server does not allow browser access". Every failure this module raises itself is a plain Error, so the constructor cleanly separates the two, and a transport failure now names the host and says the server refused a cross-origin request. Verified in the browser, light and dark: the dropdown lists both entries, the GeoLibre catalog loads (22 datasets), and the demo entry shows the CORS message instead of a bare fetch error. * fix(geolens): clear the catalog when switching or failing to reach a server Connecting to a different server left the previous one's dataset cards on screen, so a failed connect showed an error above what looked like that server's catalog. Those cards are worse than stale: their Add buttons build source paths from the old base URL, and the panel has already dropped the client, so they describe a server it is no longer pointed at. Connect now empties the list before querying the new server, so switching clears immediately rather than after the response arrives, and any failed request empties it too — after a request that never landed the panel does not know what the server holds, and leaving earlier results up presents them as the answer to a query that never ran. Verified in the browser: connect to datasets.geolibre.app (22 datasets), switch to the demo server that no browser can reach (0 datasets, just the CORS message), switch back (22 datasets). * fix(geo-editor): keep a layer's attributes through an in-place geometry edit Geoman claims eleven property names as its own "shape properties" — id, shape, center, width, height, xSemiAxis, ySemiAxis, angle, text, disableEdit, group. On import it reads them from a feature's plain attributes, and on export `parseExtraProperties` deletes both the plain and the prefixed form and re-emits the value as `__gm_<name>`. So a layer with a `height` or `id` column — building footprints, anything carrying a source id — came back from a pure geometry edit with those columns *renamed* to `__gm_height` / `__gm_id`, plus a `__gm_shape` column that was never in the data. The attribute table showed the renamed columns; the original ones were gone from the layer. The session only ever edits geometry, so the attributes it started with are the attributes it must end with. `startLayerGeometryEdit` now snapshots them (from the already-tagged collection, before Geoman sees it) and the write-back restores them by feature tag, dropping Geoman's `__gm_*` bookkeeping. A feature drawn during the session has no snapshot, so it keeps its own properties minus that bookkeeping, which would otherwise appear as columns in the layer. Found via the GeoLens plugin, where it also made every feature look edited: an edit session on the 540-feature Las Vegas Buildings demo dataset marked all 540 changed and would have PUT `{}` over every row's attributes — the geometry was byte-identical, only the renamed columns differed. Also stops GeoLens counting a dropped null-valued attribute as a change: a GeoLens row exposes every column, so an empty feature loads as `{"id": null, "height": null}` and returns from the editor as `{}`. Absent and null both leave the column NULL, so folding them together drops writes that could not change anything, while a real value that disappears still registers. Verified against the live dataset: a session with no edits went from 540 writes to none ("No local changes", Save disabled) with the attribute table keeping its id/height columns, and a single-feature attribute edit now issues exactly one PUT (HTTP 200, "Saved 1 change to GeoLens"). The demo row used for that check was restored to its original values. * feat(attribute-table): show feature, filtered and selected counts in a status bar The table had no readout of how much data it was showing: a layer's size was only visible in the Layers panel, and the size of a selection nowhere at all. A status bar under the table now reports the layer's feature count and how many features are selected. The count of rows currently shown appears only when a search or the Selected view is narrowing the table — showing it always would print the same number twice in the ordinary case and teach the eye to skip it. It sits outside the scroll area so it stays put while scrolling, and is hidden when the panel is collapsed or the layer has no attributes. Counts come from the same collections the table renders (`attributeRows`, `filtered`, `selectedFeatureIds`), so they cannot drift from what is on screen. Strings are plural-aware (`_one`/`_other`) in en.json; the other catalogs fall back to English until translated. Covered by a hermetic e2e spec over the existing smoke fixture: totals, the "shown" count appearing only while filtered, and selection. * Address CodeRabbit review feedback - saveLayerEdits: baseline the collection that was actually diffed and written, not a fresh read of the store. An edit made while the save was in flight was being snapshotted as if the server already held it, so it would read as unchanged forever and could never be saved. Covered by a test that fails against the previous logic. - Prune editSessions/pendingCountsCache for layers that have left the store. Each entry holds a full copy of its dataset, so adding and removing GeoLens layers retained one dataset per discarded layer until the plugin deactivated. - Reload now confirms before discarding unsaved changes, naming the counts it would throw away. A clean layer still reloads on a single click. - Clear the API key when the target server changes origin (sample-server pick or a retyped host), so a key issued by a private deployment is not carried to a different one. Manual edits check on `change`, not per keystroke, so fixing a path within one host leaves the key alone. - Reword the transport-failure message: a bare fetch TypeError also covers DNS, offline and TLS failures, so it no longer asserts the server refused CORS — it says the request never completed and names CORS as one possibility. - captureEditedProperties reads the pre-tag collection, so a feature whose properties were null stays null through tag → edit → reconcile instead of becoming {}. Regression test added for the null round trip. - Test the properties-only PATCH branch (mode "patch" with no geometry), both in updateFeature and in the diffFeatures fallback that selects it. * feat(geolens): load Add GeoJSON from the current map view Add GeoJSON took the first N features of a dataset, which on a large catalog layer is an arbitrary slice with no relationship to what the user is looking at. It now asks the server for the features inside the current map view (OGC `bbox`), so the feature limit caps what is loaded *from that area* instead. Verified against the Las Vegas Buildings demo dataset: zoomed into a few blocks, the load returned 49 of 534 features — the ones on screen. - Settings gains "Only load features in the current map view", persisted like the feature limit, and on by default: a catalog dataset is usually far larger than the area being looked at. A view that spans the world, wraps the antimeridian, or has no map sends no bbox at all rather than a wrong half. - A view-filtered layer is named "<dataset> (current view)", because the Layers panel is where someone will later wonder why the layer is a subset. - The load terms (limit and extent) are recorded on the layer, and the baseline and Reload now re-read on those terms rather than the panel's current settings. Without this a view-filtered layer would diff against the whole dataset and the next save would DELETE every feature outside the view. The same recording fixes the pre-existing case of changing the limit between load and save. - A save that would delete features now asks first, naming the count. Deletions are the only part of a plan that can appear without the user touching a feature — one the editor silently failed to load is simply absent afterwards, which diffs identically to a deliberate delete — and the one part that cannot be undone. * fix(map): re-render vector tiles when their endpoint changes Adding a dataset as vector tiles, editing it as GeoJSON and saving left the tiles showing pre-edit data at every zoom the user had already looked at, while zooms visited for the first time came back correct — removing and re-adding the layer fixed it. Three things were wrong, in order of depth: - `syncVectorTileLayer` created its source once and never touched it again, so a changed tile template never reached MapLibre. It now pushes the new endpoint into the live source with setTiles/setUrl, and only when it actually changed (a needless reload blanks the layer for a frame). This also repairs the GeoLens token refresh, which patched the store every few minutes and, as it turns out, never affected the map at all — a layer left open past its token's lifetime would have started 404ing. - Saving edits did not tell the tiles anything had changed. A successful save now re-points every GeoLens vector-tile layer showing that dataset, which drops MapLibre's cached tiles and re-requests them. - Re-minting a token is not enough to do that: GeoLens returns the *same* signature and expiry for the rest of its time bucket, so the URL would be unchanged and both MapLibre and the HTTP cache would answer from cache. The refresh stamps a `_v` parameter instead. Signature validation ignores unknown parameters — verified against the live server: the same tile returns byte-identical content (5414 bytes, HTTP 200) with and without it. Verified end to end on the Las Vegas Buildings demo dataset: with the map at zoom 15 and those tiles already rendered, saving one edit re-requested them immediately, and every request after further navigation carried the new version. Also fixes the attribute-table type inference that this exposed. A cell holding null carried no type, so typing into one stored a string — which made a number column mixed, and a PostGIS-backed dataset rejected the write outright ("a value is incompatible with a column's type or constraints"). The type now comes from the column across the layer, falling back to the raw string only when the column is empty everywhere. * feat(geolens): re-scope a loaded layer to the current map view Moving to a different area meant removing the GeoJSON layer and adding it again. Each Edits row now has a "Load this view" button that replaces the layer's features with the ones in the current view, in place. Unsaved work is never discarded silently. With pending changes the button offers to save them first; a save that did not fully succeed aborts the reload, so the features those writes failed on are still there to retry. Declining the save asks separately about discarding, so save, discard and cancel are all reachable from the one button — the existing Reload keeps its narrower meaning of "discard and re-read the same extent". The refresh records the new extent and limit on the layer and re-baselines from what it loaded, so the features that just left the view are not mistaken for deletions on the next save. An auto-generated layer name is kept accurate (gaining or losing the "(current view)" suffix); a name the user changed is left alone, which is why the dataset title is now recorded in the layer metadata.
1 parent e77737c commit 2c8ef73

17 files changed

Lines changed: 3107 additions & 27 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ GeoLibre is built with **Tauri v2**, **React**, **TypeScript**, **MapLibre GL JS
8181
- Project menu to create, open, save, and Save As `.geolibre.json` projects, export a project to a single standalone interactive HTML file that runs offline with no server, and a project gallery for browsing and opening shared projects with one click
8282
- Desktop diagnostics panel (capturing native Tauri HTTP requests in the network log and classifying failed `fetch()` errors), OS trust store and mTLS client-certificate support for native HTTP, automatic layer reload when local files change on disk, a guided update workflow with a startup update check and update preferences, and MSIX packaging support, with macOS installers signed with an Apple Developer ID certificate and notarized by Apple so they open without a Gatekeeper workaround, plus Windows Package Manager (winget) distribution as `OpenGeos.GeoLibre` and a Windows portable zip build that runs without installation
8383
- Customizable UI profiles that tailor which menus, panels, and data sources are visible, so a deployment can present a focused subset of the app to its users. See [UI Profiles](docs/ui-profiles.md)
84-
- Plugin system with basemap, layer control, MapLibre components, swipe, street view, Mapillary coverage and street-level image viewer, OpenAerialMap open-aerial-imagery search, Natural Earth and Source Cooperative data browsing (including opening or streaming large GeoParquet from Source Cooperative), a [GeoLens](https://github.com/geolens-io/geolens) catalog browser that connects to a self-hosted GeoLens server and adds datasets as signed vector tiles, OGC API Features GeoJSON, or server-rendered raster tiles, Historical Imagery, Elevation Profile, Overture Maps, USGS LiDAR, GeoAgent, and GeoEditor integrations (the GeoEditor can pull the vector features currently visible in the map view into the editor for editing without re-importing the source, and write edits back to their origin, including GeoPackage and GeoJSON files and PostGIS database tables), including configurable control positions and external plugin manifests; external plugins can render on the host's shared deck.gl instance via `app.getDeckGL()`, use the maplibre-gl-raster stack and the map projection control, register native raster and tile layers, register first-class right-sidebar panels, toolbar menus, and floating panels through the plugin UI host API (including a shared-rail replace-style dock mode), and place their toolbar menus after the Help menu
84+
- Plugin system with basemap, layer control, MapLibre components, swipe, street view, Mapillary coverage and street-level image viewer, OpenAerialMap open-aerial-imagery search, Natural Earth and Source Cooperative data browsing (including opening or streaming large GeoParquet from Source Cooperative), a [GeoLens](https://github.com/geolens-io/geolens) catalog browser that connects to a self-hosted GeoLens server and adds datasets as signed vector tiles, OGC API Features GeoJSON, or server-rendered raster tiles (and writes edits to a GeoJSON-loaded dataset back to the GeoLens server, feature by feature, when the server allows it), Historical Imagery, Elevation Profile, Overture Maps, USGS LiDAR, GeoAgent, and GeoEditor integrations (the GeoEditor can pull the vector features currently visible in the map view into the editor for editing without re-importing the source, and write edits back to their origin, including GeoPackage and GeoJSON files and PostGIS database tables), including configurable control positions and external plugin manifests; external plugins can render on the host's shared deck.gl instance via `app.getDeckGL()`, use the maplibre-gl-raster stack and the map projection control, register native raster and tile layers, register first-class right-sidebar panels, toolbar menus, and floating panels through the plugin UI host API (including a shared-rail replace-style dock mode), and place their toolbar menus after the Help menu
8585
- Time Slider plugin for animating time series raster and vector data, including binding existing vector layers already on the map to the timeline, stepping through mosaic sources (a MosaicJSON or STAC collection of many COGs per date) rendered on either a GPU or a WASM engine, and a pixel time-series chart that plots a sampled pixel's value across a raster stack
8686
- Atmosphere Effects plugin that renders a deep-space backdrop, parallax starfield, comets, and an atmospheric halo around the globe at low zoom (technique adapted from [Leonel Dias](https://leoneljdias.github.io/posts/globe-atmosphere-halo-comets/)), with a Spinning Globe panel and customizable atmosphere halo and deep-space colors
8787
- Directions plugin for interactive routing via [maplibre-gl-directions](https://github.com/maplibre/maplibre-gl-directions): click the map to add waypoints, drag to reposition, and click a waypoint to remove it (uses the public OSRM demo server, driving only)

apps/geolibre-desktop/src/components/panels/AttributeTable.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import {
9393
visibleColumns,
9494
type ColumnMoveDirection,
9595
type NewColumnType,
96+
inferColumnTypes,
9697
} from "../../lib/attribute-columns";
9798
import {
9899
coerceComputedValue,
@@ -170,13 +171,24 @@ function compareAttributeValues(a: unknown, b: unknown): number {
170171
});
171172
}
172173

173-
function parseAttributeDraft(draft: string, previousValue: unknown): unknown {
174+
function parseAttributeDraft(draft: string, previousValue: unknown, columnType?: string): unknown {
174175
if (draft.trim() === "") return null;
175176

176-
// A null/undefined cell carries no original type to infer from, so the raw
177-
// string is kept as-is: editing a previously-empty cell does not coerce to
178-
// number/boolean/object.
179-
if (previousValue == null) return draft;
177+
// An empty cell carries no type of its own, so fall back to what the rest of
178+
// the column holds; only a column with no values anywhere keeps the raw string.
179+
if (previousValue == null) {
180+
if (columnType === "number") {
181+
const nextValue = Number(draft);
182+
return Number.isFinite(nextValue) ? nextValue : draft;
183+
}
184+
if (columnType === "boolean") {
185+
const normalized = draft.trim().toLowerCase();
186+
if (normalized === "true") return true;
187+
if (normalized === "false") return false;
188+
return draft;
189+
}
190+
return draft;
191+
}
180192

181193
if (typeof previousValue === "number") {
182194
const nextValue = Number(draft);
@@ -221,6 +233,9 @@ function applyDraftsToFeatures(
221233
drafts: AttributeDrafts,
222234
formFields?: Map<string, AttributeFormFieldConfig>,
223235
): Feature[] {
236+
// Derived from the whole collection, so an edit to an empty cell adopts the
237+
// column's type rather than the cell's (absent) one.
238+
const columnTypes = inferColumnTypes(features.map((feature) => feature.properties));
224239
return features.map((feature, index) => {
225240
const featureId = String(feature.id ?? index);
226241
const rowDrafts = drafts[featureId];
@@ -238,7 +253,7 @@ function applyDraftsToFeatures(
238253
const config = formFields?.get(column);
239254
properties[column] = config
240255
? coerceAttributeFormValue(config, draft)
241-
: parseAttributeDraft(draft, previousValue);
256+
: parseAttributeDraft(draft, previousValue, columnTypes?.get(column));
242257
}
243258

244259
return { ...feature, properties };
@@ -313,6 +328,8 @@ function applyDraftsToDuckDBRows(
313328
): Record<string, Record<string, unknown>> {
314329
const rowById = new Map(rows.map((row) => [row.featureId, row]));
315330
const updates: Record<string, Record<string, unknown>> = {};
331+
// Same column-level inference as the GeoJSON path, over the query's rows.
332+
const columnTypes = inferColumnTypes(rows.map((row) => row.properties));
316333

317334
for (const [featureId, rowDrafts] of Object.entries(drafts)) {
318335
const row = rowById.get(featureId);
@@ -322,7 +339,7 @@ function applyDraftsToDuckDBRows(
322339
for (const [column, draft] of Object.entries(rowDrafts)) {
323340
const previousValue = row.properties[column];
324341
if (isInvalidObjectDraft(draft, previousValue)) continue;
325-
properties[column] = parseAttributeDraft(draft, previousValue);
342+
properties[column] = parseAttributeDraft(draft, previousValue, columnTypes.get(column));
326343
}
327344

328345
if (Object.keys(properties).length > 0) updates[featureId] = properties;
@@ -1914,6 +1931,25 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
19141931
)}
19151932
</ScrollArea>
19161933
) : null}
1934+
{/*
1935+
Status bar: how many features the layer has, how many the table is
1936+
currently showing, and how many are selected. The "shown" count only
1937+
appears when a search or the Selected view is narrowing the table, so
1938+
the common case reads as one unambiguous total rather than two equal
1939+
numbers. Sits outside the ScrollArea so it stays put while scrolling.
1940+
*/}
1941+
{!collapsed && hasAttributeSource ? (
1942+
<div
1943+
data-testid="attribute-table-status"
1944+
className="flex shrink-0 items-center gap-3 border-t bg-card px-3 py-1 text-[11px] text-muted-foreground"
1945+
>
1946+
<span>{t("attributeTable.statusFeatures", { count: attributeRows.length })}</span>
1947+
{filtered.length !== attributeRows.length ? (
1948+
<span>{t("attributeTable.statusShown", { count: filtered.length })}</span>
1949+
) : null}
1950+
<span>{t("attributeTable.statusSelected", { count: selectedFeatureIds.length })}</span>
1951+
</div>
1952+
) : null}
19171953
<Dialog
19181954
open={columnPendingDelete !== null}
19191955
onOpenChange={(open: boolean) => {

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3298,6 +3298,12 @@
32983298
"searchPlaceholder": "Search attributes...",
32993299
"searchAria": "Search attributes",
33003300
"clearSelectedFeature": "Clear selection",
3301+
"statusFeatures_one": "{{count}} feature",
3302+
"statusFeatures_other": "{{count}} features",
3303+
"statusShown_one": "{{count}} shown",
3304+
"statusShown_other": "{{count}} shown",
3305+
"statusSelected_one": "{{count}} selected",
3306+
"statusSelected_other": "{{count}} selected",
33013307
"featureViewAria": "Feature view",
33023308
"showAllFeatures": "Show All Features",
33033309
"showSelectedFeatures": "Show Selected ({{count}})",

apps/geolibre-desktop/src/lib/attribute-columns.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,27 @@ export function moveColumn(
440440
metadata: metadataWithSettings(layer, { ...settings, order: next }),
441441
};
442442
}
443+
444+
/**
445+
* The type a column holds, taken from the first feature that has a value for it.
446+
*
447+
* An individual cell that is null says nothing about its column, and typing into
448+
* one used to store a string — so a number column with an empty cell silently
449+
* became mixed. That reads fine locally but is rejected by anything with a real
450+
* schema behind it (a PostGIS-backed dataset answers "a value is incompatible
451+
* with a column's type or constraints"), and it corrupts sorting and styling for
452+
* plain files too.
453+
*/
454+
export function inferColumnTypes(
455+
rows: readonly (Record<string, unknown> | null | undefined)[],
456+
): Map<string, string> {
457+
const types = new Map<string, string>();
458+
for (const properties of rows) {
459+
if (!properties) continue;
460+
for (const [key, value] of Object.entries(properties)) {
461+
if (value == null || types.has(key)) continue;
462+
types.set(key, typeof value);
463+
}
464+
}
465+
return types;
466+
}

docs/roadmap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@
359359
- [x] An auto-generated **Legend panel** on the map that derives itself from the visible layers' symbology — per-class rows for graduated, categorized, rule-based, and expression styling, gradient bars for heatmaps and continuous raster colormaps, proportional-symbol size ramps, diagram fields, and land-cover labels read from a Raster Attribute Table — with an edit mode for renaming, hiding, and reordering entries, adding a section from a color dictionary, choosing a corner, collapsing sections, resizing the panel, and exporting the rendered legend as JSON, all saved with the project and shared with the Print Layout legend
360360
- [x] **Symbology swatches in the Layers panel**: every row carries a dot, line, square, or image glyph colored from the layer's own styling, dimmed when the layer is hidden, so a tall layer stack reads at a glance
361361
- [x] A new **GeoLens catalog browser** plugin for connecting to a self-hosted [GeoLens](https://github.com/geolens-io/geolens) server, searching its catalog, and adding datasets as signed vector tiles, OGC API Features GeoJSON, or server-rendered raster tiles, with automatic tile-token refresh and a Metadata link back to each dataset's page
362+
- [x] **Editing GeoLens datasets in place**: a dataset added as GeoJSON can be redrawn with the GeoEditor or retyped in the attribute table, and the plugin's Edits section shows what changed and writes it back to the GeoLens dataset — added, moved, and deleted features become per-feature `POST`/`PUT`/`PATCH`/`DELETE` calls, with the row ids GeoLens assigns stamped back onto the layer. Offered only when the server has dataset editing enabled and the connection carries an API key; a rejected write stays pending instead of being silently dropped
362363
- [x] **iOS support**, scaffolded end to end — Tauri iOS configuration, location permissions, a signing and TestFlight CI workflow, and a publishing guide. See [iOS](ios.md)
363364
- [x] The **Android build is Google Play-ready**: a permanent `org.geolibre.app` package id, API level 36, 16 KB page-size alignment verified in CI, and a signed universal App Bundle published alongside the per-architecture sideload APKs. See [Android](android.md)
364365
- [x] **Emerging Hot Spot Analysis**: aggregate timestamped points into a space-time cube, run Getis-Ord Gi\* per time slice, and classify every cell as a new, intensifying, persistent, diminishing, sporadic, oscillating, or historical hot or cold spot, entirely in the browser

e2e/attribute-status.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect, test } from "@playwright/test";
2+
import { dropGeoJson, layerRow, readFixture, waitForMap } from "./helpers";
3+
4+
const FIXTURE_TEXT = readFixture("smoke.geojson");
5+
const FIXTURE_FEATURE_COUNT = (JSON.parse(FIXTURE_TEXT) as { features: unknown[] }).features.length;
6+
7+
/**
8+
* The attribute table's status bar: the layer's feature count, how many rows the
9+
* table is currently showing, and how many features are selected. The "shown"
10+
* count is deliberately absent until a filter narrows the table, so the ordinary
11+
* case reads as one number rather than two identical ones.
12+
*/
13+
test("the attribute table status bar reports totals, filtering, and selection", async ({
14+
page,
15+
}) => {
16+
await waitForMap(page);
17+
await dropGeoJson(page, "smoke", FIXTURE_TEXT);
18+
19+
const row = layerRow(page, "smoke");
20+
await expect(row).toBeVisible();
21+
await row.locator('button[aria-label="Layer actions"]').click();
22+
await page.getByRole("menuitem", { name: "Open attribute table" }).click();
23+
await expect(page.getByTestId("attribute-table")).toBeVisible();
24+
25+
const status = page.getByTestId("attribute-table-status");
26+
await expect(status).toContainText(`${FIXTURE_FEATURE_COUNT} features`);
27+
await expect(status).toContainText("0 selected");
28+
// Nothing is filtered, so the redundant "shown" count stays out of the way.
29+
await expect(status).not.toContainText("shown");
30+
31+
// Selecting a row is reflected immediately.
32+
await page.locator('[data-testid="attribute-table"] tbody tr').first().click();
33+
await expect(status).toContainText("1 selected");
34+
35+
// A search that matches one feature adds the "shown" count; the total stays
36+
// the layer's, not the filtered set's.
37+
await page.getByPlaceholder("Search attributes...").fill("Alpha");
38+
await expect(status).toContainText("1 shown");
39+
await expect(status).toContainText(`${FIXTURE_FEATURE_COUNT} features`);
40+
41+
// Clearing the search puts it back to the unfiltered reading.
42+
await page.getByPlaceholder("Search attributes...").fill("");
43+
await expect(status).not.toContainText("shown");
44+
});

packages/map/src/layer-sync.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2731,6 +2731,37 @@ function proxyWmsTileUrl(tileUrl: string): string {
27312731
return `${WMS_PROXY_PATH}?url=${encodedUrl}`;
27322732
}
27332733

2734+
/** The parts of MapLibre's `VectorTileSource` this module reads and updates. */
2735+
interface VectorSourceLike {
2736+
url?: string;
2737+
tiles?: string[];
2738+
setUrl?: (url: string) => unknown;
2739+
setTiles?: (tiles: string[]) => unknown;
2740+
}
2741+
2742+
/**
2743+
* Point an existing vector source at a new endpoint, if it moved.
2744+
*
2745+
* `setUrl`/`setTiles` reload the source, which drops its cached tiles and
2746+
* re-requests them, so this is deliberately a no-op when nothing changed — a
2747+
* needless reload would blank the layer for a frame on every sync.
2748+
*/
2749+
function updateVectorSourceEndpoint(
2750+
source: VectorSourceLike,
2751+
url: string | undefined,
2752+
tiles: string[] | undefined,
2753+
): void {
2754+
if (url) {
2755+
if (source.url !== url && typeof source.setUrl === "function") source.setUrl(url);
2756+
return;
2757+
}
2758+
if (!tiles || tiles.length === 0 || typeof source.setTiles !== "function") return;
2759+
const current = Array.isArray(source.tiles) ? source.tiles : [];
2760+
const unchanged = current.length === tiles.length && current.every((t, i) => t === tiles[i]);
2761+
if (unchanged) return;
2762+
source.setTiles(tiles);
2763+
}
2764+
27342765
function syncVectorTileLayer(map: maplibregl.Map, layer: GeoLibreLayer, beforeId?: string): void {
27352766
const src = sourceId(layer.id);
27362767
const url = layer.source.url as string | undefined;
@@ -2743,7 +2774,8 @@ function syncVectorTileLayer(map: maplibregl.Map, layer: GeoLibreLayer, beforeId
27432774
)
27442775
: undefined;
27452776
if (!url && !(tiles && tiles.length > 0)) return;
2746-
if (!map.getSource(src)) {
2777+
const existingSource = map.getSource(src) as VectorSourceLike | undefined;
2778+
if (!existingSource) {
27472779
if (url) {
27482780
map.addSource(src, { type: "vector", url });
27492781
} else {
@@ -2758,6 +2790,14 @@ function syncVectorTileLayer(map: maplibregl.Map, layer: GeoLibreLayer, beforeId
27582790
: {}),
27592791
});
27602792
}
2793+
} else {
2794+
// The source already exists, so a changed endpoint has to be pushed into it:
2795+
// MapLibre keeps serving whatever it has already cached otherwise, and the
2796+
// store update alone would never reach the map. This is what makes a
2797+
// re-signed tile URL (GeoLens mints short-lived tokens) take effect, and
2798+
// what re-renders already-loaded zoom levels after the underlying data
2799+
// changes — without it only zooms the user had not visited yet look updated.
2800+
updateVectorSourceEndpoint(existingSource, url, tiles);
27612801
}
27622802
const visibility = layer.visible ? "visible" : "none";
27632803
const sourceLayers = getVectorTileSourceLayers(layer);

packages/plugins/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,14 @@ export {
361361
export {
362362
DEFAULT_GEOLENS_LABELS,
363363
DEFAULT_GEOLENS_FEATURE_LIMIT,
364+
GEOLENS_FEATURES_SOURCE_KIND,
364365
GEOLENS_PLUGIN_ID,
366+
GEOLENS_SAMPLE_SERVERS,
365367
maplibreGeoLensPlugin,
366368
normalizeGeoLensFeatureLimit,
367369
setGeoLensLabels,
368370
type GeoLensLabels,
371+
type GeoLensSampleServer,
369372
} from "./plugins/maplibre-geolens";
370373
export {
371374
buildListObjectsUrl,

0 commit comments

Comments
 (0)