Commit 2c8ef73
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
File tree
- apps/geolibre-desktop/src
- components/panels
- i18n/locales
- lib
- docs
- e2e
- packages
- map/src
- plugins/src
- plugins
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
81 | 81 | | |
82 | 82 | | |
83 | 83 | | |
84 | | - | |
| 84 | + | |
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
| |||
Lines changed: 43 additions & 7 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
93 | 93 | | |
94 | 94 | | |
95 | 95 | | |
| 96 | + | |
96 | 97 | | |
97 | 98 | | |
98 | 99 | | |
| |||
170 | 171 | | |
171 | 172 | | |
172 | 173 | | |
173 | | - | |
| 174 | + | |
174 | 175 | | |
175 | 176 | | |
176 | | - | |
177 | | - | |
178 | | - | |
179 | | - | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
180 | 192 | | |
181 | 193 | | |
182 | 194 | | |
| |||
221 | 233 | | |
222 | 234 | | |
223 | 235 | | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
224 | 239 | | |
225 | 240 | | |
226 | 241 | | |
| |||
238 | 253 | | |
239 | 254 | | |
240 | 255 | | |
241 | | - | |
| 256 | + | |
242 | 257 | | |
243 | 258 | | |
244 | 259 | | |
| |||
313 | 328 | | |
314 | 329 | | |
315 | 330 | | |
| 331 | + | |
| 332 | + | |
316 | 333 | | |
317 | 334 | | |
318 | 335 | | |
| |||
322 | 339 | | |
323 | 340 | | |
324 | 341 | | |
325 | | - | |
| 342 | + | |
326 | 343 | | |
327 | 344 | | |
328 | 345 | | |
| |||
1914 | 1931 | | |
1915 | 1932 | | |
1916 | 1933 | | |
| 1934 | + | |
| 1935 | + | |
| 1936 | + | |
| 1937 | + | |
| 1938 | + | |
| 1939 | + | |
| 1940 | + | |
| 1941 | + | |
| 1942 | + | |
| 1943 | + | |
| 1944 | + | |
| 1945 | + | |
| 1946 | + | |
| 1947 | + | |
| 1948 | + | |
| 1949 | + | |
| 1950 | + | |
| 1951 | + | |
| 1952 | + | |
1917 | 1953 | | |
1918 | 1954 | | |
1919 | 1955 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3298 | 3298 | | |
3299 | 3299 | | |
3300 | 3300 | | |
| 3301 | + | |
| 3302 | + | |
| 3303 | + | |
| 3304 | + | |
| 3305 | + | |
| 3306 | + | |
3301 | 3307 | | |
3302 | 3308 | | |
3303 | 3309 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
440 | 440 | | |
441 | 441 | | |
442 | 442 | | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
359 | 359 | | |
360 | 360 | | |
361 | 361 | | |
| 362 | + | |
362 | 363 | | |
363 | 364 | | |
364 | 365 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2731 | 2731 | | |
2732 | 2732 | | |
2733 | 2733 | | |
| 2734 | + | |
| 2735 | + | |
| 2736 | + | |
| 2737 | + | |
| 2738 | + | |
| 2739 | + | |
| 2740 | + | |
| 2741 | + | |
| 2742 | + | |
| 2743 | + | |
| 2744 | + | |
| 2745 | + | |
| 2746 | + | |
| 2747 | + | |
| 2748 | + | |
| 2749 | + | |
| 2750 | + | |
| 2751 | + | |
| 2752 | + | |
| 2753 | + | |
| 2754 | + | |
| 2755 | + | |
| 2756 | + | |
| 2757 | + | |
| 2758 | + | |
| 2759 | + | |
| 2760 | + | |
| 2761 | + | |
| 2762 | + | |
| 2763 | + | |
| 2764 | + | |
2734 | 2765 | | |
2735 | 2766 | | |
2736 | 2767 | | |
| |||
2743 | 2774 | | |
2744 | 2775 | | |
2745 | 2776 | | |
2746 | | - | |
| 2777 | + | |
| 2778 | + | |
2747 | 2779 | | |
2748 | 2780 | | |
2749 | 2781 | | |
| |||
2758 | 2790 | | |
2759 | 2791 | | |
2760 | 2792 | | |
| 2793 | + | |
| 2794 | + | |
| 2795 | + | |
| 2796 | + | |
| 2797 | + | |
| 2798 | + | |
| 2799 | + | |
| 2800 | + | |
2761 | 2801 | | |
2762 | 2802 | | |
2763 | 2803 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
361 | 361 | | |
362 | 362 | | |
363 | 363 | | |
| 364 | + | |
364 | 365 | | |
| 366 | + | |
365 | 367 | | |
366 | 368 | | |
367 | 369 | | |
368 | 370 | | |
| 371 | + | |
369 | 372 | | |
370 | 373 | | |
371 | 374 | | |
| |||
0 commit comments