67 Commits
Author SHA1 Message Date
Qiusheng Wu 5a826b2fbb chore(release): v3.6.1 (#3313) 2026-11-02 16:47:58 -05:00
Qiusheng Wu 44b9c42a16 chore(release): v3.6.0 (#3299)
* chore(release): v3.6.0

* Address review feedback

- Say plugins do not receive the S3 secret key, since a presigned URL
  carries the access key ID, and note the trust scope.
2026-10-31 02:53:41 -04:00
Qiusheng Wu 5f8582e75a chore(release): v3.5.2 (#3268) 2026-10-27 20:50:17 -04:00
Qiusheng Wu 085c75735c chore(release): v3.5.1 (#3246) 2026-10-26 20:35:28 -04:00
Qiusheng Wu ffcf4b82e0 chore(release): v3.5.0 (#3209) 2026-10-25 15:01:50 -04:00
Qiusheng Wu 31264e594f chore(release): v3.4.0 (#3115) 2026-10-11 19:30:47 -04:00
20b04f9ce8 feat(assistant): add Vercel AI Gateway as a provider (#3018)
* feat(assistant): add Vercel AI Gateway as a provider

One AI Gateway key reaches the gateway catalog through the existing Chat Completions client, with a public model picker, so it no longer has to be entered as a custom endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* Address CodeRabbit review feedback

- docs/roadmap.md: separate keyless model catalog discovery from the
  AI_GATEWAY_API_KEY used for Chat Completions requests.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: giswqs <giswqs@gmail.com>
2026-10-07 14:48:35 -04:00
Qiusheng Wu 38ed979d3b docs: review the docs against the code and fix drift (#3008)
* docs: review the docs against the code and fix drift

Check every docs page against the current code and correct what no
longer matches: menu paths and labels, plugin placement (most plugins
now open in a docked panel), Cesium ion tokens being optional, desktop
OAuth sign-in for sharing, browser project save/open, project schema
fields, server and collaboration endpoints, Python and MCP APIs, and
stale paths in the source-code deep dives.

Fix the Spatial SQL tutorial's ST_Area_Spheroid query, which needs
ST_FlipCoordinates for lat/lon axis order. Document the Settings
sections that were listed but never described (Language, Appearance,
Interface, Geocoding, AI Providers), and add the USGS NLDI and ArcGIS
feature editing pages to the nav.

* docs: keep the NLDI and ArcGIS editing pages out of the nav

* Address CodeRabbit review feedback

- collaboration.md: give both snapshot-cap defaults, 10 MB for the
  relay and 1 MB in the Docker Compose setup.
- features.md, cesium-renderer.md: without an Ion token the globe still
  gets terrain from keyless Terrarium elevation (or a configured COG),
  not a bare ellipsoid (cesium-engine.ts enableWorldTerrain).

* Address CodeRabbit review feedback

- project-format.md: type blankBackgroundColor and selectedLayerId as
  string | null, matching ProjectFile in packages/core/src/types.ts.
2026-10-06 19:03:19 -04:00
Qiusheng Wu c2a74d5a43 chore(release): v3.3.0 (#2942) 2026-10-05 00:05:39 -04:00
Qiusheng Wu aa28b2a4f9 chore(release): v3.2.0 (#2791) 2026-10-01 15:47:14 -04:00
Qiusheng Wu 5b5b3b8146 chore(release): v3.1.0 (#2680) 2026-09-25 18:43:48 -04:00
Qiusheng Wu b1221d9d40 feat(cesium): add Google Photorealistic 3D Tiles and Asset Depot samples to the Ion quick picks (#2551)
* feat(cesium): add Google Photorealistic 3D Tiles to the Ion quick picks

Ion asset 2275207 joins Cesium OSM Buildings and Bing Aerial as a quick
pick in Add Data -> Cesium Ion Asset, and the picks move from a row of
buttons to a dropdown so the list can grow without widening the dialog.

The dropdown mirrors the form instead of holding its own state: typing an
asset id by hand, or switching the layer type, drops it back to the
placeholder rather than leaving a stale pick selected.

* feat(cesium): add Asset Depot sample datasets to the Ion quick picks

Six samples from the Cesium ion Asset Depot join the quick picks: Japan
3D Building Data, Melbourne Photogrammetry, Melbourne Point Cloud,
Montreal Point Cloud, New York City 3D Buildings, and Washington DC 2017.
Every id is the one Cesium's own Sandcastle and engine samples use.

Depot assets only stream once an account has added them, so the picks are
split into two optgroups and the hint under the dropdown swaps to say so
when a depot sample is selected -- otherwise the asset endpoint 404s and
the layer silently never paints.

Japan Regional Terrain is left out: it is an Ion terrain asset, and
terrain is a scene setting rather than a layer here. Filed as #2552.

* Address Claude review feedback

- Re-picking the placeholder in the Ion quick-picks dropdown now clears the
  asset id, layer type, and layer name instead of returning without a state
  change, which left the native select showing the placeholder while the
  fields below still held the previous pick (the DeckVizSource "Custom model"
  precedent).
- Moved the quick-pick assertions out of the "does not mistake an ordinary
  layer for an Ion asset" test into their own it() block, so a failure names
  the behaviour it checks.

* fix(cesium): zoom to a layer whose extent only the globe knows

Adding a Cesium Ion asset left the camera where it was, and the Layers
panel's zoom-to did nothing for it: both go through fitLayer, which
resolves an extent from the store, and an Ion asset (like a tileset by
URL, CZML, or KML) keeps none there. The extent belongs to the loaded
Cesium object.

CesiumLayerSync.zoomToLayer flies to the entry's handle -- Viewer.flyTo
reads a tileset's bounding sphere, an imagery layer's rectangle, or a
data source's entities, and an I3S provider's rectangle is used directly
since it is not a flyTo target. A layer added a moment ago has no handle
yet, so the request is held and runs when that entry finishes loading,
and is dropped if the layer goes away first.

CesiumEngine.fitLayer hands over to it when the store has no bounds, and
the Add Data Ion source now asks for the fit.

* fix(cesium): surface a globe layer that fails to load

A tileset or imagery layer whose load rejects was invisible: the sync
recorded the failure on the entry, but only screenshot readiness and
capture ever read it, so the Layers panel showed a healthy row over an
empty globe. That is what an Ion Asset Depot sample looks like when the
account has not added it -- the endpoint 404s, nothing draws, and the
camera has no extent to fit, which reads as "zoom to layer is broken".

The sync now reports the failure through an onLayerError dep, the Cesium
engine forwards it as a MapDiagnosticEvent, and CesiumCanvas /
PrimaryCesiumCanvas pass it to the shell's diagnostics handler -- the
same channel MapCanvas and MapboxCanvas already use. A failed Ion asset
now reads "<layer>: Request has failed. Status Code: 404" in Diagnostics.
2026-09-22 11:45:46 -04:00
Qiusheng Wuandpre-commit-ci[bot] faf582b9f8 feat(basemap): offer Cesium and Mapbox basemaps in the picker (#2511)
* feat(basemap): offer Cesium and Mapbox basemaps in the picker

The basemap picker only listed engine-specific choices for ArcGIS, so the
Cesium globe and the native Mapbox renderer had no way to switch basemaps
from the dialog. Picking a shared style now also clears a pinned Cesium
override, matching how Mapbox and ArcGIS already behave.

* fix(basemap): remove keyed Stadia choices from Cesium

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* Address CodeRabbit review feedback

- Clear a pinned Cesium basemap override for secondary Cesium panes too, not
  only when Cesium is the primary renderer: split panes pick their renderer
  independently and CesiumCanvas reads the shared preference, so such a pane
  would otherwise ignore the newly picked basemap (matches the ArcGIS rule).
- Cover the split-pane case in the Cesium basemap test.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-09-20 16:35:30 -04:00
Qiusheng Wu 9778da6cbf chore(release): v3.0.0 (#2378) 2026-09-13 21:36:03 -04:00
Qiusheng Wu 4c4c6c7584 chore(release): v2.9.0 (#2220)
* chore(release): v2.9.0

Bump every version-locked file to 2.9.0: the root, app, and packages/*
package.json, the Tauri crate Cargo.toml and tauri.conf.json, the geolibre
Python package __init__.py, and CITATION.cff (version plus date-released,
which the Validate CITATION.cff job checks against package.json). workers/*
stay pinned at 0.0.0 and backend/geolibre_server keeps its own version.

Add the v2.9 roadmap section and mark v2.8 as no longer current, and extend
the features page with the capabilities this release opens up.

Claude-Session: https://claude.ai/code/session_017x81cDoYXbJK4YwKoq1drz

* Address Claude review feedback

Two new top-level bullets in docs/features.md were inserted between a parent
bullet and its own sub-bullets, so the sub-bullets would have re-nested under
the new bullet when the list rendered:

- The GeoParquet metadata bullet landed above "Layers whose features exist only
  in memory or in a local file embed them behind a size cap", which belongs to
  the Layer Library entry. Moved below it.
- The global Identify bullet landed above the popup and tooltip designer's four
  sub-bullets, which describe the designer. Moved below them.

Also moved the "catalog plugins are mutually exclusive" note to the end of the
Data catalog browsers sub-list. It is a cross-cutting note rather than a
browser, and it was splitting the named browsers in two.

Audited the other four insertions on this branch for the same class of problem:
the DashMap sub-bullet, the Planet and Vantor sub-bullets, and the docked Web
Services bullet all sit at the right level. A structural check now reports no
orphaned sub-bullets in the file.

Claude-Session: https://claude.ai/code/session_017x81cDoYXbJK4YwKoq1drz
2026-09-02 20:35:27 -04:00
Qiusheng Wu 074973372b 3D globe: offer the pane without a Cesium Ion token and render the store basemap on it (#2205)
* feat(cesium): offer the 3D globe without an Ion token and render the project basemap on it

The globe pane was hidden unless a Cesium Ion token was configured, and it
ignored the project basemap entirely — showing Ion World Imagery with a token
and OpenStreetMap without. A project saved with a globe pane silently reopened
as the 2D map, and switching the project to a dark basemap left a bright globe
in the corner.

The pane is now offered either way, and draws the project's own basemap:

- `basemapToCesiumImagery()` (`@geolibre/core`) translates `basemapStyleUrl`
  into a plain, engine-free descriptor. Planetary and regional basemaps are
  already raster tile sets and carry straight over (TMS included, plus a hybrid
  basemap's labels overlay); GeoLibre's vector styles map to a keyless raster
  basemap of matching tone; the blank basemap draws a bare ellipsoid. Anything
  with no raster form reports `default` and leaves the fallback to the renderer.
- `applyBasemapImagery()` (`packages/map/src/cesium-basemap.ts`) turns that into
  imagery layers, inserted at the bottom of the stack so they stay below the
  data layers CesiumLayerSync appends. It falls back to Ion World Imagery when a
  token is present — preserving the old behaviour for projects that rely on
  it — and to OpenStreetMap when not.
- `MapGrid` always shows the 2D/3D toggle, and a tokenless globe pane carries a
  one-line hint about what a token would add rather than disappearing.

The tone analogues use OpenStreetMap and EOX Maps rather than the obvious CARTO
raster endpoints: verified in a browser, CARTO now stamps every raster tile with
an "API KEY REQUIRED" watermark.

Attribution is forwarded into Cesium's credit display, so the keyless raster
basemaps stay licence-clean.

Refs #2180

Claude-Session: https://claude.ai/code/session_01E9PXzrYZMVNBKhxFEU5ksY

* Address review feedback

- Honor `basemapVisible` and `basemapOpacity` on the globe (CodeRabbit).
  `CesiumCanvas` selected only `basemapStyleUrl`, so hiding or fading the
  Background row left the globe opaque while the 2D map dimmed. Adds
  `applyBasemapAppearance()` and a live effect that re-styles the drawn layers
  rather than rebuilding them; a hybrid basemap's overlay fades with its
  imagery, matching the 2D map treating the pair as one background.
- Stop `basemapToCesiumImagery()` throwing on a malformed percent-escape
  (CodeRabbit). A URL keeps `%E0%A4` in its pathname but decoding it raises
  `URIError`, and the call sits inside a render, so a hand-edited project could
  take the pane down. Decoding now degrades to `{ kind: "default" }`.
- Draw the basemap once per mount (Claude). `ready` flips right after the mount
  effect's own draw, so the `[ready, basemapImagery]` effect immediately redrew
  the same descriptor — rebuilding providers, restarting the first tile
  requests, and flashing a hybrid basemap's overlay. Guarded on the applied
  descriptor.
- Qualify the `getCesiumIonToken` docstring (CodeRabbit): the globe draws the
  project basemap only when it has a raster form, and falls back to Ion World
  Imagery, or OpenStreetMap without a token, for the ones that do not.
- Align the token summaries in `docs/features.md` and `docs/roadmap.md` with
  that same fallback behaviour (CodeRabbit).

Claude-Session: https://claude.ai/code/session_01E9PXzrYZMVNBKhxFEU5ksY

* Address review feedback

- Compare basemap descriptors by value, not reference (Claude). The redraw
  guard added in 1e62517a used `===`, but `toImagery()` builds a fresh object
  per call, so switching between two basemaps that share an analogue
  (OpenFreeMap liberty -> bright, both the streets tone) still rebuilt an
  identical tile provider — the flash and re-requested tiles the guard was
  meant to prevent. Adds `sameCesiumImagery()` in core beside the descriptor
  type, so the comparison is robust however descriptors are produced.

Claude-Session: https://claude.ai/code/session_01E9PXzrYZMVNBKhxFEU5ksY
2026-09-01 21:10:59 -04:00
Qiusheng Wu 477e9cfb4e chore(release): v2.8.0 (#2131) 2026-08-27 10:48:29 -04:00
Qiusheng Wu ea91c93730 feat(statistics): composite score builder for suitability and index analysis (#2124)
* feat(statistics): composite score builder for suitability and index analysis

Adds a Spatial Statistics tool that normalizes several numeric fields,
weights them, and writes a single 0-100 index column, then hands the
result to the graduated renderer so the layer lands already styled by
the score.

Each field gets a normalization (min-max, z-score, rank, or quantile), a
relative weight, and the end of its range that scores well. The fields
combine with a weighted arithmetic mean, or a geometric mean when a
strong field should not be able to compensate for a weak one.

- Rank and quantile share ranks across ties (mid-rank), so equal inputs
  always score equally; a field with no spread contributes a neutral 0.5.
- Null handling is an explicit choice with no default: drop the feature,
  or renormalize its weights over the fields it has.
- The weights are shown as the share of the total each field carries.
- Optional per-field component columns, so a score can be audited.

New pieces this needed: a `field-weights` parameter type with its own
row editor, offered only the layer's numeric fields; a `ResultLayerOptions`
argument on `addResultLayer` so a tool can ask for graduated styling; and
a placeholder option on a required select that has no default.

Fixes #2116

* Address review feedback

- Compute min/max with a loop instead of spreading a per-feature array
  into Math.min/Math.max, in the min-max normalization and in the run
  summary. A composite score has no MAX_WEIGHTS_FEATURES cap, so a large
  layer would have hit the engine's argument limit. (CodeRabbit, Claude)
- Keep a zero weight as "no contribution" rather than silently restoring
  it to 1, matching what the editor's share preview already shows; guard
  the zero total in computeCompositeScores so it yields null scores
  instead of NaN, disable Run when no field carries weight, and error
  with "give at least one field a weight above zero" on a run that
  reaches the tool anyway. (Claude)
- Detect numeric fields by the value's own type: booleans, blank
  strings, and single-element arrays all survive Number(), and none of
  them belongs in a scoring picker. fieldColumn now applies the same
  rule, so the dialog offers exactly what the tool can score. Quoted
  numbers still count. (CodeRabbit, Claude)
- Document the score range as 0-100 by default with 0-1 on request, in
  the features list, the roadmap, and the user guide. (CodeRabbit)

Adds six tests over the zero-weight, boolean, and numeric-string paths.

* Address review feedback

- Skip zero-weight fields before reading their components in
  computeCompositeScores, so a null in a field the user weighted out no
  longer marks the feature missing and drops it under "drop" handling.
  Covered by a new regression test.
- Warn when the user-named score field (or a kept component column)
  already exists on the layer, instead of silently overwriting it. Every
  other tool in the file writes a fixed name, so this is the first place
  a collision is possible. Covered by a new test.
- Reword the "renormalize" unscored log to "had no positively-weighted
  value", which is accurate for both ways the per-feature weight sum can
  land at zero.
- Distinguish "one numeric value" from "not numeric" in
  buildSymbologyStyle's graduated-mode error, so a composite run that
  scores a single feature no longer reports the wrong cause.
- Complete the roadmap wording for the composite score item.

* Address Claude review feedback

- Clamp a negative or non-finite field weight to zero inside
  computeCompositeScores. The dialog path already clamped in
  readFieldWeights, but the function is exported, and a negative share
  would push the combined mean outside the [0, 1] the components live on,
  putting the score outside its documented range. Covered by a test.
- Extract the "is this property value numeric" rule as
  numericFieldValue in the processing package and have both fieldColumn
  and the dialog's field picker call it, so the fields offered and the
  fields actually scored cannot drift apart.

* Address Claude review feedback

- Pull the score's decimal-precision rule into one scoreDecimals helper
  instead of repeating `scale >= 100 ? 2 : 4` in both
  computeCompositeScores and the tool's summary statistics, so a third
  score range cannot round the scores and the log differently.
2026-08-26 20:29:05 -04:00
Qiusheng Wu fafaa6ece3 feat(layers): search the loaded layers' attributes from the search box (#2121)
* feat(layers): search the loaded layers' attributes from the search box

The Layers panel search box forward-geocoded the typed query and resolved
a lat/lon coordinate and an H3 cell index locally, but it never looked at
the data on the map. Typing a parcel id, a station code, or a well name
from the layer you just loaded returned a place somewhere else entirely,
so finding a known feature meant opening the attribute table and
filtering.

The box now searches both, independently:

- `lib/feature-search.ts` matches the query against the string and
  stringifiable attributes of the loaded vector layers, ranked exact
  match first, then prefix, then contains (shorter value wins a tie), one
  row per feature on its best-ranked field. It is capped per layer by
  feature count and by a wall-clock budget, and a scan that stopped early
  labels its group partial. Fields marked hidden or excluded are skipped,
  as are hidden layers and tile-backed layers holding no local features.
- The component runs that scan on its own short debounce, so data matches
  land while the geocoder call is still in flight, and renders them as
  per-layer groups above the place results. The two halves share one flat
  row index, so the arrow keys walk the whole dropdown.
- Selecting a feature makes it the live selection on its layer and frames
  it through `highlightFeature(..., { fit: true })`, the same path the
  attribute table and Zoom to selection already use.

A query with no data match renders exactly as before, place results with
no heading above them.

* Address Claude review feedback, and clear the selection on Clear search

- `isSearchableLayer` folded group visibility in through
  `effectiveLayerRenderState`. `layer.visible` is a raw per-layer flag that a
  hidden parent group never writes to, so a layer inside a hidden group was
  still searched and could be flown to, the exact case the docstring says it
  avoids. `searchLayerFeatures` takes the project's `layerGroups` and builds
  the id map once; the component passes them from the store.
- `settle()` aborts the in-flight forward-geocode. The local scan answers on a
  much shorter debounce, so a row can be picked while the network call is still
  outstanding, and letting it resolve repopulated the dropdown with results for
  a query already moved away from.
- The local-scan effect resets `activeIndex`. It re-runs on any `layers`
  change, not only on typing, so rebuilding shorter rows under a stale index
  could leave it past the end of the list; the Enter handler now also ignores a
  row that is not there.
- Added a call-wide `totalBudgetMs` (120ms). Each layer had its own budget and
  a layer matching nothing still paid a full scan, so the cost grew with the
  layer count; a layer can no longer spend more than the call has left.
- Dropped the unreachable `exactCount` decrement: the rank guard above it
  already keeps an exact match from being overwritten.
- The component gates the local scan on the exported `MIN_FEATURE_QUERY_LENGTH`
  rather than its own geocoder minimum, so the two cannot drift.

Clear search now also clears the feature selection, so the highlight a picked
row left on the map goes with the query text (through the store, not by
touching MapLibre).

* Address CodeRabbit and Claude review feedback

- The local scan no longer runs while the box is idle. The effect depends on
  `layers`, and the query keeps a picked label, so every layer mutation (a
  refresh, a time filter, a visibility toggle) paid for a scan nobody would
  see; it now returns early unless the dropdown is open or the input has focus.
- The places half enters the loading state when the query changes rather than
  when its timer fires, and drops the previous query's rows there. The local
  scan can open the dropdown within 120ms while the geocoder waits >=500ms, so
  the "Places" heading could sit over nothing, or over the last query's results,
  for the gap between the two.
- `rows` carries place rows only while they are actually rendered. They were
  always in the list, so during a follow-up geocode the arrow keys walked rows
  that were not on screen, Enter flew to a place from the previous query, and
  `aria-activedescendant` named an id with no element.
- `handleClear` only takes back a selection this box made. Escape runs it too,
  and `selectFeature(null)` writes the same app-wide selection the attribute
  table and map click-select use, so focusing the input and pressing Escape
  discarded an unrelated selection.
- The row cap marks its group partial. Slicing to `maxPerLayer` dropped matches
  while leaving `truncated` false, so the group claimed to show everything.
- `settledQuery` replaces the `skipNextSearch` flag. Both debounce effects read
  it and neither clears it (the input's own `onChange` does), so the two halves
  no longer depend on the order React runs them in.
- Tightened `BUDGET_CHECK_INTERVAL` to 128 features and documented that the
  budgets are best-effort to that granularity, not hard ceilings.

* Address CodeRabbit and Claude review feedback

- Selection ownership is tracked by identity, not a flag. The flag stayed true
  after the attribute table or the map moved the shared selection elsewhere, so
  Clear or Escape discarded a selection this box no longer held. `handleClear`
  now clears only while the live selection is still exactly the feature a row
  picked, through `holdsOwnedSelection`, which is unit tested for the sequence
  the review named (pick here, pick elsewhere, Escape here).
- Photo and internal fields are no longer matched. `photo`/`photo_full` hold
  image data URLs that can run to megabytes, and a base64 alphabet makes a
  two-character query hit nearly all of them, so the whole blob could land in a
  dropdown row. The scan now skips `isInternalPopupField` keys and `photo` the
  way `visiblePopupFields` does, and `searchableText` rejects any `data:` value,
  which covers a blob on a field the two key names do not name.
- The per-layer budget is checked after the first feature as well as every
  interval, so a layer smaller than one interval is no longer exempt from it
  however expensive its property sets are.
- A feature row whose layer has been removed since the scan no longer sets a
  `selectedLayerId` pointing at nothing.
- The "Places" heading now requires that the places half has something under it
  (rows, a spinner, or a message), so it cannot be stranded by the geocoder's
  minimum query length and the local scan's minimum diverging.

* Address CodeRabbit and Claude review feedback

- The local scan's gate reads `open` from a ref rather than a dependency. The
  scan is also what opens the dropdown, so with `open` in the dependency list
  the first match re-ran the effect and scheduled a second, identical scan whose
  `setActiveIndex(-1)` silently cleared a row the user had already highlighted
  with the arrow keys.
- Both debounce timers re-check the settle marker before applying. Selecting a
  row whose label matches the text already in the input makes `setQuery` a
  no-op, so the effects never re-run and their cleanups never cancel the pending
  timers, which then reopened the dropdown on a box the user had settled.
- `searchableText` rejects a data URL case-insensitively: the scheme is
  case-insensitive, so `DATA:` carried the same blob past the check.

* Address Claude review feedback

- Picking a place, a coordinate, or a cell now releases the feature selection
  this box had made. Only the feature branch of `handleSelect` touched
  `ownedSelection`, so searching again and choosing a place flew the map away
  while the old feature stayed selected in the store, still drawn by the
  highlight overlay off-screen and still shown as selected by the attribute
  table. A feature row keeps its own branch, since it replaces the selection.
2026-08-26 10:56:38 -04:00
Qiusheng Wu 9ce647b64c chore(release): v2.7.0 (#2048) 2026-08-22 00:18:57 -04:00
Qiusheng Wu 54934d0f7a feat(style): drive geometry-generator size and buffer distance by an attribute (#1894)
* feat(style): drive geometry-generator size and buffer distance by an attribute

The geometry generator could only draw its derived symbols at one fixed
size: every centroid took the same `Point radius`, and every buffer the
same distance in meters. Asked on the discussion below, a polygon layer's
centroids should be able to scale with a field (proportional symbols on
the derived points), and buffers should be able to take their distance
from a column instead of a single typed number.

Both are now attribute-driven:

- `geometryGeneratorSizeProperty` (plus its value/radius range) turns the
  derived centroid circles into an `interpolate` over the chosen field.
  It is deliberately separate from `proportionalSizeProperty`, which also
  feeds `line-width` — reusing it would have resized a polygon layer's
  outlines as a side effect of sizing its centroids.
- `geometryGeneratorBufferProperty` buffers each feature by its own value
  in meters. Following QGIS's data-defined overrides, a missing, blank or
  non-numeric value falls back to the flat buffer distance, and a feature
  whose distance resolves to zero is skipped rather than drawn as its own
  untouched geometry (turf's zero buffer returns the source).

Both pickers list only numeric columns, and choosing a size field seeds
the value range from the data so the first render is already meaningful.

Discussion: https://github.com/opengeos/GeoLibre/discussions/1877

* Address Claude review feedback

- Keep a stored generator size/distance field selected in the picker even
  when it is not a numeric column on the active layer (a pasted style, a
  `?style=` import, or a changed schema). The browser was falling back to
  "None (fixed)" while the style still sized by a field that resolves to
  null everywhere, collapsing every centroid onto the minimum radius with
  nothing in the UI to explain it. Mirrors the diagram field pickers, and
  the select now only disables when there is neither an option nor a
  stored value to show.

* Address Claude review feedback

- Treat a whitespace-only buffer-field value as a missing value. Number()
  trims before coercing, so " " read as a real zero-distance buffer and
  the feature was skipped instead of falling back to the flat distance —
  the same failure mode the empty-string guard was already closing.
- Factor the shared field-driven size validation out of
  proportionalSizeRange and generatorSizeRange, so a future tightening of
  the rules cannot land on one and miss the other.

* Address review feedback

- Only coerce numbers and numeric strings into a buffer distance. Number()
  turned `true` into a 1 m buffer and `[1000]` into a 1 km one; those are
  no more a distance than a name is, so they take the flat fallback now.
- Seed the generator's size range from the defaults, not from whatever the
  previous field left behind, when the chosen column has a single numeric
  value and so no spread to derive a range from. The comment above claimed
  a numeric column always yields a usable range; it does not.
- de: use "Entfernung" for the buffer distance field, per the locale's
  distance/spacing convention.
- es: the hint said the entity "is stored in a buffer" and referred to the
  "previous" distance; reword it around the section's own "zona de
  influencia" wording and the fixed fallback.
- vi: fix two pre-existing strings in the same block, both flagged as
  wrong on review: generator strokeColor read "Chữ" (text) and
  typeConvexHull was an unrelated sentence about string conversion.

* Address CodeRabbit review feedback

- Make the buffer-fallback test prove the behavior it names. It asserted
  only that every width matched, with a numeric string equal to the flat
  distance, so a regression to a bare Number(raw) could slip `[1000]`
  through and a broken numeric-string path would look identical to a
  fallback. The flat distance and the string value now differ from each
  other and from the values a coercion regression would produce; the test
  fails against the pre-fix implementation.

* Address Claude review feedback

- Say in the buffer distance-field hint that a value of 0 draws nothing.
  The tooltip explained the missing-value fallback but not the skip, so a
  column with legitimate zeros made features vanish with no explanation.

* Address review feedback

- Cover the layer-sync wiring in symbology-pack-sync: one case asserting
  the generated centroid layer's circle-radius becomes the interpolate,
  and one asserting the buffer distance field reaches the derived
  geometry (two squares with different values buffer to different
  extents). Both paths were unit-tested in isolation but nothing checked
  that applyGeometryGeneratorLayers threads them through.
- ar: reword the buffer hint, which could be read as "features without
  the value of the distance above"; it now says the fixed distance
  applies to features with no valid distance value.

* Address Claude review feedback

- Keep the flat generator point-radius input visible once a size field is
  chosen. It is the radius generatorCircleRadiusValue falls back to when
  the field's range turns out degenerate, so hiding it left the radius
  actually in use with no way to see or change it.
2026-08-14 13:30:20 -04:00
Qiusheng Wu ef1604f66b chore(release): v2.6.0 (#1887) 2026-08-13 23:19:55 -04:00
Qiusheng Wu 360f3a932f docs(ios): GeoLibre is on the App Store (#1869)
* docs(ios): GeoLibre is on the App Store

The iOS app has been approved and is live, so the docs no longer describe
it as scaffolding that has never shipped. Adds the App Store badge and
install links, and rewrites the iOS page around a published app: an
Install section, per-release submission steps, and the App Store Connect
onboarding marked done.

* Address Claude review feedback

- docs/ios.md: mark every completed App Store Connect onboarding step with
  an inline *(Done.)* so the numbered list reads correctly on its own,
  instead of the intro enumerating step numbers that would drift if the
  list is ever renumbered.

* Address CodeRabbit review feedback

- docs/comparison.md: add iOS to the opening platform summary, which still
  listed only desktop, Android, and Jupyter after the table row was updated.
- docs/ios.md, docs/downloads.md: scope the sideload wording to GeoLibre.
  Saying Apple installs apps only through the App Store or TestFlight
  overstates it, since Ad Hoc, enterprise, custom apps, and regional
  alternative marketplaces exist. What is true is that GeoLibre does not
  publish a sideloadable .ipa for end users.
- README.md, docs/getting-started.md: recolor the Mac App Store badge from
  black to Apple blue, so every store badge carries its platform's brand
  color instead of one outlier.
2026-08-12 22:14:32 -04:00
Qiusheng Wu e895829626 chore(release): v2.5.0 (#1728)
* chore(release): v2.5.0

* Address review feedback

- Bump `packages/embed/package.json` to 2.5.0. It was left at 2.4.1 while
  every sibling workspace moved, and `@geolibre/embed`'s version is
  documented as tracking the app's, so the v2.5.0 GitHub Release would have
  published the embed client as 2.4.1.
- Regenerate `package-lock.json` so its workspace entries match the bumped
  `package.json` files. `npm ci` treats that drift as an out-of-sync lockfile
  and fails. The diff is the nine version fields only, with no dependency
  churn.
2026-08-05 20:04:07 -04:00
Qiusheng Wu d3ba817289 docs: add the Mac App Store listing and its limitations (#1656)
* docs: add the Mac App Store listing and its limitations

GeoLibre Desktop is now on the Mac App Store, so the badge and download
links point at it. The Store build is sandboxed, so the docs spell out
what it drops relative to the Homebrew and DMG build (Python sidecar
engines, PostGIS, the local Jupyter server, Earth Engine sign-in, and
external plugin installs) and keep Homebrew as the recommended route.

* Address review feedback

- Say "App Store ID" for the numeric listing identifier instead of "Apple
  ID", which reads as a user account login, and note what App Store Connect
  calls it.
- Qualify what the Store build drops as server-backed PostgreSQL/PostGIS via
  martin, so the in-browser PGlite/PostGIS engine is not read as missing too.
- Name Apache Sedona in the retained SQL Workspace row and scope the sidecar
  row to the SedonaDB sidecar: IS_MAS_BUILD skips the sidecar probe and runs
  Sedona on the in-browser CereusDB engine, so Sedona SQL still works there.

* Address Claude review feedback

- Split the comma-spliced macOS packaging bullet so the Homebrew Cask is its
  own sentence rather than a trailing fragment.
2026-08-03 08:56:21 -04:00
Qiusheng Wu ca8c8ec954 docs: add the Google Play listing and refresh project status (#1545)
* docs: add the Google Play listing and refresh project status

GeoLibre is now published on Google Play as org.geolibre.app, so the
README badges, Getting Started, Downloads, and Android pages point users
at the store instead of only the sideload APKs. The Project status
summary had also stalled at 2.2 while the repo shipped 2.4.

* docs: raise the Google Play badge contrast

The 414141 fill sat almost on top of the badge's default gray label half,
so the two sides ran together. Google Play's brand green separates them
the way the Microsoft Store badge already does.

* Address Claude review feedback

- docs/android.md: "Routine updates are steps 2-3" was wrong. Step 2 is
  Play App Signing, a one-time console action; only step 3 (upload the
  AAB, bump versionCode) recurs per release.
- docs/features.md: reword the Android bullet so the App Bundle and the
  sideload APKs read as two outputs of the same workflow.

* Address Claude review feedback

- docs/getting-started.md: drop "and tablets" from the Android touch-layout
  claim. The rest of the docs scope the responsive layout to phones, and
  widening it is unrelated to adding the Play listing.
2026-07-29 12:21:45 -04:00
Qiusheng Wu 874b024d5e chore(release): v2.4.0 (#1517) 2026-07-29 00:29:42 -04:00
Qiusheng Wu 4c3a188702 feat(search): resolve H3 cell indexes in the place-search box (#1497)
* feat(search): resolve H3 cell indexes in the place-search box

Typing an H3 cell index into the Layers panel search box now resolves it
locally, with no geocoder round-trip, and jumps to that cell. Both
spellings people copy around are accepted: the hexadecimal string
(8928308280fffff, optionally 0x-prefixed and in any case) and the
unsigned 64-bit integer (617700169958293503).

Selecting the result fits the view to the cell rather than flying to a
fixed zoom, since a cell spans anything from a continent at resolution 0
to under a square meter at resolution 15, and outlines the cell on the
map so the match is visible. The outline is ephemeral, cleared on the
next selection, on Clear, and on unmount.

Parsing lives in a standalone lib module alongside coordinates.ts so it
unit tests in isolation, and unwraps boundary longitudes around the cell
center so a cell crossing the antimeridian yields a contiguous ring.

Fixes #1492

* i18n: localize the search placeholder's coordinate and H3 hints

The English placeholder advertises place, lat/lon, and H3 input, but every
other locale still read "Search places...", so the two locally resolved
query forms were undiscoverable outside English. Bring all 15 remaining
locales in line.
2026-07-28 08:39:02 -04:00
Qiusheng Wu 2c8ef73506 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.
2026-07-25 22:53:53 -04:00
Qiusheng Wu 417e52911e chore(release): v2.3.0 (#1424)
* chore(release): v2.3.0

* docs: link GeoLens to its upstream repo
2026-07-25 11:24:14 -04:00
Qiusheng Wu de5df2599f chore(release): v2.2.0 (#1334)
* chore(release): v2.2.0

* docs(release): bump homepage project status to v2.2

* Address Claude review feedback

- i18n: hoist the language-switch race guard from a per-hook-instance ref to a
  module-scope token via a new setActiveLanguage(). The old useRef only guarded
  races within one mounted useLanguage() instance, so it would silently break if
  a second call site (or a remount) were added. setActiveLanguage loads the
  target catalog, applies "latest request wins" across all callers, resolves
  true only when it actually switched (callers persist only then), and rejects
  only for the latest request's genuine fetch failure.
- useLanguage: use setActiveLanguage; drop the now-redundant ref/guard.

* Address Claude review feedback

- i18n: apply the "reject only for the latest request" contract to
  setActiveLanguage's changeLanguage step too, not just loadCatalog. If
  changeLanguage rejects (rare) for a request a newer switch has already
  superseded, swallow it instead of logging a misleading "Failed to change
  language" — matching the catalog-fetch guard above.

* Address Claude review feedback

- i18n: serialize setActiveLanguage's changeLanguage calls through a module-scope
  queue so overlapping switches apply in issue order, not resolution order. The
  token guard already prevented persisting a superseded selection, but two
  in-flight changeLanguage calls could still resolve out of order and flip the
  visible i18next language back to a stale one. Queued switches now run one at a
  time and skip themselves once superseded, so the newest request always wins
  the final active language.
2026-07-18 21:31:29 -04:00
Qiusheng Wu 3ba3a290e8 feat(plugins): Source Cooperative data browser (Plugins > Web Services) (#1262)
* feat(plugins): Source Cooperative data browser (Plugins > Web Services)

Adds a Source Cooperative (https://source.coop) browser: search the
catalog, walk a product's files, and add GeoParquet, PMTiles, COG,
GeoJSON, and FlatGeobuf straight to the map or download them.

Two properties of the platform shape the design:

- `data.source.coop` sends `Access-Control-Allow-Origin: *` and supports
  byte ranges, so data is read directly from the browser -- a 115 GB
  PMTiles archive streams without a proxy.

- `source.coop/api/v1` sends no CORS headers at all, so metadata cannot
  be fetched from a browser. Desktop reads it over Tauri's native HTTP;
  web/dev/embed go through a new origin-gated `/source-coop` route on the
  tiles Worker, mirroring the existing `/oam/meta` proxy. The route
  exposes only the two public unauthenticated reads and cannot be pointed
  at the API-key routes.

There is also no public search endpoint -- source.coop's own search is a
Next.js Server Action, not REST, and unknown `/api/v1` paths return an
HTML 404 body with status 200. Search is therefore assembled client-side
from the three public listings that do exist (the RSS feed's 50 newest,
`/products/featured`, and `/products/{account}`) and filtered locally,
with `account/product` queries resolved directly so any product stays
reachable by id. Every read validates the parsed shape rather than
trusting `response.ok`, because of that HTML-404 trap.

Adding a layer delegates to the controls that already know each format --
a new `addPMTilesLayerFromUrl` and `addVectorLayerFromUrl` expose the
PMTiles and vector panels' own load paths without opening their UI -- so
the result lands in the Layers panel, styles, and persists exactly like
the same file added by hand through Add Data.

Verified in a browser against live source.coop data: the catalog loads,
search narrows, subfolder navigation works, and both GeoParquet
(cholmes/admin-boundaries) and PMTiles (protomaps/openstreetmap, 115 GB)
render and appear in the Layers panel.

* Address review feedback

- Surface PMTiles load failures instead of reporting success (Claude).
  PMTilesLayerControl._addLayer catches its errors into `state.error` and
  emits "error" rather than rejecting, so `addLayer()` resolved even for an
  unreachable or malformed archive and addPMTilesLayerFromUrl returned true.
  Because the control is mounted hidden, its own on-panel error was never
  visible either, so a failed add did nothing at all with no message. Now
  checks `getState().error` after the await and throws, the same way the
  sibling addLayerWithCogRasterControl handles CogLayerControl. Confirmed
  against a forced failure: previously silent, now reports "Failed to load
  PMTiles: …" in the panel.

- Encode the account segment in buildListObjectsUrl (CodeRabbit). It
  interpolated `accountId` raw while buildObjectUrl encoded it; the value
  comes from unvalidated API/feed text, so a `#` or `?` could truncate the
  path. Added a regression test.

- Correct the classifyKey extension-table comment (Claude): it claimed the
  order mattered against a `.json` rule that does not exist. The patterns are
  mutually exclusive; the comment now says why `.geo.json` needs its own rule.

- Document downloadObject's cross-origin behavior accurately (CodeRabbit).
  The `download` attribute is ignored cross-origin, so the comment no longer
  implies it renames the file, and records why the direct link is deliberate
  (a proxy would stream 100+ GB archives through the tiles Worker).

- Fix the indentation of the new toolbar.plugin key in en.json (Claude).
2026-07-15 12:09:54 -04:00
Qiusheng Wu e5c945625a chore(release): v2.1.0 (#1230)
* chore(release): v2.1.0

* Address Claude review feedback

- Regenerate package-lock.json so its root and workspace version entries
  match the 2.1.0 bump in package.json; without this, CI's `npm ci` fails
  with an out-of-sync lockfile error.

* docs: update project status on homepage for v2.1

- Bump "GeoLibre 2.0 is a stable release" to 2.1 and add a Version 2.1
  summary sentence (Browser panel, route animation, object detection,
  map recording, photo viewer, Wikipedia cards, planetary basemaps,
  OpenAerialMap plugin) to the docs homepage Project status section.
2026-07-13 23:08:08 -04:00
Qiusheng Wu c3510460a7 feat(planetary): USGS basemaps for nine more celestial bodies (#1196)
* feat(planetary): USGS basemaps for nine more celestial bodies

Add Mercury, Venus, the four Galilean moons, Titan, Pluto, and Charon as
selectable planetary basemaps. USGS Astrogeology only serves these as
equirectangular WMS, so the tiles Worker now reprojects each tile to Web
Mercator on the fly. Group the new bodies under one collapsible "Other
celestial bodies" section, and make the on-map scale bar use the active
body's radius instead of assuming Earth.

* Address Claude review feedback

- WMS path: skip negative-caching 5xx/429 upstream failures so a transient
  USGS outage/throttle isn't pinned as a blank tile, matching the OPM path.
- Cap the /wms reprojection zoom (MAX_WMS_ZOOM) so a direct client can't run
  up Worker CPU + USGS load with unlimited high-z cache keys.
- Add a [limits] cpu_ms block to wrangler.toml so the CPU-bound reprojection
  has a concrete budget and the code comment references a real setting.

* Address CodeRabbit review feedback

- Use Object.hasOwn() for both the OPM and WMS dataset lookups (ES2022,
  clearer than the hasOwnProperty.call form).

* Address CodeRabbit review feedback

- Replace the hand-rolled upng-js ambient declaration with the published
  @types/upng-js, switching to a namespace import; verified the reprojection
  still decodes/encodes correctly at runtime.

* feat(planetary): use Venus MDIR colour in the planet switcher

The Layers-panel celestial-body switcher now applies the Magellan C3-MDIR
colour mosaic for Venus instead of the grayscale FMAP.

* Address Claude review feedback

- Lower MAX_WMS_ZOOM from 10 to 8 (one level above the datasets' native max)
  to shrink the high-zoom cache-key space a client could enumerate.
- Log WMS reprojection failures (dataset/status/content-type) so a misconfigured
  dataset surfaces in Worker logs instead of failing silently as a blank tile.
- Add tests asserting the picker groups: the nine non-Earth/Moon/Mars bodies
  land in the "other" section and it is sorted alphabetically by body name.
- CollapsibleSection: pair aria-expanded with aria-controls via useId.

* docs: document the expanded celestial-body basemaps

Update the README, docs index, roadmap, and Adding Data guide to cover the new
USGS Astrogeology basemaps (Mercury, Venus, the Galilean moons, Titan, Pluto,
Charon) reprojected to Web Mercator, alongside the existing OpenPlanetaryMap
Mars and Moon sets, the per-project ellipsoid, and the planet switcher.

* Address Claude review feedback

- Refresh the body-aware scale bar in applyMapPreferences, so changing the
  ellipsoid via Settings (without changing the basemap) updates the bar
  immediately instead of waiting for the next pan/zoom.

* feat(planetary): auto-expand the Other bodies section for the active basemap

The "Other celestial bodies" picker section stays collapsed by default but now
opens automatically when the currently-active (or selected) basemap is one of
those bodies, so the highlighted selection is never hidden.

* Address Claude review feedback

- Negative-cache WMS decode/warp/encode failures as a transparent tile (with a
  logged warning) instead of returning an uncached 502, so a misconfigured
  dataset degrades once rather than re-running the CPU-bound path forever.
2026-07-12 12:25:55 -04:00
Qiusheng Wu 40afc031c2 chore(release): v2.0.0 (#1180) 2026-07-10 23:44:28 -04:00
Qiusheng Wu 70125ded30 chore(release): v1.10.0 (#1067)
* chore(release): v1.10.0

* Address review feedback

- Refresh package-lock.json to record version 1.10.0 for the root package
  and every bumped workspace entry (was left at 1.9.0 by bump-version.sh,
  which only syncs Cargo.lock). Ran `npm install --package-lock-only`; no
  dependency ranges changed, only the version fields.
2026-07-05 21:29:28 -04:00
Qiusheng Wu 71a3f20da0 chore(release): v1.9.0 (#1040)
* chore(release): v1.9.0

* Address review feedback

- Sync package-lock.json workspace versions to 1.9.0 (Copilot, Claude): the
  bump left the lockfile at 1.8.0 for the root and the geolibre-desktop, core,
  map, plugins, processing, and ui workspace entries, which would break
  `npm ci` in CI. Updated those 8 entries surgically (left the unrelated
  xmlhttprequest dependency at 1.8.0); `npm ci --dry-run` now passes.
2026-07-01 12:17:46 -04:00
Qiusheng Wu ce8ff71330 chore: move web viewer to web.geolibre.app (#929)
The web viewer now resolves at web.geolibre.app, so point the Cloudflare
custom-domain route, embed/HTML-export base URLs, docs, and tests at the
new hostname.
2026-06-26 23:00:40 -04:00
Qiusheng Wu 7436225ec0 chore(release): v1.8.0 (#904) 2026-06-25 21:50:30 -04:00
Qiusheng Wu 50c1c46be6 chore(release): v1.7.0 (#764)
* chore(release): v1.7.0

* Address Claude review feedback

- Regenerate package-lock.json so the root and all eight GeoLibre workspace
  entries record version 1.7.0, matching the bumped package.json manifests
  (the bump script synced Cargo.lock but missed the npm lockfile). Only the
  GeoLibre workspace version fields changed; no dependency resolution changed.
2026-06-23 01:00:49 -04:00
Qiusheng Wu b7baab8b4f docs: add Microsoft Store install option and distribution badges (#758)
* docs: add Microsoft Store as a Windows install option

* docs: add Microsoft Store, AUR, and FlatPark badges

Surface the new Windows Microsoft Store listing and the existing Arch (AUR)
and Flatpak (FlatPark) install paths as badges, and correct the stable
release version to 1.6.

* Address Claude review feedback

- Clarify that winget installs the unsigned GitHub Release build, not the
  signed, auto-updating Microsoft Store build
2026-06-22 17:58:56 -04:00
Qiusheng Wu de14289bae chore(release): v1.6.0 (#698)
* chore(release): v1.6.0

* chore(release): update CITATION.cff to v1.6.0

* Address Claude review feedback

- Regenerate package-lock.json so the root and all workspace entries
  (apps/geolibre-desktop, packages/*) record 1.6.0 instead of the stale
  1.5.0, keeping the lockfile in sync with the bumped package.json files.
- Add a "reference copy; CI regenerates per release" header to the three
  packaging/winget/*.yaml manifests, matching the note already on the
  aur/copr/linux packaging files, so the intentionally-lagging PackageVersion
  is signposted (winget-releaser owns the real submission to winget-pkgs).

* Address review feedback (round 2)

- README: reword the Vector menu bullet to fold the new movement, space-time,
  and cell-coverage tools into the parenthetical tool list, so the restrictive
  "that run in the browser" clause covers them without the spurious comma the
  Claude bot flagged.
- packaging/winget/*.yaml: make the header's README cross-reference
  unambiguous — point to the "How CI keeps it current" section of the
  README.md in this directory (which documents the version-lag policy), rather
  than the bare "See README.md" that read as the root README.
2026-06-22 01:36:09 -04:00
Qiusheng Wu 5b89731c02 chore(release): v1.5.0 (#581)
* chore(release): v1.5.0

* docs(roadmap): place v1.5 after v1.4 (ascending order)

* Address Claude review feedback

- Regenerate package-lock.json after the version bump so all seven workspace
  entries record 1.5.0 (root, apps/geolibre-desktop, packages/core, map, ui,
  plugins, processing); bump-version.sh synced Cargo.lock but not the npm
  lockfile, which would fail `npm ci` consistency checks in CI.
2026-06-20 02:10:13 -04:00
Qiusheng Wu 6f22532351 feat(toolbar): add View menu with viewport history navigation (#510) (#515)
* feat(toolbar): add View menu with viewport history navigation (#510)

Add a View menu after the Edit menu offering browser-style Previous/Next
View navigation through recent map extents, addressing the accidental
loss of a carefully framed view described in #510.

- useViewportHistory hook records each user-settled viewport (moveend)
  into a back/forward stack and restores entries via the controller's
  applyView, flagging its own moves so they are not re-recorded.
- ViewMenu surfaces Previous/Next View, disabled at the stack ends.
- Previous/Next View also added to the command palette's View group.
- The View menu trigger uses secondaryButtonClass (hidden below md), so
  it collapses on small mobile screens and the menu bar stays one row.

* feat(toolbar): add Reset North and Reset Pitch & Bearing to View menu

Add two camera-reset items to the View menu (issue #510 follow-up):
- Reset North levels the compass (bearing -> 0), leaving pitch/center/zoom.
- Reset Pitch & Bearing flattens and faces north (bearing 0, pitch 0).

Both wrap MapLibre's resetNorth / resetNorthPitch via new thin
MapController methods, are also in the command palette's View group, and
are registered in the UI-profile catalog so they honour the active
profile and appear in Settings -> Interface.

* feat(toolbar): add Zoom In/Out and Set View to the View menu

Extend the View menu (issue #510 follow-up):
- Zoom In / Zoom Out at the top, wrapping MapLibre's zoomIn/zoomOut.
- Set View… opens a dialog to jump to an exact camera (center
  longitude/latitude, zoom, pitch, bearing). The dialog reads the live
  camera via the controller's readView() each time it opens, so the
  fields always reflect the current view; submitting animates there via
  flyTo. Pitch and bearing default to 0 when left blank.

All items are also in the command palette's View group and registered in
the UI-profile catalog so they honour the active profile and appear in
Settings -> Interface.

* Address Claude review feedback

- useViewportHistory: guard the controller before setting restoringRef in
  restore() so an absent map can't leave the flag stuck true and swallow the
  next user pan.
- useViewportHistory: clear restoringRef on effect (re)attach and in cleanup so
  a restore in flight during a map reinit can't leave a stuck flag on the new
  map.
- useViewportHistory: add a projectGeneration parameter and reset the stack when
  a different project loads, so "Previous View" can't cross into the previous
  project's extents.
- Animate viewport restores: add MapController.easeToView (easeTo) and use it in
  restore() instead of the instant applyView/jumpTo, matching the browser-style
  framing and the animated reset actions.
- viewsEqual: reword the doc-comment to say "identical (exact equality)" rather
  than "close enough", which implied epsilon matching.
- ViewMenu: skip the heading/separator when a profile hides every item so the
  dropdown isn't an empty shell.

* fix(e2e): seed onboarding as completed so the wizard overlay doesn't block tests

The first-launch UI-profile onboarding wizard (issue #500) defaults to
`onboarded: false`, so it opened on every fresh Playwright context. Its modal
overlay intercepted pointer events, timing out any spec that clicked through the
UI (the a11y suite failed at the "Layer actions" click). Seed
`geolibre.desktopSettings` with `uiProfile.onboarded: true` via a project-level
storageState so the wizard never renders in e2e. All 10 e2e specs pass locally.

* Address Claude/CodeRabbit review feedback (round 2)

- useViewportHistory: replace the boolean restoring flag with a counter.
  Rapid Previous/Next clicks cancel each in-flight easeTo and fire one moveend
  per call; a boolean was cleared by the first, letting a cancelled mid-animation
  position leak into the stack. The counter ignores exactly one moveend per
  driven move.
- SetViewDialog: reject blank longitude/latitude/zoom instead of coercing via
  Number("")===0, which silently submitted zoom 0 (whole-earth) or 0,0.
- ViewMenu: return null when a profile hides every item, so the trigger can't
  open an empty dropdown shell.

* fix(ui-profile): keep Atmosphere Effects toggle visible for Beginners

Atmosphere Effects is activeByDefault, so gating its Controls-menu toggle at the
advanced tier left Beginners with the effect on and no way to turn it off. Lower
controls.atmosphereEffects to the basic tier so the toggle is always reachable.

* refactor: rename "Atmosphere Effects" to "Atmospheric Effects"

Use the grammatically correct adjective form for the plugin's display name and
toggle labels (plugin name, Controls-menu item, command-palette toggle), plus
the matching docs and code comments.

* Address Claude review feedback (round 3)

- useViewportHistory: skip moveends carrying a storyCameraToken so scripted
  story-presenter / chapter-preview camera moves are not recorded as user
  navigation; tag flyToView (editor preview) with the token too so both story
  paths are excluded.
- SetViewDialog: reject negative zoom/pitch (always invalid — MapLibre's
  min zoom/pitch are >= 0) with an error instead of a silent clamp; upper bounds
  stay with MapLibre since maxZoom/maxPitch are per-project.
- SetViewDialog: add HTML min/max to the number inputs (longitude ±180,
  latitude ±90, zoom/pitch >= 0) for native spinner bounds and screen-reader
  range hints.
- playwright.config: import DESKTOP_SETTINGS_STORAGE_KEY instead of hardcoding
  the localStorage key so a future rename is caught at compile time.
2026-06-18 22:10:57 -04:00
Qiusheng Wu 6f5e9ff59b chore(release): v1.4.0 - version bump and docs update (#399)
* chore(release): v1.4.0 - version bump and docs update

Bump the app and Python package minor version 1.3.0 -> 1.4.0 and document
features added since PR #382 (Notebook panel, Spectral Index toolbox, Raster
Georeferencer, Field Collection, runtime env overrides), excluding bug fixes.
Flatten the v1.3 and v1.4 roadmap sections to reduce page nav clutter.

* Address Claude review feedback

- README: add a build-time example for VITE_DUCKDB_SPATIAL_EXTENSION_PATH
  (full path/URL to the spatial extension file loaded via LOAD) alongside the
  existing Pyodide block, so air-gapped mirror builds have a value to follow.
- roadmap: move the Homebrew Cask item ahead of the Android/offline items in
  the flattened v1.3 list so macOS packaging no longer reads as an
  offline/mobile concern.
2026-06-15 22:38:55 -04:00
Qiusheng Wu 04f581cc75 feat(android,pwa): native Android build + offline caching + region download (#377)
* feat(android,pwa): native Android build + offline caching + region download

Android (Tauri v2 mobile):
- Make the crate compile for Android: cfg-gate the desktop-only OAuth-popup
  window code (WebviewWindowBuilder::{on_new_window, window_features} don't
  exist on the mobile runtime) behind #[cfg(desktop)]. Desktop behavior is
  unchanged; a debug APK now builds via `tauri android build`.
- Add .github/workflows/android.yml to build the debug APK in CI (JDK 21,
  Android SDK + NDK r27 LTS, Rust android targets) and upload it as an artifact.
- Document the port status, verified toolchain, and remaining work in
  docs/android-port-spike.md.

PWA / offline:
- Cache the CDN-loaded heavy engines (Pyodide, PGlite/PostGIS) in a new
  geolibre-cdn-engines CacheFirst rule so browser SQL + Python work offline
  after first use; raise the basemap cache cap for region downloads.
- Add a "Download Offline Area" feature (Project menu): pick the current view +
  zoom range and pre-fetch basemap tiles/style/sprite/glyphs into the SW cache.
  New lib/offline-tiles.ts (tile math + cache warming) with unit tests, and
  OfflineRegionDialog.

i18n: add the offline.* and toolbar.item.offlineRegionEllipsis strings (en).

* feat(mobile): responsive panel overlays + touch-capable resizing

- On phone-width viewports (max-md), expanded Layers/Style panels now overlay
  the map as slide-over sheets (absolute, z-30) instead of squeezing it into a
  sliver; collapsed thin bars stay in-flow. The map stays full-bleed and
  primary. Verified at 390x844 (map 640px tall; expanded panel overlays).
- Make panel resize handles touch-capable: convert the layer/style resize
  handlers and handle elements from mouse events to Pointer Events (+touch-none),
  so resizing works with touch/stylus, not just a mouse. Desktop is unchanged
  (pointer events also fire for mouse).
- Extract the duplicated inline isMobileViewport() checks into a shared, reactive
  useIsMobileViewport hook (with a getIsMobileViewport one-shot for initial
  state); LayerPanel/StylePanel now import it.

Also removes docs/android-port-spike.md.

* feat(mobile): hide sidecar/desktop-only tools on mobile (isMobile gating)

isTauri() is true on Android, so sidecar-backed tools previously appeared in the
UI and failed at runtime. Add a UA-based isMobile() helper (distinct from the
narrow-viewport hook) and hide the tools that can't run on a mobile OS:

- Processing menu: Whitebox, format Conversion, Raster tools, and AI Segmentation
  (all require the Python sidecar) are hidden on mobile. Vector (Turf), SQL
  (PGlite/DuckDB), Python (Pyodide), geocode, statistics, network, and the
  assistant run client-side and remain.
- Add Data menu: the PostgreSQL source (served via the Martin binary, no Android
  build) is hidden on mobile.

Verified in a real browser: with an Android UA those entries are absent while the
client-side tools remain; desktop is unchanged. Adds tests/is-mobile.test.ts.

* build(android): ship slim release APKs + sign in CI

The debug APK was ~208 MB, dominated by the unstripped debug Rust .so (210 MB).
Switch the Android workflow to a release build, which uses the existing
size-optimized + stripped Cargo profile → ~41 MB APK (.so ~38 MB), a ~5x cut.

- .github/workflows/android.yml: build release (not --debug), then zipalign +
  sign. Uses release-keystore secrets (ANDROID_KEYSTORE_BASE64 / _PASSWORD /
  ANDROID_KEY_ALIAS / _PASSWORD) when present; otherwise falls back to a
  throwaway debug keystore so the CI artifact is still installable for testing.
- tauri.android.conf.json: drop bundle.resources (the Python backend) on Android
  — it can't run there and only bloats the package.

* fix(android): safe-area insets, launcher icon, per-ABI APK splits

Three issues found testing the APK on a phone:

- Status bar overlap / unclickable menus: the webview drew edge-to-edge, so the
  top toolbar rendered under the Android status bar — which intercepts taps,
  making those menu buttons unresponsive. Add viewport-fit=cover and pad #root by
  env(safe-area-inset-*) so the chrome clears the system bars and cutouts. Insets
  are 0 on desktop/web, so it only affects mobile. Verified on an emulator.

- Wrong launcher icon: `tauri android init` writes default Tauri icons. CI now
  copies the checked-in GeoLibre icons (src-tauri/icons/android) over the
  generated mipmaps after init. Verified the GeoLibre globe shows in the launcher.

- Oversized CI APK (~150 MB): the workflow built a universal APK bundling all
  four ABIs. Build --split-per-abi instead and sign each, giving ~40 MB
  per-architecture APKs (install the arm64-v8a one on real phones).

* feat(android): name the app "GeoLibre" (not "GeoLibre Desktop")

Override productName to "GeoLibre" in tauri.android.conf.json so the Android
launcher label and activity title read "GeoLibre". The desktop build keeps
"GeoLibre Desktop" (base tauri.conf.json). Verified a clean `tauri android init`
generates app_name=GeoLibre in strings.xml.

* fix(android): pad adaptive launcher icon into the safe zone

The foreground filled the full 108dp canvas, so the launcher's adaptive mask
cropped the globe's edges. Scale the foreground to ~64% centered (all densities)
so the whole icon stays within the ~66dp safe zone and is never clipped.

* docs: document Android app, mobile, and offline features for v1.3

- README: native Android app + Download Offline Area + engine offline caching in
  the v1.3 feature list, an Android build/run section, and a Reference link.
- New docs/android.md: toolchain setup, build, signing, sideload, and emulator
  guide (added to mkdocs nav).
- roadmap: a "Mobile, offline, and Android" subsection under v1.3.
- index: mention Android/mobile/offline in the v1.3 status summary.

Also: build the Android APK only on published releases (and on demand), not on
every PR — the Rust cross-compile + Gradle build is slow.

* fix(ui): keep long dropdown menus within the visible viewport on mobile

On Android the dropdown max-height used 100vh / Radix's available-height, both
based on the layout viewport, which extends under the system status/navigation
bars. Long menus (e.g. Add Data) then ran their last items under the nav bar
where they were clipped and unreachable. Cap the max-height with dvh minus the
top/bottom safe-area insets so the menu (and its scroll area) stays within the
visible region and scrolls to the end. Desktop/web are unchanged (dvh == vh and
insets are 0).

* Address Copilot/Claude/CodeRabbit review feedback

offline-tiles.ts:
- collectOfflineUrls: accept an AbortSignal (Cancel now interrupts TileJSON
  discovery), thread source `subdomains` into expandTileUrl, and handle the
  MapLibre array `sprite` format so multi-sprite styles cache their icons.
- expandTileUrl: drop `{s}` together with its trailing dot when no subdomains,
  avoiding invalid `https://.host/...` URLs.
- Glyphs: stop encodeURIComponent-ing the fontstack so the warmed URL matches
  MapLibre's actual (browser-normalized) glyph request and the SW cache key hits.
- countTiles/enumerateTiles: split antimeridian-crossing bboxes (west > east).
- warmUrls: clamp concurrency to >=1, and don't increment `done` on aborted
  fetches (finally ran after the catch `return`).

OfflineRegionDialog.tsx: catch collectOfflineUrls errors (no false "done"),
abort the in-flight download when the dialog closes, only clear abortRef if it
still points to this run, disable Download when no service worker is active, and
warn when the area exceeds the SW cache cap.

DesktopShell.tsx: panel resize handles now setPointerCapture, listen for
pointercancel, and tear down on unmount, so a canceled/interrupted drag can't
leave listeners attached or document.body styles stuck.

is-mobile.ts: detect iPadOS 13+ desktop-UA via maxTouchPoints (+ tests).

AddDataMenu/ProcessingMenu: memoize the isMobile() check (stable per session).

android.yml: pin android-actions/setup-android to a commit SHA, rename the job
to "(release)", fail fast when keystore secrets are partially set, and pass
apksigner passwords via files instead of CLI args.
2026-06-15 01:06:18 -04:00
Qiusheng Wu 1c0ad2d627 chore(release): v1.3.0 — version bump and docs update (#373)
* chore(release): bump version to 1.3.0 and update docs

Bump the app and Python package minor version 1.2.0 -> 1.3.0 and
document features added since PR #290 in the README, docs site, and
roadmap (excluding bug fixes).

* Address Claude review feedback

- README: drop "distributed" from the Apache Sedona SQL description; the
  engine runs via the SedonaDB sidecar, not a Spark/Flink cluster, so
  "distributed spatial SQL" is misleading.
- docs/index.md: note the real-time collaboration card requires
  VITE_GEOLIBRE_COLLAB_URL (the feature is gated/hidden when unset).

* Address Claude review feedback

- docs/index.md: link the Collaboration card to collaboration.md and
  user-guide/storymaps.md, and drop the print-layout sentence (print is
  not a collaboration feature).
- docs/index.md: link the geocoding portion of the Network analysis card
  to user-guide/data-integrations.md#geocoding (no dedicated network
  page exists to link the network tools to).
- README: note real-time collaboration requires the
  VITE_GEOLIBRE_COLLAB_URL build variable and link docs/collaboration.md.
2026-06-14 19:39:01 -04:00
Qiusheng Wu 579d8b4252 chore: release v1.2.0 (version bump, README, docs, roadmap) (#289)
Bump every JS workspace, the Tauri config and Rust crate, and the geolibre
Python package from 1.1.x to 1.2.0, and document the features shipped since
v1.1.0 in the README, roadmap, and docs landing page.
2026-06-12 23:35:49 -04:00
Qiusheng Wu 5dc75986c2 chore: release v1.1.0 (version bump, README, docs, roadmap) (#224)
* chore: release v1.1.0 — bump version, update README, docs, and roadmap

- Bump version 1.0.0 -> 1.1.0 across the JS workspaces (root + @geolibre/*
  + geolibre-desktop), the Tauri config and Rust crate (Cargo.toml/lock),
  the package-lock, and the geolibre Python package.
- README: retitle Features (v1.1) and document the features added since v1.0
  (Add Vector Layer auto-refresh, layer rename / open-table / export actions,
  categorized/graduated/expression styling incl. Add Vector Layer layers,
  attribute-table column management + export, and app.getDeckGL for plugins).
- docs/roadmap.md: add a v1.1 section listing every feature shipped since v1.0
  and drop "(current)" from v1.0.
- docs/plugin-api.md: document app.getDeckGL().
- docs/index.md: refresh the feature cards (attribute-table management,
  symbology modes, atmosphere effects plugin).

* Address review feedback

- docs/plugin-api.md: add the GeoLibreDeckGL interface definition so plugin
  authors using app.getDeckGL() have a typed contract for the returned
  deck.gl modules instead of only the prose description. (Claude, CodeRabbit)
- packages/{map,ui}: revert react/react-dom from ^19.0.0 back to ^19.2.7 — the
  release npm install had downgraded those ranges, leaving them below the
  @types/react ^19.2.17 dev dependency. This was unintended scope for the
  version bump; restoring ^19.2.7 realigns the runtime range with the types and
  with main. (Claude)

* Address Claude review feedback

- docs/plugin-api.md: move the GeoLibreDeckGL interface above GeoLibreAppAPI so
  it is defined before getDeckGL references it, matching the declaration order
  in packages/plugins/src/types.ts and removing the forward reference.
2026-06-11 11:21:20 -04:00
Qiusheng Wuandgiswqs 9f643c3d72 feat: add geolibre Python package for Jupyter notebooks (#197)
* feat: add geolibre Python package for Jupyter notebooks

Embed the full GeoLibre app in a notebook cell as an anywidget, with a
leafmap-style Python API (Map.add_geojson/add_tile_layer/add_cog/...) and
two-way .geolibre.json project sync.

- New python/ package: Map widget, project/layer builders, named basemaps,
  a localhost app server, and the postMessage bridge front-end. The web build
  is bundled into the wheel by a hatch build hook (npm run build:embed).
- App-side useEmbedBridge hook applies host load-project messages and emits
  debounced project snapshots; wired into DesktopShell. New build:embed script
  builds with a relative asset base so the app loads from the wheel.
- CI workflow to test and publish to PyPI via OIDC trusted publishing.
- Docs: README section, docs/python.md guide, mkdocs nav, home feature card,
  and a v1.0 roadmap entry.

* Address Claude review feedback; set default map height to 800px

Review feedback (PR #197):
- useEmbedBridge: only accept inbound messages from the embedding host
  (event.source === host) so a same-page script cannot inject a project.
- useEmbedBridge: drop the dead `window.parent ?? window` fallback (window.parent
  is always defined in a browser); clarify the comment.
- useEmbedBridge: stop the serialize -> JSON.parse round-trip in postState; send
  the project object directly, keeping the string only for the dedup check.
- project.load_featurecollection: add a 30s timeout and a 50 MB size cap to the
  URL fetch so a slow or oversized response cannot hang or OOM the kernel.
- _server: handle_error now only swallows ConnectionResetError/BrokenPipeError
  and re-raises genuine handler bugs.
- geolibre.remove_layer: replace the lambda __setitem__ workaround and ambiguous
  `l` name with a named inner function.
- _frontend.js: post to the iframe using its own origin instead of "*"; document
  the synchronous-change-dispatch dependency of the applyingRemoteState guard.

Also:
- Set the default Map height to 800px (trait default, __init__ default, JS
  fallback, and docs).

* Address CodeRabbit review feedback

- publish-python.yml: pin the third-party pypa/gh-action-pypi-publish action to
  a commit SHA (release/v1), matching the repo's convention of SHA-pinning
  third-party actions while leaving first-party actions/* on tags as in ci.yml.
- useEmbedBridge: reset lastLoadedSeq to 0 when an inbound load omits seq, so a
  later snapshot cannot echo a stale, unrelated sequence number.
- _frontend.js: also validate inbound event.origin against the app origin
  (defense in depth alongside the existing event.source check).
- project.load_featurecollection: normalize URL transport failures (URLError,
  TimeoutError) to ValueError to match the documented contract.

* Address Claude review feedback (round 2)

- _frontend.js: replace the synchronous `applyingRemoteState` boolean loop guard
  with reference-identity tracking (`lastRemoteProject`), so echo suppression no
  longer depends on anywidget firing change:project synchronously.
- useEmbedBridge: cache the echo-suppression string from the store *after*
  loadProject normalizes the project, so a normalized load is not re-echoed.
- project.py: cap local GeoJSON file reads at 50 MB (matching the URL cap) via a
  shared _MAX_GEOJSON_BYTES constant.
- geolibre.load_project: validate required top-level keys (version, name,
  mapView) and raise ValueError instead of pushing an invalid project that would
  only surface through the error trait.
- docs: add a security note that add_geojson(url) fetches from the kernel and
  intentionally allows private/localhost URLs; not for shared kernels. (SSRF
  flag — a hard private-IP block would break legitimate local tile servers.)

* Support Google Colab; address Claude/CodeRabbit review feedback (round 3)

Colab support:
- Serve the app port to the front-end (_app_port trait + _server.app_port()).
- _frontend.js resolves the iframe base via google.colab.kernel.proxyPort on
  Colab (falling back to the localhost URL elsewhere), so the browser reaches
  the kernel through Colab's port proxy. Docs updated: Colab now supported.

Review feedback:
- save_project: create parent directories instead of raising FileNotFoundError.
- build_empty_project: raise ValueError for a center with fewer than 2 elements.
- _server.serve_app: document that static_dir is ignored after the first call;
  simplify the singleton guard to `_base_url is None`.
- useEmbedBridge.postState: post the JSON-parsed snapshot so the wire payload
  matches the serialized .geolibre.json form and is structured-clone-safe
  (CodeRabbit); clarify the geolibre:error target-origin comment.

* Bump Python package to v0.2.0

* Address Claude/CodeRabbit review feedback (round 4)

- load_project: backfill a missing `layers` to [] and reject a non-list, so the
  add_*/remove_layer mutators cannot KeyError on a project loaded without it
  (CodeRabbit).
- useEmbedBridge: learn the host's origin from its first message and scope
  outbound geolibre:state / geolibre:error posts to it (falling back to "*" only
  for the version-only ready ping), so a third-party framing page cannot read
  the project. Document why applyLoad re-stringifies an object for parseProject.
- _frontend.js: note that traitlets.Dict value-based change detection keeps the
  kernel from re-broadcasting the just-sent project, so the identity loop guard
  is not defeated by the save round trip.
- geolibre.py: use p.update({...}) instead of __setitem__ in clear_layers and
  add_basemap for readability.

* Support JupyterHub and remote servers via jupyter-server-proxy

The localhost app server is unreachable from the browser on remote kernels. Add
a server-proxy path so the widget works on JupyterHub and other remote setups:

- Map(server_proxy="auto"|True|False): "auto" (default) serves directly from
  localhost and switches to jupyter-server-proxy automatically under JupyterHub
  (detected via JUPYTERHUB_SERVICE_PREFIX). True forces it on any remote server;
  False forces the direct path. (_use_server_proxy trait + _resolve_server_proxy.)
- _frontend.js: when proxying, load the app from {base_url}proxy/{port}/, reading
  the Jupyter base URL from the page's jupyter-config-data. Colab's own port
  proxy still takes precedence. The relative-asset build makes the app work under
  the proxy path prefix.
- Add the `hub` extra (jupyter-server-proxy); document JupyterHub/remote support
  in the README and docs; add tests for the proxy resolution.

* Address Claude review feedback

- useEmbedBridge.ts: gate proactive postState on a host handshake so a
  third-party frame that never speaks can't keep receiving project
  snapshots via the "*" fallback; use an empty mount-only effect dep array
  instead of the stable mapControllerRef.
- project.py: validate center has exactly 2 elements (no silent truncation);
  omit rescale from rasterState when None (match bands/colormap); reject a
  FeatureCollection without a 'features' list.
- geolibre.py: mark set_center_zoom as a leafmap-compat alias; document that
  add_geojson inlines file/URL sources into the project.
- hatch_build.py: add a 600s timeout to the npm build subprocess.
- _frontend.js: document the anywidget object-identity invariant the
  echo-suppression guard relies on.
- publish-python.yml: test against Python 3.10 and 3.12 (declared minimum).

* Address Claude review feedback (round 2)

- geolibre.py: when a source string starts with "{" but isn't valid JSON
  (e.g. a path like `{backup}/map.json`), fall back to reading it as a file
  instead of raising JSONDecodeError.
- hatch_build.py: raise a clear RuntimeError (install Node.js / run npm ci)
  when npm isn't on PATH, instead of an opaque FileNotFoundError traceback.
- project.py: use copy.deepcopy for the preferences default instead of a
  json.loads(json.dumps(...)) round-trip.
- _frontend.js: reframe the loop-guard comment to call out the primary
  (traitlets value-based dedup) vs secondary (reference identity) protection.
- useEmbedBridge.ts: document the embed trust model in the hook's JSDoc.
- docs/python.md, python/README.md: list set_center_zoom in the API table.

* Address Claude review feedback (round 3)

- project.py: deep-copy DEFAULT_LAYER_STYLE before spreading so a caller
  mutating a returned layer's nested style (e.g. vectorStyleStops) can't
  corrupt the module constant shared by later layers.
- project.py: add an SSRF guard to remote GeoJSON fetches — reject hosts that
  resolve to non-public addresses and re-validate every redirect hop, so a
  redirect to a cloud metadata endpoint (169.254.169.254) can't be followed.
- geolibre.py: validate layout/theme against their allowed values in the
  constructor so a typo raises immediately instead of silently falling back.
- geolibre.py: wrap load_project's file/JSON parse failures in the documented
  ValueError instead of leaking FileNotFoundError/JSONDecodeError.

* Address Claude review feedback (round 4)

- useEmbedBridge.ts: don't activate the bridge for a cross-origin parent
  unless ?embed=1 is present, so an arbitrary third-party iframe of a deployed
  app never receives project state (the widget and to_html() always pass
  ?embed=1; same-origin framing stays trusted).
- useEmbedBridge.ts: advance lastLoadedSeq before parsing in applyLoad so a
  later state snapshot carries the right correlation id even when a load fails.
- geolibre.py: in load_project, reject a non-dict mapView with a clear
  ValueError instead of letting set_center raise a confusing TypeError later.
- python/README.md: note that add_geojson inlines data and steer large layers
  toward tile/COG sources.

---------

Co-authored-by: giswqs <giswqs@users.noreply.github.com>
2026-06-10 06:28:33 -04:00