Commit Graph
43 Commits
Author SHA1 Message Date
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 49f083b334 feat(bookmarks): save bookmarks in the project (#3006)
* feat(bookmarks): save bookmarks in the project

Bookmarks lived only in the browser's localStorage, so they did not
travel with a .geolibre.json and the Python package and MCP server
could not author them.

- Project format: optional `bookmarks` and `bookmarkGroups` (folders),
  normalized on load (camera range checks, duplicate ids, bearing wrap,
  dangling folder ids dropped) and omitted when empty.
- Store: `bookmarks`, `bookmarkGroups` and `setBookmarks`; saved,
  autosaved and shared in collaboration like other project sections.
- Bookmarks panel: no longer persists to localStorage. It opens with
  the project's bookmarks, writes every edit (including host-dialog
  imports) back to the project, and rebuilds when a project load or a
  collaborator replaces them. Bookmarks saved by older versions move
  into the first project opened that has none, once.
- Python `Map.add_bookmark` / `remove_bookmark` and MCP tools
  `add_bookmark` / `remove_bookmark` (camera from the saved view by
  default, folder by id or name, layers to show), plus bookmarks in
  `describe_project`. The Python round-trip fixture now carries
  bookmarks through the app's parseProject.
- Docs: project format, Python, MCP, user guide, and the agent skill.

Part of #2869

* Address review feedback

- Trim a bookmark's groupId before matching it to the (trimmed) folder
  ids, so a hand-edited id with spaces is not silently un-filed
  (Claude review).
- Legacy migration no longer deletes the old localStorage bookmarks:
  a marker key records the one-time copy, so bookmarks copied into the
  wrong project stay recoverable from browser storage (Claude review).
- Python add_bookmark refuses a 501st bookmark before changing the
  project, matching the app's MAX_PROJECT_BOOKMARKS (500), with a drift
  test that pins the two together (CodeRabbit).

* Address review feedback

- Write the legacy-bookmark migration marker only once the project that
  received them is saved; a project discarded or replaced unsaved leaves
  it unset so the next empty project gets the copy (CodeRabbit).
- Correct the user guide: the old browser copy is left in place but no
  longer updated, not kept as a backup (Claude).
- Move the bookmarkGroups normalization above the print-layout comment so
  the comment stays attached to its code (Claude).
2026-10-06 18:20:03 -04:00
Qiusheng Wu 81fb86faaf feat(layers): editable layer metadata with STAC Item export (#2904)
* feat(layers): editable layer metadata with STAC Item export

Add an optional `descriptiveMetadata` block to GeoLibreLayer (title,
abstract, keywords, license, attribution, contact, lineage, temporal
extent, links). It is normalized on load and save, so old projects load
unchanged and an empty block is never written. The field is named
apart from the existing internal `metadata` record the renderers and
plugins key off.

The layer Metadata dialog becomes an editable form above the read-only
source details, with email, URL and date validation. Save commits the
draft through setLayerDescriptiveMetadata, one undoable store update.
"Export as STAC Item" writes a STAC 1.0 Item (extent, datetime or
start/end, providers, processing:lineage, links, a data asset for a
remote source URL). GeoParquet exports carry the block in the Parquet
key-value metadata under `geolibre:metadata`.

The layer library captures and restores the block, and the Python
package and MCP server gain set_layer_metadata / clear_layer_metadata,
pinned by the cross-language round-trip fixture.

Part of #2858

* Address review feedback

- MCP set_layer_metadata: merge temporal_start and temporal_end one at a
  time, so passing only one bound keeps the stored other bound.
- isRealDate: use setUTCFullYear instead of Date.UTC, which maps years
  0-99 to 1900-1999 and rejected valid dates such as 0099-01-01.
- metadataDateToRfc3339: normalize a compact +HHMM offset to +HH:MM
  before parsing; the compact form is not an ECMAScript date-time.
- Python link validation error names every accepted URL scheme.

* Address Claude review feedback

- Metadata dialog status line shows "Unsaved changes" alongside a save
  or STAC export note, so an export note cannot hide draft edits that
  the export (built from the stored record) did not include.
- STAC footprint of a box degenerate on one axis is a LineString, not a
  zero-area Polygon.

* Address Claude review feedback

- metadataDateToRfc3339 upper-cases the date-time before parsing: the
  validator is case-insensitive, but ECMAScript only promises to read an
  upper-case T and Z, so a lower-case z could export the export time.
- The Python mirror upper-cases too, covering a lower-case t separator.

* Address Claude review feedback

- A {z}/{x}/{y} tile template is exported as a `tiles` asset (role
  `tiles`, no guessed media type) instead of a `data` asset, since it
  cannot be fetched as-is. Documented in project-format.md.
2026-10-04 09:55:27 -04:00
Qiusheng Wu 126c51afb1 feat(python): author filters, labels, plugin state and story maps; live processing in MCP (#2892)
* feat(python): author filters, labels, plugin state and story maps

Add first-class authoring for layer filters, labels, plugin state and
story map chapters to geolibre.authoring, with thin wrappers on Map and
new MCP tools (set_layer_filter, set_labels, set_plugin_state,
set_story_map, add/remove/move_story_chapter). project.py builds the
pieces (labels object, filter expression, chapter) mirroring the
TypeScript defaults and vocabularies, and repo drift tests compare them
with types.ts and the plugin sources.

Add live_list_algorithms and live_run_algorithm MCP tools over the
desktop relay; processing runs in the app, so it is live-only.

A pinned fixture written by the authoring functions is loaded through
the app's parseProject in a new frontend test, so the produced JSON is
proven to round-trip unchanged.

Bookmarks are not authored: the app keeps them in browser storage, not
in the project file.

Part of #2858

* Address review feedback

- set_labels (MCP): also refuse field/expression/enabled inside
  options, which previously crashed with a TypeError instead of a
  readable tool error.
- filter_expression: case/match/coalesce/let are accepted only when
  every branch they can return is boolean, so a numeric case no longer
  slips past the pre-check and gets dropped by the app on load.
- set_plugin_state: clearing settings (state=None) no longer activates
  the plugin or moves its control as a side effect; documented.
- set_labels: enabled now defaults to None and keeps the current state,
  so a restyle no longer re-shows labels hidden with enabled=False.
- docs/python.md: state must be plain JSON.
- test_mcp_live: import ToolError after the SDK skip guard.

* Address review feedback

- set_plugin_state: an omitted state now keeps the stored settings, so a
  call that only repositions or (de)activates a plugin cannot wipe them;
  removing them takes an explicit clear=True (Map and MCP too).
- Expressions: a RecursionError from a deeply nested expression is now
  the documented ValueError, for filters and label expressions.
- set_labels: a stored labels object without an enabled key counts as
  on, so labels added over a hand-edited {} are visible.
- label_style: document that data-defined expressions are checked for
  shape, not result type.
2026-10-04 07:42:24 -04:00
Daniel Hofbauerandgithub-actions[bot] 06b120c503 feat(desktop): keep project-file credentials in the OS keychain (2/2) (#2728)
* feat(desktop): keep project-file credentials in the OS keychain

Geocoding API keys, secret environment variables, and layer request headers
are stored device-wide by name in the OS keychain on desktop and left out of
the saved project file (#1667). Environment variables gain a per-row
`secret` flag; rows marked `secret: false` stay in the file and survive
share/export. The web build keeps today's keep/strip save prompt.

* fix(desktop): keep Settings secrets until stored, guard the credential index

- Settings keeps the edited plaintext in the store and writes only changed
  entries to the keychain, so a failed write can no longer lose a value; the
  next project save strips it or falls back to the keep/strip prompt.
  commitPreferenceCredentials becomes changedPreferenceCredentials.
- rememberProjectCredentials skips names the credential store rejects
  (over 512 bytes, control characters) so one bad name cannot make the
  device-wide index unreadable.
- Seed the Settings credential ref from the same overlaid preferences as
  the draft.

* fix: preserve ambiguous secrets and show accurate keychain warnings

* feat: reference environment variables in request headers instead of storing them by layer ID

Layer request headers may now contain ${NAME}, resolved from the project's
enabled Environment Variables when the request is made (keychain-backed
secret values on desktop). The template is saved and shared as typed.
This replaces the device-wide project.layer.<id>.requestHeaders keychain
entries, which any opened project reusing a layer ID could have received.

Redaction (JS and Python) keeps header values that are only an optional
scheme word plus one reference. The 3D Tiles panel and Settings explain
the syntax.

* fix(desktop): clearing a project-file override keeps the shared keychain value

Settings deleted the device-wide geocoding key (or secret variable) whenever a
field was cleared, even when the field showed a plaintext override from the
opened project. Other projects then lost the shared value. A cleared field now
deletes the stored value only when it showed that value; clearing a differing
override just drops it, and the shared value applies again.

* Update apps/geolibre-desktop/src/lib/project-credentials.ts

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix: address review findings on credential saves and header references

- Undo a save's keychain writes when the save never writes the project
  (cancelled prompt or picker, failed write, project switched), so an
  abandoned save cannot replace a device-wide value.
- Rebuild Cesium imagery/3D Tiles and Mapbox 3D Tiles when a variable a
  ${NAME} header references changes.
- Honour "Fly to tileset after load" for templated MapLibre tilesets.
- Drop the duplicated index comment in project-credentials.ts.

* fix: keep 3D Tiles credentials on HTTPS, finish save rollback, rebuild MapLibre tilesets on variable change

- Request headers go only to HTTPS (or loopback) tileset URLs on Cesium,
  Mapbox/deck.gl and the MapLibre panel. Cesium and deck.gl also refuse
  plaintext child-tile requests (Cesium via a resource proxy, deck.gl via a
  fetch function). The shared check moves to @geolibre/core.
- Await an abandoned save's keychain rollback before the save guard is
  released, and report a failed restore.
- Rebuild native MapLibre tilesets whose ${NAME} headers resolve
  differently after an environment variable change.

* fix: undo partial keychain writes after a Keep save, prune resolved 3D Tiles headers

- When only some credentials reached the keychain and the user keeps the
  rest in the file, undo the writes that landed. After Strip they stay, as
  the keychain is then the only copy.
- Forget the resolved headers of a removed MapLibre tileset when the layer
  leaves the store, not only on the next variable change.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-30 15:53:35 -04:00
Qiusheng Wu 29b389e548 fix: keep the layout, theme and collapsed Layers panel in HTML exports (#2768)
* fix: keep the layout, theme and collapsed Layers panel in HTML exports

Map.to_html() framed the app with only ?embed=1, so a map-only or dark
notebook map exported with the default chrome. It now passes the map's
layout and theme as the same query flags the widget front-end uses.

The in-app Export as HTML likewise carries the exporting app's chrome:
its layout query params, the current theme, and panels=collapsed while
the Layers panel is collapsed.

Fixes #2764

* fix: honor maponly values and let collapse replace a panels value in exports

* fix: keep query flags the to_html app_url already sets

* Address Claude review feedback

- to_html: force embed=1 again when app_url carries a disabling embed
  value. The app reads only the first "embed", so the disabling pair is
  dropped from app_url instead of appended after.
- Move the chrome query parsing (hidden-panel values, maponly truthiness,
  panelsHidden) into lib/layout-params.ts, shared by useLayoutOptions
  and the HTML exporter so the two cannot drift.
2026-09-30 15:49:11 -04:00
Qiusheng Wu ce6c1303aa feat: headless point cloud pre-labels and labelled full-file rewrites (#2771)
* feat: headless point cloud pre-labels and labelled full-file rewrites

geolibre.pointcloud maps COPC node keys to file order, encodes labels
like the app, pre-labels a local copy of a LiDAR layer with the app's
Whitebox classifiers (whitebox-workflows) and streams a LAS/LAZ/COPC
file with the saved labels and instance ids applied. MCP gains
prelabel_point_cloud and write_labeled_point_cloud; Map gains the same.
The sidecar gets a /pointcloud job (laspy[lazrs] installed into the
conversion runtime on first use), and the annotator a "Full file with
labels" export that runs it.

* Address review feedback

- Sidecar decoder matches the Python reader: a decoded-entry cap
  alongside the byte budget, a bad node skipped rather than failing the
  job, and instance ids above 32 bits rejected.
- The legacy-to-1.4 upgrade (Python and sidecar) keeps the extra-bytes
  dimensions, identifiers and creation date, and rewrites the CRS as WKT.
- Remove the label payload when a job cannot start.
- Retry a failed job poll a few times; recheck sidecar availability at
  each session start.
- Map.write_labeled_point_cloud checks the URL is a LiDAR layer.
- Pre-labelling reads only classification codes, chunk by chunk.

* Drop an unused laspy binding in prelabel_point_cloud

* Address Claude review feedback

- Install point cloud support under the conversion runtime's own setup
  lock, since it mutates the venv that lock guards.
- Charge a failed label record's full cap to the budget, as the Python
  reader does, so malformed records cannot bypass it.

* Address Claude review feedback

- Keep job label payloads in their own temp folder and remove any older
  than an hour when a job is prepared, so a job that dies before its
  script runs cannot leave them behind.
- Upgrade the waveform formats 4 and 5 to 9 and 10 (not 6 and 7), so
  their waveform fields carry over; in both the sidecar and the module.

* Fix the waveform-format test fixture and wrap a docstring

- The test fixture writes format 4/5 as LAS 1.3, which those formats
  require (1.2 only allows 0-3).
- Wrap an over-long docstring line.

* Address review feedback

- write_labeled_point_cloud refuses to write over its input, which it
  would truncate mid-read (the MCP tool and sidecar already refuse).
- Use a private payload folder when the shared one in the temp directory
  is owned by someone else or writable by others, so no other local user
  can swap a job's labels.

* Address Claude review feedback

- Refuse COPC hierarchy pages that lie outside the file and cap the
  hierarchy walk at the file size, in the module and the sidecar, so a
  crafted page size cannot force a huge read.
2026-09-30 13:17:34 -04:00
Qiusheng Wu 3c295b6a0b feat: 3D polylines, polygons and keypoints for point cloud annotation (#2765)
* feat: 3D polylines, polygons and keypoints for point cloud annotation

A 3D vectors section draws polylines (V), 3D polygons and keypoints (K)
whose vertices snap to the nearest drawn point and take its elevation.
They are saved with the project, exported as 3D GeoJSON and a
Segments.ai pointcloud-vector label in the source CRS, and read by the
Python package and MCP. A dblclick listener now finishes vectors and
closes selection polygons, since pointer events carry no click count.

* Address review feedback

- Mark the project as changed on every annotation edit (point labels,
  instances, custom classes, 3D boxes and vectors), so closing an edited
  project prompts to save; these live in plugin state, not store layers.
- Backspace on the last vertex ends the draft and releases the camera.
- Switching vector kinds mid-draw reports that the draft was discarded.
- Cover signed-URL redaction of vectors in get_point_cloud_annotations.

* Address Claude review feedback

- Clamp the vertex snap loop to the points the live buffers cover, like
  the selection and highlight readers, since a streamed cloud's point
  count can run ahead of its buffers.
2026-09-30 06:41:56 -04:00
Qiusheng Wu 30503be2e9 feat: point cloud class schema and instances in Python and MCP (#2763)
* feat: point cloud class schema and instances in Python and MCP

Map.set_point_cloud_classes and the set_point_cloud_classes MCP tool
define the annotator's custom classes (its label schema) in a project,
keeping existing labels and boxes. point_cloud_annotations now also
decodes instance ids (the app's varint encoding) and returns the custom
classes; get_point_cloud_annotations summarizes instances as point
counts per id.

* Address Claude review feedback

- Validate custom class colors with a strict #rrggbb pattern, like the
  app's parseHexColor; int(..., 16) accepted signs and "_" separators
  that the app would then drop on load.
2026-09-30 05:29:04 -04:00
Qiusheng Wu c2f11ae6de feat(python): add_lidar and point cloud annotation readers, MCP tools (#2758)
* feat(python): add_lidar and point cloud annotation readers, MCP tools

Phase 5 of #2749.

- Map.add_lidar(url) / project.lidar_layer: a LAS/LAZ/COPC/EPT layer in
  the shape the app's LiDAR control writes, so it re-streams on open.
- Map.point_cloud_annotations() / project.point_cloud_annotations: decode
  the labels (per URL, node key, point index) and 3D boxes the app's
  annotator saves; project.apply_point_labels writes a whole-file
  source's labels onto e.g. laspy classification.
- MCP: add_lidar_layer and get_point_cloud_annotations (a per-class
  count, not every point).
- Docs and the agent skill references updated.

* Address review feedback

- Cap label decompression like the app: 16 MB per node via
  decompressobj(max_length), 256 MB per project (Claude review,
  CodeRabbit).
- Skip malformed sources, cuboid entries and boxes instead of raising
  (Claude review).
- apply_point_labels validates every key and index before changing
  anything (CodeRabbit).
- get_point_cloud_annotations redacts credentials from source URLs
  (CodeRabbit).

* Address review feedback

- Cap the decoded label entry count across a project (Python memory, not
  just inflated bytes) (CodeRabbit).
- Reject a varint longer than five bytes, which also stops a quadratic
  bigint loop on a crafted record (Claude review).
- apply_point_labels rejects negative indices (Claude review).
- Direct Map.add_lidar / point_cloud_annotations tests (Claude review).

* Address Claude review feedback

- Charge a rejected label node's inflate cap to the shared budget, so a
  project full of bad nodes cannot force unbounded decompression work.
- Merge label counts when two signed URLs redact to the same cloud in
  get_point_cloud_annotations instead of overwriting them.
- Skip cuboid entries without a string URL, matching the sources loop.

* Address Claude review feedback

- Charge a decoded node's inflated size to the budget before the entry
  cap check, so nodes skipped for exceeding the remaining entry count
  still draw the decompression budget down.

* Address review feedback

- Charge each decoded node's actual inflated size to the label budget;
  varints run to five bytes, so two bytes per edit understated it.
- Merge repeated source entries for one URL instead of dropping the first.
- Derive the default LiDAR layer name only from a string URL, so a bad
  URL reaches lidar_layer's ValueError rather than an AttributeError.
2026-09-30 03:10:25 -04:00
Qiusheng Wu 3bbeb7b38c fix: restore saved legend/colorbar panels and add set_map_legend (#2720)
* fix: restore saved legend/colorbar panels and add set_map_legend

A project that carries a Components legend, colorbar, or HTML panel lost
it on reopen. Those panels restore from settings without activating the
Components plugin and mount only after a dynamic import resolves, but
the restore-scoped app refused any control added after the synchronous
turn from an inactive plugin. Restore scopes now stay valid until the
next restore (a project load or renderer swap) supersedes them; the
activation-generation check still rejects a deactivated plugin.

Add set_map_legend to the Python package, Map, and the MCP server. It
writes the project-level legend behind Controls > Legend, whose rows
come from each layer's symbology, so a classified layer gets a matching
legend without restating its colors.

* Address review feedback

- set_map_legend: visible/collapsed default to None and keep the saved
  panel state, so a rename no longer re-expands a collapsed legend; a
  project with no legend still opens it (Claude review).
- Plugin manager: a scope also goes stale when the renderer changes or
  the plugin is re-registered, closing the window before the next
  restore bumps the pass (CodeRabbit).
2026-09-28 00:37:02 -04:00
Andrea BorrusoandClaude Opus 5.5 c3ad29ceff feat(desktop): reproject WMS layers in projected CRSs into Web Mercator (#2695)
* feat(desktop): reproject WMS layers in projected CRSs into Web Mercator

A WMS without EPSG:3857 stored with a projected crs (UTM, national grids)
is requested over an extent covering the tile and warped into Web Mercator.
Python/MCP crs accepts any EPSG code. Refs #2686.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcPLNhH3B7ZhPE3cqohhs

* fix(desktop): warp geographic EPSG codes outside the strip list; log resolver errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcPLNhH3B7ZhPE3cqohhs

* refactor(desktop): lazy-load proj4 in wms-projected; clarify resolver comments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcPLNhH3B7ZhPE3cqohhs

* fix(desktop): warp against the returned WMS image size, not the requested one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcPLNhH3B7ZhPE3cqohhs

* fix(desktop): send WMS request as is when a projected tile edge collapses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcPLNhH3B7ZhPE3cqohhs

* refactor(desktop): drop the unneeded cast on the toProj4 call

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcPLNhH3B7ZhPE3cqohhs

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:05:46 -04:00
Qiusheng Wu 188b9a4f8a feat(python): save set_identify/show_control state; identify a layer list (#2689)
* feat(python): save set_identify/show_control state; identify a layer list

set_identify and show_control only lived in the widget's synced _ui trait,
so save_project, to_project and to_html dropped them and a shared map
opened with Identify off and the default controls (#2688).

Projects gain an optional `interaction` block (identify target and
control visibility). The Python Map writes it from _ui on export and
reads it back in load_project. The app arms Identify from it in
loadProject and shows or hides the named controls and panels once per
loaded project through the same setControlVisible command the widget
replays, and writes the block back unchanged on save.

set_identify (and the setIdentify command) also take a list of layers.
The store keeps it as the all-layers Identify mode restricted by a new
identifyLayerIds field, honoured by the MapLibre, Mapbox, ArcGIS and
Cesium identify paths and the DuckDB pick bridge. Removing a listed
layer narrows the list; the in-app Identify buttons clear it.

Fixes #2688

* fix(python): reject a non-list set_identify argument with ValueError

* fix(identify): honour the layer list in the Layers panel; keep it on group ungroup

A row outside a restricted Identify list no longer counts as owning map
clicks, so editing or selecting on it does not disarm Identify. Deleting
a group but keeping its layers leaves them in the Identify target.
2026-09-26 09:59:09 -04:00
Qiusheng Wu 0ad0a8e699 fix(3d-tiles): place 3D BAG on its footprint, keep it visible at pitch 0, add it as a sample (#2613)
* fix(3d-tiles): place large tilesets on their true footprint (#2606)

Bump maplibre-gl-3d-tiles to 0.5.9, which anchors the tileset's local
tangent-plane frame at the view center instead of once at the tileset
center. Nationwide tilesets such as 3D BAG (center near Utrecht) drew
Amsterdam buildings tens of meters off the basemap and ~100 m low,
because the earth curves away from a single tangent plane and Mercator
stretches with latitude.

* fix(3d-tiles): keep 3D BAG visible at pitch 0 and add it as a sample

Bump maplibre-gl-3d-tiles to 0.5.10. 3d-tiles-renderer built a bounding
box's culling planes from its half-axes, which is only correct when they
are perpendicular; 3D BAG's root box is slightly skewed, so its top
plane sat 501 m low, under the ground near the tileset center. At pitch
0 MapLibre's frustum ends at the ground, so the whole tileset was culled
past ~z13 (e.g. in Zeist). 0.5.10 builds the planes from the true face
normals.

Add "3D BAG Netherlands (LoD 2.2)" to the 3D Tiles sample dropdown with
an altitude offset of -43 (3D BAG stores ellipsoidal heights; the geoid
is ~43 m above the ellipsoid in the Netherlands), using the per-sample
altitudeOffset added in 0.5.10.

* docs(readme): drop the DashMap note from the Python package link

* Address CodeRabbit review feedback

- docs/python.md: document the `geolibre[dash]` extra and the
  `from geolibre import DashMap` import in the Install section, since the
  README no longer carries that note and the guide only mentioned DashMap
  in passing.
2026-09-23 23:25:33 -04:00
Qiusheng Wuandgiswqs 38b3961423 feat(python): arm Identify, toggle controls, and set projection from Python (#2603)
* feat(python): arm Identify, toggle controls, and set projection from Python

Add Map.set_identify, show_control/hide_control, and set_projection so a
notebook can open a map with click popups armed, the Bookmarks and Search
panels shown, and a flat map instead of the globe. Identify and control
visibility ride a synced _ui trait the widget replays on load and after
every project push, so they work before the map is displayed.

Refs #2600

* Address review feedback

- Record every script-set map control at module scope in ui-controls.ts and
  replay it from a new useScriptControlRestore hook in DesktopShell, keyed on
  the controller and project generations. The toolbar was the only thing
  re-applying a scripted show_control/hide_control, and it is unmounted in
  `?maponly` embeds, so a hidden control came back on a renderer swap or
  project load. This follows the split useTerrainRestore already uses for
  terrain, which exists for exactly this reason.
- The same record covers a script that runs before the map controller exists:
  it is created asynchronously and `geolibre:ready` is emitted from a
  mount-only effect, so a command flushed right after ready could find no
  controller and be silently dropped. It is now applied once the controller is
  ready.
- Note in TopToolbar that its listener only mirrors the checkmark, so the
  restore responsibility is not moved back there later.
- Cover the record in tests/scripting-ui-controls.test.ts: what is recorded,
  last-write-wins per control, and that it starts empty.

* Address review feedback

- Drop a script's recorded control override when the user toggles that control
  from the Controls menu. Without it the replay added in the previous commit
  would force the scripted value back on the next renderer swap or project
  load, overriding an explicit user choice. Covered by three tests, which fail
  without the drop.
- Factor the duplicated `controllerRef` getter literal in scriptingApi.ts into
  a shared `controllerRefFrom` helper, removing the repeated cast.
- Disarm Identify before pushing the project in `remove_layer` and
  `clear_layers`. The two traits sync independently, so clearing afterwards
  left a window where the front end could replay `identify` for a layer the
  project push had just deleted.

* Address review feedback

- Drop a pinned Identify target in `load_project` when the incoming project
  does not carry that layer. The guard added for `remove_layer` and
  `clear_layers` missed the most drastic replacement of all: a stale layer id
  survived in `_ui` and was replayed on the next sync, which the front end
  rejects into a reply nobody reads, disarming Identify via a stray error
  rather than cleanly. "all" and None survive any project, as before.
- Cover it in test_ui_controls.py: the layer is gone, the layer survives, "all"
  is untouched, and the existing remove_layer path.

* Address review feedback

- Forget every scripted control override when the user starts a New Project.
  That reset returns all controls to their defaults, so the overrides are
  spent; without this, useScriptControlRestore re-applied them to the live map
  on the same project-generation bump (parent effects run after the toolbar's)
  and left the map disagreeing with the checkmarks just reset.
- Cleared in the toolbar's New Project reset rather than in the store's
  newProject/loadProject: the widget's project push goes through
  useAppStore.loadProject, so clearing there would wipe the controls a script
  set on every push, which is the case the app-side replay exists to serve.

* Address review feedback

- Guard the hand-mirrored control lists against drift: MAP_PANELS and
  MAP_CONTROLS in geolibre.py duplicate SCRIPTABLE_PANELS and
  SCRIPTABLE_MAP_CONTROLS in ui-controls.ts by hand, and nothing failed when
  the two diverged, so a control added on one side only would be accepted by
  show_control and rejected by the app (or the reverse). The test reads the
  TypeScript constants and compares them, and skips where the frontend source
  is not in the checkout. CLAUDE.md calls out silently drifting hand-mirrors
  as a recurring trap in this repo.

* Address review feedback

Document two behaviours reviewers asked about, neither of which changes:

- Why setControlVisible does not gate on setBuiltInControlVisible's return
  value: it is not a clean success flag, since the MapLibre engine returns
  false when a control is already mounted, so an idempotent show_control would
  report failure. Separating that from a real refusal needs a visibility
  getter on MapEngine, which does not exist.
- That the "all" Identify sentinel is matched before the layer lookup, so a
  layer whose id or display name is literally "all" cannot be targeted on its
  own. Noted on both the TypeScript helper and Map.set_identify.

---------

Co-authored-by: giswqs <5016453+giswqs@users.noreply.github.com>
2026-09-23 14:41:59 -04:00
Andrea BorrusoandClaude Opus 5.5 bf47a7a766 feat: draw WMS tiles from servers without EPSG:3857 in the desktop app (#2562)
* feat(desktop): draw WMS tiles from servers without EPSG:3857

When a WMS tile template names a geographic CRS (EPSG:4326, EPSG:4258,
EPSG:6706, CRS:84), the desktop geolibre-wms protocol requests the tile's
lon/lat extent in that CRS and redraws the latitude-linear image into Web
Mercator in horizontal strips, as the KML Super-Overlay protocol does.
Requests in EPSG:3857 are fetched unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(python): crs option for WMS layers

wms_layer, Map.add_wms and the add_ogc_layer MCP tool accept crs, written
into the GetMap template in place of EPSG:3857 while the bbox placeholder
stays. Only Web Mercator and the geographic CRSs the desktop can redraw are
accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(python): reject crs for WMTS; cross-reference the CRS allowlists

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(desktop): clamp latitude in the WMS strip redraw

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(desktop): skip degenerate extents in the WMS strip redraw

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(desktop): read any WMS 1.3.x as latitude first; name the server error when a geographic tile is not an image

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(python): replace a CRS already in the WMS endpoint; document desktop-only geographic CRS

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(desktop): cover the non-image path of the geographic WMS redraw

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(python): replace every GetMap key already in a WMS endpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(python): require WMS 1.3.0 for crs='CRS:84'

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: decode endpoint key names before replacing them; trim the tile CRS

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: keep the desktop and Python WMS CRS allowlists equal

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 17:24:57 -04:00
Qiusheng Wu dcef224bdc feat(popup): configurable popup width and image height (#2537)
* feat(popup): configurable popup width and image height

Discussion #2304 asked for a `popup_max_width=` on `add_markers` and a way to
make the pictures in a popup bigger. Both sizes were hard-coded: the popup root
capped at 520px (420px once it carried a picture) and an image field's
thumbnail at `min(50vh, 420px)`.

`LayerPopupConfig` gains `maxWidth` and `imageHeight`, both in CSS pixels.
`resolvePopupMaxWidth` / `resolvePopupImageHeight` clamp them the way the rest
of the popup path treats untrusted project JSON, the renderer applies the width
as inline style (so it beats the Tailwind cap and the wider image-popup rule)
and widens the MapLibre shell to match, and publishes the height as
`--geolibre-popup-image-height` for `.geolibre-popup-image` to read. The 2D,
Mapbox and Cesium click popups all honor it; the grouped all-layer popup takes
the widest width any hit layer asked for.

The Style panel's Popup section gets both controls, and Python gets
`max_width=` / `image_height=` on `popup_config`, `set_popup` and the MCP
`set_layer_popup` tool, plus `popup_max_width=` / `popup_image_height=`
shorthands that every `add_*` method accepts. Python raises on an out-of-range
size rather than writing one the app would silently clamp.

* fix(popup): address review on the popup size settings

Three things the automated review caught:

- A text-only popup was pinned to the configured width, not capped by it. The
  inline `width` exists to override the fixed `width: min(420px, …)` the
  `:has(.geolibre-popup-image)` rule sets, and to stop a `width: 100%` image
  shrinking to the text beside it -- neither applies without a picture, where
  the popup shrank to its content before this branch. `applyPopupWidth` now
  sets `width` only when the root carries an image, so it runs after the
  content is in place in both popup builders.
- The size placeholders read "Default (520)" / "Default (420)", but the width
  default is 420px once the popup carries a picture and the image default is
  `min(50vh, 420px)`. Both now read "Default" in all 19 catalogs; the numbers
  live in the docs, where the two cases can be spelled out.
- `popup_max_width=` did not fully win over a `max_width` key inside `popup=`:
  the mapping's value was still range-checked first, so an out-of-range one
  raised even though a valid argument was about to overwrite it. The mapping's
  copy is now dropped before validation, as an inline `tooltip` key already is.

* fix(popup): surface an infinite size as the documented ValueError

int(float("inf")) raises OverflowError, which neither numeric guard caught, so
an infinite max_width, image_height or decimals escaped as an internal error
instead of the ValueError each one documents. The decimals guard my helper was
modeled on had the same hole, so both are fixed together.

* fix(popup): let a text-only globe popup shrink to its content

The globe's popup box took the configured width as its `width` whether or not
it carried a picture, which is the behavior just corrected for the 2D and
Mapbox popups -- a short text-only feature would have rendered as a mostly
empty box at the author's width. It now mirrors `applyPopupWidth`: `width` is
pinned only for a box with an image, and a text-only box keeps `width: auto`
under the configured ceiling.
2026-09-21 19:53:25 -04:00
Qiusheng Wu ccfbc730db docs: add rendering engines guide and renderer references (#2471)
* docs: add rendering engines guide and renderer references

GeoLibre now draws projects with MapLibre, Mapbox, Cesium, and ArcGIS, but the
docs still described a MapLibre-plus-Cesium setup. Add a user guide for choosing
and mixing engines, MapLibre and Cesium renderer references, and update the
existing pages to match.

* Address review feedback

- Correct Cesium credential docs: the web version bundles a demo ion token,
  while desktop and mobile require the user's own token.
- Restore the tutorial "limitations" bullet to an actual limitation (Claude
  review), in both the English and Chinese tutorials.
2026-09-18 09:45:16 -04:00
Qiusheng Wu da2d636f9c feat(cesium): native KML, terrain profiles, and control events (#2356)
* fix(cesium): restore startup and forward control events

* feat(cesium): load native KML and sample elevation profiles

* fix(cesium): fade KML labels and align Python tool coverage

* fix(cesium): avoid MapLibre placeholder warnings on native layers

* Address review feedback

- KmlSource: route the URL field label and the default layer name through
  t(), and add addData.kml.url / addData.kml.defaultName to all 19 locale
  catalogs, matching the sibling Add Data sources (Claude).
- LayerPanel: scope the placeholder-warning suppression to the layer kinds
  Cesium actually draws, so a kind the globe cannot render (duckdb-query)
  keeps its message while the globe is primary (Claude).
- cesium-control-host: convert hit positions through scene.globe?.ellipsoid
  with a WGS84 fallback -- pickGlobeHit answers a globe-less scene with an
  ellipsoid pick, which the old dereference would have thrown on
  (CodeRabbit); skip the pick entirely when no control listens for the
  event, so mousemove no longer pays for a terrain ray per pointer frame
  that nothing consumes (Claude).
- cesium-layer-sync: re-append KML ScreenOverlay containers in store order
  during sync(), so a panel reorder restacks overlapping overlays instead
  of leaving them in load order (CodeRabbit).
- ElevationProfileControl: keep the native sampler's own error messages
  ("the terrain source changed", "the globe was closed") instead of
  replacing them with the generic HTTP-path fallback (CodeRabbit).
- Tests: regression coverage for the globe-less pointer conversion, the
  unlistened-event pick skip, and BOM-prefixed inline KML.
2026-09-13 00:15:47 -04:00
9d1599e67a feat(cesium): support CZML dynamic 3D scenes on the globe (#2350)
* feat(cesium): support CZML dynamic 3D scenes on the globe

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

* fix(cesium): resolve TypeScript typings, i18n keys, and test environments for CZML

* Address Claude review feedback

- Parse a serialized inline CZML document before handing it to
  CzmlDataSource.load, which treats a string as a URL to fetch; an
  unparseable string now surfaces as a layer error instead of a bogus fetch.
- Store the inline document once under source.czmlData; the czml key is
  only read as a legacy fallback, so writing both doubled project size.
- Drop the unused CzmlDataSource type import.
- Let the first CZML document with a clock packet own the viewer clock;
  later CZML layers no longer reset the Time Slider's position or each
  other's interval, and ownership is released when that layer is removed.
- Use the logical me-2 utility on the file-picker icon so it mirrors in
  RTL locales like the other Add Data sources.
- Ship the Map.add_czml method and the add_czml_layer MCP tool the PR
  description promised, with tests and docs (python.md, mcp.md, agent
  skill references).

* Address Claude review feedback

- Hide the tileset symbology and quick-filter sections of the Style panel
  for CZML scenes: the globe sync only toggles their visibility, so those
  controls were silent no-ops; the footer now reads "czml" rather than
  "3d-tiles".
- Drop metadata.customLayerType from the Python czml_layer builder so it
  matches createCzmlLayer and the Layer Library treats CZML layers the
  same regardless of which API authored them.

* Address review feedback

- Elect the CZML clock owner deterministically: the first layer in synced
  order whose loaded document carries a clock packet, re-elected (and its
  clock re-applied without a reload) when the owner is removed. Tests cover
  out-of-order loads and removal with an already-loaded fallback.
- Let the add_czml_layer MCP tool accept a single packet dict as well as a
  packet list, matching Map.add_czml and czml_layer.
- Treat an empty packet array as no document in czmlSource, and reject
  empty data in the Python builder, so a layer can never look ready with
  nothing to load.
- Wrap the Add Data quick pick in the same error handling as the form
  submit and clone the sample packets so layers never share the exported
  constant.
- Drop the unused czml option alias from createCzmlLayer.

* Address review feedback

- Elect the CZML clock owner only once the data source has joined the
  scene, so a rejected add never leaves a non-rendering layer driving the
  viewer clock; regression test added.
- Skip writing an empty czmlData alongside a URL in the Python builder.

* Address review feedback

- Re-elect the CZML clock owner at the end of sync(), so reordering two
  already-loaded clocked documents hands the clock to the new first one;
  test extended.
- Drop the never-written source.czml and metadata.czmlUrl fallbacks from
  czmlSource/createCzmlLayer; nothing produces either key.

* Address Claude review feedback

- Wrap a bare packet object in czmlSource() the way parseCzml does, so a
  single-packet document from the Python API or MCP tool reaches Cesium
  as an array instead of rendering nothing; test added.
- Document that the viewer clock stays where the last CZML document left
  it once no CZML layer remains, since the Time Slider owns time then.

* Address Claude review feedback

- Compare the raw source.czmlData reference in needsRebuild instead of
  czmlSource().data, which wraps a bare packet in a fresh array per call
  and would have rebuilt such a layer on every unrelated sync; test
  asserts a spread layer does not reload.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: giswqs <giswqs@gmail.com>
2026-09-10 23:23:04 -07:00
Qiusheng Wuandpre-commit-ci[bot] e1a003d576 feat(map): Cesium Ion assets by id on the globe (#2321)
* feat(map): Cesium Ion assets by id on the globe

A 3D Tiles tileset or an imagery layer can now be referenced by its Cesium
Ion asset id (issue #2290). The globe resolves the asset through an
IonResource / IonImageryProvider with the app's Ion token, read at load time
so a token added in Settings reaches the next sync; a missing token is a
layer error rather than a silent blank.

- `@geolibre/core` `cesium-ion.ts`: `createCesiumIonLayer`, the asset-id
  parser, `isCesiumOnlyLayer`, and the one-click picks (Cesium OSM Buildings,
  Bing Aerial through Ion).
- Add Data → 3D → Cesium Ion Asset: asset id, tileset/imagery, altitude
  offset, quick picks, and a token hint; enabled when the globe is primary.
- The Layers panel and the pane layer menus badge these layers "3D only" on
  the 2D map, the mirror of the existing "2D only" badge.
- Python `add_3d_tiles(ion_asset_id=…)` / `add_cesium_ion(...)`, the MCP
  `add_3d_tiles_layer(ion_asset_id=…)` / `add_cesium_ion_layer` tools, and
  the docs and agent skill references.

Refs #2290, #2259.

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

* test(python): assert Ion asset ids survive the MCP save; document Ion imagery in the format table

* fix: Ion asset follow-ups

- three_d_tiles_layer rejects request_headers together with ion_asset_id
  instead of dropping them silently.
- An Ion asset id counts as a restorable source, so Ion layers can be saved
  to My Data.
- The Add Data form reports an unparseable altitude offset instead of
  coercing it to zero.

* fix(core): name the Bing Aerial Ion asset for what it is; parse restorable Ion ids the globe's way

* fix(core): an Ion asset id is restorable only under the cesium-ion source kind

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-09-08 09:37:59 -04:00
Qiusheng Wu 7af7c17104 feat(python): popup/tooltip authoring and marker symbology arguments (#2306)
* feat(python): popup/tooltip authoring and marker symbology args

The app has had a popup and tooltip designer (per-field labels, kinds,
formats, title/body expressions, hover tips) and marker symbology (shape,
color, size, custom SVG) since #2113, but none of it was reachable from the
Python API. `add_markers(**style)` merged everything into the layer's `style`
dict, and the popup config is a top-level layer key, so a notebook had no way
to author one at all. Reported in discussion #2304 by someone porting popups
with images and links over from another library.

- `project.py`: `popup_field`, `popup_config`, `apply_tooltip`,
  `normalize_popup`, `marker_style`, and `normalize_hex_color` (a mirror of
  the sprite baker's own color check). `_layer_base` now lifts `popup=` and
  `tooltip=` out of the style overrides and onto the layer, so every `add_*`
  builder accepts them without threading two more arguments through each
  signature.
- `authoring.py`: `set_popup` / `clear_popup`, so the widget and the MCP
  server share one implementation.
- `Map.set_popup` / `set_tooltip` / `clear_popup`, `Layer.popup` and the same
  three methods on the handle, plus named marker arguments (`color`,
  `opacity`, `radius`, `stroke_color`, `stroke_width`, `shape`, `size`,
  `icon`) on `add_marker` and `add_markers`.
- MCP: `set_layer_popup`.

Two failure modes are raised rather than left to fail silently in the browser:
a marker sprite with a non-hex color (the baker rejects it and draws the
default blue), and a `tooltip=True` on a popup with no fields and no title
(the tooltip element would never render).

Docs, the agent skill's MCP and Python references, and tests across the four
suites.

* fix(python): name what a popup accepts when the value is not a field spec

* Address review feedback

- marker_style: raise when `icon` is given alongside an explicit non-custom
  `shape`. The app reads `markerSvg` only for `markerShape == "custom"`, so
  honoring the icon silently discarded the shape the caller asked for.
- Move the "this tooltip could never render" guard out of the `tooltip=True`
  branch and onto the finished config (`_assert_tooltip_can_render`), so it
  also catches `hover=True` passed directly to `popup_config`, `set_popup`, or
  a `popup={"hover": True}` mapping, and a merge that leaves `hover` on with no
  flagged field left.
- `tooltip=[]` now means the same as `tooltip=False` rather than storing a
  hover-enabled tooltip with no fields. The MCP tool's own empty-list mapping
  becomes redundant and is dropped.
- Derive the accepted-key list in the unknown-popup-key error from
  `_POPUP_CONFIG_KEYS` instead of hand-writing it, so it cannot drift.
- Docs: `add_ee_layer` has a fixed signature and takes no style overrides, so
  it does not accept `popup=`/`tooltip=`. Narrow the claim in `docs/python.md`
  and the agent skill's Python reference.

Six new tests cover the icon/shape conflict, both routes to a dead tooltip, and
the empty tooltip sequence.

* Address review feedback

- marker_style: reject the circle-only settings when shape/size/icon selects a
  marker sprite. Verified in layer-sync.ts that a sprite layer removes the
  circle layer outright and paints only icon-opacity from the layer opacity,
  and in markers.ts that drawBuiltinMarker strokes a fixed white halo -- so
  opacity, radius, stroke_color and stroke_width were not merely overridden,
  they were unreachable.
- add_marker / add_markers: apply the named marker arguments with setdefault
  rather than update, so a raw style key passed alongside one still wins. This
  restores the precedence add_circle_markers had before this PR, where
  `circleRadius` in **style beat `radius=`.
- Reword the ambiguous "change one later" sentence in the agent skill's Python
  reference. It was not a typo for "layer" (these change a popup, not a layer),
  but it read as one, so it now says what it means.
- Document both rules in docs/python.md and the skill reference.

Five new tests: the sprite guard from both entry points, and the style-key
precedence including the add_circle_markers case.

* Address review feedback

- apply_tooltip: wrap the `list(tooltip)` coercion in the same try/except
  TypeError guard that popup_config already uses for `fields`, so a public-API
  typo like `tooltip=1` raises a ValueError naming what tooltip accepts instead
  of surfacing "'int' object is not iterable" to a notebook. The two
  near-identical coercions in this module now handle errors the same way.

* Address review feedback

- _assert_tooltip_can_render: a tooltip whose only flagged fields are `image`
  now raises too. resolvePopupRows drops image rows from the hover subset (the
  value is a URL that would become the whole tip), so flagging only an image
  left exactly the dead tooltip this guard exists to prevent.
- Map.set_popup / Map.clear_popup delegate to authoring.set_popup /
  authoring.clear_popup instead of re-deriving the merge, matching what
  authoring.py's module docstring promises and what remove_layer and add_swipe
  already do. The merge semantics now live in one place.
- Document that the tooltip and the click popup share one field list, so
  `tooltip="name"` on a popup that configured no fields narrows the click popup
  to `name` rather than leaving it showing everything. This is a constraint of
  the app's LayerPopupConfig schema (resolvePopupRows only falls back to "all
  visible properties" while `fields` is empty), not something this API can
  paper over, so it is now stated in docs/python.md, the agent skill, and the
  docstrings of set_popup, set_tooltip and apply_tooltip, with the two-line
  workaround.

Three new tests.

* Address review feedback

- popup_field: reject a fractional or non-numeric `decimals` instead of
  truncating it. `decimals=2.9` silently became 2, formatting to a precision
  the caller never asked for, and the range check below could not catch it. An
  integral float (2.0) is still accepted.
- The nested `format` block now rejects `hover` too. It is a field-level flag
  rather than a format concern, and accepting it there was a hole in the
  reject-what-you-do-not-recognize rule the rest of the module follows.

Three new tests.
2026-09-07 20:53:28 -04:00
Qiusheng Wu 5ec567d878 Add Cesium drawing, capture, and plugin lifecycle support (#2302)
* feat: support Cesium drawing capture and plugin lifecycle

* feat: add keyless and COG-backed Cesium terrain

* feat: expose Cesium renderer and mixed pane authoring APIs

* Address review feedback

- DesktopShell: dispatch plugin URL parameters before the Cesium early
  return so Cesium-capable plugins still receive them on the globe.
- PrintLayoutDialog: gate the post-capture extent-box restore on request
  identity so a superseded capture cannot re-show it mid-capture; drop the
  redundant nested `if (map)`.
- print-extent: keep the final Cesium extent preview after a successful
  draw and hand its disposer to the dialog, which now mirrors the MapLibre
  box lifecycle on the globe (re-show on open, hide during capture, clear
  on close/clear/unmount).
- cesium-layer-sync: use the `tilesLoaded` flag (not the `allTilesLoaded`
  Event) for 3D Tiles readiness; turn the no-bounds, unsupported-WMTS and
  no-tile-URL early returns into load errors and report globe-supported
  kinds with an unusable source instead of leaving them pending forever.
- extent-drawing/map-engine/cesium-engine: define MapExtent as unwrapped
  (west < east, east may exceed 180 across the antimeridian, MapLibre's
  convention); Cesium view bounds unwrap and the globe preview re-wraps.
- map-controller: reuse suspendNavigation() in drawExtent so touchPitch is
  suspended while drawing.
- plugin-manager: every scoped UI registration (map controls, toolbar
  menus, right panels) now requires the current activation generation and
  a live activation; deactivation advances the generation. Regression test
  for a retained state scope registering after deactivation.

* Address CodeRabbit review feedback

- useEmbedApi: hold the `ready` event until the engine for the current
  primary renderer is live, so a renderer hand-off (which bumps the
  readiness generation before the destination engine publishes) no longer
  advertises a bridge whose commands would answer "not ready".
- authoring/geolibre: validate `secondaryMapViews` through one helper
  (a list of pane objects with a string id) so set_renderer, get_renderer
  and set_map_layout raise ValueError on hand-edited projects instead of
  KeyError or slicing errors; regression test via Map.load_project.

* Address Claude review feedback

- cesium-layer-sync: "2D only" layer kinds are skipped by getRenderStatus
  instead of reported as errors, so a mixed project no longer makes every
  globe capture, export, and readiness check fail.
- cesium-engine: project() clamps a corner that sits behind the camera
  into the visible rectangle instead of throwing, so an extent drawn
  before a pan/tilt still crops to the canvas edge like MapLibre.
- ControlsMenu: dispatch TERRAIN_SETTINGS_EVENT from @geolibre/map rather
  than a duplicated string literal.
- isFullViewportMapCanvas lives once in @geolibre/map; the app copy in
  print-capture.ts is removed and its callers and test import the package.

* Address CodeRabbit review feedback

- authoring: secondary_panes() also rejects a present viewKind that is not
  maplibre or cesium (an omitted one still means maplibre), so get_renderer
  cannot return None or an unknown renderer and set_map_layout cannot keep
  one; regression test for omitted, valid and invalid values.

* Address CodeRabbit review feedback

- authoring: secondary_panes() rejects duplicate pane ids, since pane
  lookups take the first match and would silently read or update the
  wrong pane.
- authoring: renderer values (viewKind, view_kinds entries, renderer) are
  type-checked before set membership, so a JSON array or object raises the
  documented ValueError instead of TypeError; regression cases added.

* fix(ci): load isFullViewportMapCanvas without the @geolibre/map barrel

The recorder and print export imported the helper from the package barrel,
which pulls in MapLibre's stylesheet and fails under node's test runner
(tests/map-recorder.test.ts). Expose it through a `@geolibre/map/map-capture`
subpath (dev and publish exports, plus the tsdown entry) like the other
node-safe modules, and import that instead.

* fix(map): drop unrelated dependency changes swept into the CI fix

The previous commit staged the whole package.json and picked up a
cog-tiler-wasm dependency and a React peer-range bump from unrelated local
work, without the matching lockfile entry. Revert those two hunks; the
map-capture subpath entries stay.
2026-09-07 19:02:05 -04:00
Qiusheng Wu b0158bfae3 feat: add heatmap palette and weighting controls (#2241)
* feat: add heatmap palette and weighting controls

Let point heatmaps use a selectable color ramp and a numeric feature field as density weight. Keep legends and saved layer styles aligned with those choices.

* Address Claude review feedback

- Preserve stale heatmap weight fields in the selector so UI and rendering agree.
- Mirror heatmap palette and weighting settings in the Python API.

* Address automated review feedback

- Hide unsupported heatmap controls on control-rendered layers.
- Match the legend to the transparent density-zero stop.
- Remove stale documentation and update Python API references.

* Address Claude review feedback

- Recover GeoLibre heatmap ramps and weight fields from imported Mapbox styles.
- Warn when third-party heatmap expressions cannot be represented.

* Address Claude review feedback

- share the heatmap ramp expression builder with style import

- reuse the numeric field selector for heatmap weighting

* Address CodeRabbit review feedback

- import direct heatmap weight property expressions

- assert supported heatmap round trips do not warn

* Address automated review feedback

- guard malformed imported heatmap weight expressions

- use the configured heatmap default for invalid ramp names

* Address Claude review feedback

- Resolve the heatmap ramp through one shared `heatmapRampColors()` helper in
  `@geolibre/core`, called by both `heatmapPaint()` (`@geolibre/map`) and the
  auto-legend gradient. `getVectorColorRamp` falls back to the first ramp
  ("viridis") for an unknown name while the map fell back to the default
  ("turbo"), so an out-of-catalog `heatmapColorRamp` — a renamed ramp in a
  stale project, or a hand-edited `.geolibre.json` — made the legend gradient
  and the rendered heatmap disagree.
- Cover the mismatch with an auto-legend regression test asserting an unknown
  ramp name yields the default ramp's colors in the legend.

Claude-Session: https://claude.ai/code/session_012zpXBAj8WPtxokSMCi2BU4
2026-09-04 09:20:22 -04:00
Andrea BorrusoandClaude Opus 5 308a700925 feat(python): accept optional bounds on WMS and WMTS layers (#2244)
* feat(python): accept optional bounds on WMS and WMTS layers

A service layer carries no geometry, so the app has nothing to derive its
extent from: every WMS or WMTS layer written by wms_layer, wmts_layer or
add_ogc_layer lands without source.bounds, and zoom-to-layer cannot reach it.

tile_layer already takes an optional bounds and writes it only when given.
Give wms_layer and wmts_layer the same parameter, and expose it on the MCP
tool that builds both.

Omitting bounds leaves the written source byte-identical to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* fix(python): refuse OGC bounds that are not four values

Review on #2244: a truthiness check dropped bounds=[] without a word, and a
malformed list reached the project file as an extent the app cannot use.

Validate the length and coerce to float, as new_project already does for
center. tile_layer keeps its existing lenient behavior: it predates this and
giving it a new exception path is not this PR's business.

Also covers the WMTS branch of add_ogc_layer, which the first test missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* docs(python): keep **style under Args in the wms_layer docstring

The Raises: section landed before the **style line, which pushed it out of
Args: and into Raises:, where a Google-style block expects an exception type.
Moved after Returns:, as wmts_layer already had it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* test(python): cover the wmts float coercion path too

wms_layer and wmts_layer validate and coerce bounds separately, so a test
naming only one of them would miss a regression in the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* feat(python): expose bounds on Map.add_wms and Map.add_wmts

Review on #2244 caught two things.

The validate-and-coerce block was duplicated verbatim in wms_layer and
wmts_layer; it is now one _resolve_bounds helper, alongside the existing
_normalize_wms_version. It also reports a non-numeric value as a bounds
error rather than letting float() raise its own.

The widget API was left behind: Map.add_tile_layer already forwards bounds
to tile_layer, but Map.add_wms and Map.add_wmts did not, so a notebook
building the same multi-service map had no way to set an extent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* fix(python): refuse non-finite bounds

float() takes nan and inf without complaint, so _resolve_bounds could store
an extent the app cannot fit to. Map.fit_bounds already refuses them; the
same check now guards the layer builders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* fix(python): check bounds latitudes, and catch OverflowError

Review on #2244 asked why _resolve_bounds stops at finiteness when the
sibling fit_bounds go further. Two of those checks belong here:

- south > north is the axis-order mixup the docstrings warn about, and
  latitudes have no wraparound to excuse it;
- a latitude outside +/-90 is not a coordinate.

West > east stays legal. RFC 7946 section 5.2 writes an antimeridian box
that way, and authoring.fit_bounds frames one rather than refusing it.

float(10**1000) raises OverflowError, which the coercion did not catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* docs(python): document what add_ogc_layer raises

The other four entry points gained a Raises: section for malformed bounds
and this one did not, though it is also the tool description an agent reads
before calling it. Covers the two pre-existing errors as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* fix(python): measure bounds after coercion, not before

len() on the raw argument raises TypeError for an iterable that is not
sized, and _reports_its_errors only restates ValueError - so through the
MCP server the agent would get "Error executing tool" with no sentence to
act on, against the package's stated invariant that every validation
failure is a ValueError.

Building the list first works for any iterable and leaves the existing
messages unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

* docs(python): name the right capabilities element for WMTS

EX_GeographicBoundingBox is a WMS element; a WMTS capabilities document
carries the extent as ows:WGS84BoundingBox. add_ogc_layer serves both, so
pointing at the WMS field alone sent WMTS callers to something absent.

Verified against a live WMTS (USGS basemap), which advertises
WGS84BoundingBox and no EX_GeographicBoundingBox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uQhcknLWhSS4pGKt6kYeH

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 07:54:53 -04:00
Qiusheng Wuandpre-commit-ci[bot] 3b54b06add fix: proxy local raster URLs in Colab (#2108)
* fix: proxy local raster URLs in Colab

Route session-scoped raster files through Colab's kernel proxy so xarray layers can load remotely. Always strip outputs from every tracked notebook during pre-commit runs.

* Address Claude review feedback

- Support local raster rewriting through jupyter-server-proxy as well as Colab.
- Rewrite sourcePath independently when it references a registered local file.
- Preserve git stderr when notebook discovery fails.

* Address CodeRabbit review feedback

- Terminate nbstripout option parsing before passing tracked notebook paths.

* fix: rebase stale Colab raster proxy URLs

Colab gives separate widget views distinct proxy origins. Rebase tokenized raster URLs from an older view onto the current view before restoring layers.

* fix(python): preserve raster range requests through Colab proxy

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

* fix(python): tunnel partial raster responses through Colab

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

* fix(python): render local rasters as XYZ tiles in Colab

* Address review feedback

- _server.py: gate the Content-Range/X-GeoLibre-Content-Range headers on a
  newly parsed range rather than on the presence of a Range header, so a
  non-bytes unit (e.g. "items=0-1") no longer emits a spec-invalid
  Content-Range alongside a full-file 200.
- _server.py: send Access-Control-Expose-Headers for
  X-GeoLibre-Content-Range, without which a cross-origin fetch cannot read it
  and the app leaves the response at 200 instead of rebuilding the 206.
- _frontend.js: require an empty port on the Colab proxy hostname, so a
  look-alike host on a foreign network port does not enter the rewrite path.
- _frontend.js: extract the transport gate into an exported
  canProxyLocalFiles(base, port) and cover it in tests, so a future
  proxyBase/resolveBase refactor cannot silently stop the rewrite.
- geolibre.py: resolve the local raster path once and hand the resolved path
  to both register_raster_tiles and rasterio.open — GDAL does not expand "~",
  so a "~/dem.tif" source silently lost its computed bounds.
- kernel-proxy-range.ts: export KERNEL_CONTENT_RANGE_HEADER and cross-reference
  the three mirrored literals from _frontend.js and _server.py; a new test
  asserts both Python sources against the exported constants.
- python-api.md: fix the duplicated "JupyterHub when" clause, matching the
  wording in docs/python.md.
- tests: cover the malformed-range 200, the exposed range header, and both the
  populated and None bounds paths of add_cog's Colab branch.

* Address Claude review feedback

- add_cog: the `url:` docstring still claimed a local raster is only reachable
  when the browser runs on the kernel's host, which this PR made stale. Restate
  it the way docs/python.md now does: direct reads in local Jupyter/VS Code,
  kernel-rendered PNG XYZ tiles in Colab, the kernel port via
  jupyter-server-proxy on JupyterHub, and a hosted URL for an
  extension-only deployment.

* Address Claude review feedback

- Remove the Colab range-proxy bridge, which the PNG-tile path in 45599eff
  superseded and left unreachable: `register_local_file` is called only from
  `_resolve_raster_source`, which only `add_cog`'s non-Colab branch reaches, so
  no `/_geolibre_local/` URL is ever emitted under Colab and nothing ever set
  the `__geolibre_range_proxy` marker the bridge keys on. Deleted
  `kernel-proxy-range.ts` and its global `fetch` wrapper (installed in
  `main.tsx` for every GeoLibre user), the marker logic in `_frontend.js`, and
  the `__geolibre_range` / `X-GeoLibre-Content-Range` transport in `_server.py`,
  along with their tests. Ordinary `Range` header support — what local Jupyter,
  VS Code, and jupyter-server-proxy actually use — is unchanged, including the
  `range_matched` fix from 8af3e91c.
- Document the notebook hook's blast radius in docs/contributing.md and expand
  the comment in .pre-commit-config.yaml: it rewrites tracked notebooks in
  place, so unstaged locally-executed outputs are erased by any commit, not
  merely excluded from it.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-25 14:48:36 -04:00
Qiusheng Wuandpre-commit-ci[bot] b9a720d630 feat: support xarray raster layers in Python API (#2105)
* feat: support xarray raster layers in Python API

Allow DataArray and Dataset inputs to be materialized as session-scoped COGs so they render directly in the browser. Include optional raster dependencies, focused tests, and a runnable notebook example.

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

* Address CodeRabbit review feedback

- Document raster dependencies included by the all extra.
- Apply Dataset nodata metadata to each DataArray variable before COG export.

* Address Claude review feedback

- add stacklevel=2 to the "array_args is ignored" warning so it is attributed
  to the caller, matching every other warnings.warn in geolibre.py
- register a weakref.finalize safety net for the GeoTIFFs materialized from
  xarray objects, so they are removed when the Map is collected and at
  interpreter exit rather than only on an explicit Map.close(); close() now
  clears the shared list in place so the finalizer stays valid afterwards,
  and the lifetime is documented in add_raster's docstring and docs/python.md
- add a `vector` extra (geopandas + shapely) so environments that only need
  GeoDataFrame/local-vector support can opt out of the rasterio/GDAL stack
  that `all` now pulls in; document the split in docs/python.md and
  python/README.md
- cover the finalizer cleanup path in python/tests/test_map.py

* Address CodeRabbit review feedback

- make the fake xarray Dataset.copy() return an independent Dataset (a new
  mapping over the same variables, as xarray's shallow copy does) instead of
  self, and assert the GeoTIFF is written from the copy rather than the
  caller's Dataset — removing the production data.copy() now fails the test

* Address Claude review feedback

- keep `add_raster(url=...)` working: the first parameter's rename to `source`
  broke that keyword for a published API, so `url` is back as a keyword-only
  deprecated alias that emits a DeprecationWarning, raises if combined with
  `source`, and still raises a clear TypeError when neither is given
- carry the xarray behavior into skills/geolibre: the agent reference now
  documents `array_args` (variable, isel, x_dim/y_dim, crs, nodata, plus the
  rio.to_raster passthrough), the EPSG:4326-only-for-lon/lat rule and the
  errors raised otherwise, and the temp-file/close() cleanup caveat
- cover the deprecated alias and both TypeErrors in python/tests/test_map.py

* Address Claude review feedback

- add `_server.unregister_local_file` and call it when a materialized GeoTIFF
  is removed: every xarray materialization writes to a fresh mkstemp path, so
  the token registry (which register_local_file scans linearly to dedup) grew
  one dead entry per add_raster call for the life of the kernel
- pin the Dataset nodata path against real rioxarray: a round-trip test opens
  the written COG with rasterio and asserts the CRS, band count and nodata
  survive the copy + per-variable reassignment, which the fake xarray modules
  cannot model

* Address CodeRabbit review feedback

- note in the agent reference that `url=` remains a deprecated keyword alias
  of `add_raster`'s `source`, so an existing caller reading only the skill
  does not think the keyword was dropped
- say that `driver` only defaults to "COG" and that overriding it through
  array_args really does change the written format, with the reason the
  default matters (the app range-reads a COG; a plain GeoTIFF is converted
  in the browser first)

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-25 11:06:02 -04:00
Qiusheng Wu ba96ccebf4 feat: add Earth Engine layers to Python API (#2085)
* feat: add Earth Engine layers to Python API

Expose a geemap-style add_ee_layer method that turns authenticated Earth Engine objects into restorable raster tile layers. Document supported objects and cover image, collection, vector, validation, and error paths.

* Address review feedback

- add_ee_layer: classify Earth Engine types before the duck-typed `getMapId`
  fallback. `ee.ImageCollection`, `ee.FeatureCollection` and `ee.Feature` all
  expose `getMapId`, so real objects skipped the mosaic/style step entirely —
  collections were never mosaicked and vector styling (`width`, `fillColor`,
  `pointSize`) was dropped, since `FeatureCollection.getMapId` honours only
  `color`. The `getMapId` duck-type is now the fallback for non-`ee` objects.
- Update the ImageCollection/FeatureCollection test fakes to expose `getMapId`
  (asserting it is never called) so the dispatch order is guarded, and add a
  test for the unsupported-type TypeError.
- Include the original error text in the RuntimeError raised when
  `getMapId()` fails, so quota/vis_params failures are not reported as auth
  problems, and document `RuntimeError` in the `Raises` section.
- python/README.md: note that `add_ee_layer` needs `earthengine-api`, call
  `ee.Authenticate()` in the quickstart, and mention map-id expiry.
- docs/python.md: sync the docs-site copy — add the `add_ee_layer` row and the
  Earth Engine quickstart example with the map-id expiry caveat.
- skills/geolibre/references/python-api.md: document that the stored tile URL
  is tied to an expiring Earth Engine map id.

* Address Claude review feedback

- Validate vector `vis_params` against the keys `ee.FeatureCollection.style()`
  actually declares (it takes explicit keywords, not `**kwargs`), so an
  image-shaped `{"min": ..., "max": ...}` on a FeatureCollection raises a
  ValueError naming the accepted keys instead of a raw
  `TypeError: style() got an unexpected keyword argument 'min'` that collides
  with the documented meaning of TypeError.
- Wrap `mosaic()`/`style()` in the same failure handling as `getMapId()`, so a
  preparation failure surfaces as the documented RuntimeError; document the
  vector key set on `vis_params` and widen the Raises entries.
- Move the Earth Engine snippet out of the primary quickstart block in
  python/README.md and docs/python.md into its own clearly-optional example, so
  copy-pasting the quickstart no longer runs a blocking `ee.Authenticate()` or
  requires earthengine-api.
- Note the vector-only style keys in the agent skill reference.
- Tests for both new paths (rejected image vis_params, wrapped mosaic failure).

* Address review feedback

- Test the ee.Feature and ee.Geometry conversion chain in add_ee_layer: a
  Geometry is wrapped into a Feature and a Feature into a single-element
  FeatureCollection before style() runs, and the styled image is fetched with
  empty map params. Those two branches were previously unexercised.

* Address Claude review feedback

- Validate that `vis_params` is a mapping, so a list or string raises the
  method's own TypeError instead of a raw `dict()` error.
- Document that `add_ee_layer` produces a plain raster tile layer, not one of
  the live layers the app's Earth Engine panel manages (that panel matches on
  `metadata.sourceKind === "earth-engine-raster"`). This is intentional: the
  Python path evaluates the object kernel-side into a static tile URL and has
  no browser-side control to sync with.

* Address CodeRabbit review feedback

- Parameterize the non-mapping `vis_params` test over a list, a string, and an
  int, so every shape the documented TypeError covers is exercised.
2026-08-24 09:20:22 -04:00
58d08f4ee7 feat: Encoded Polyline Encoder/Decoder Implementation (#2030)
* feat: Encoded Polyline Encoder/Decoder Implementation

Implemented a comprehensive Google Encoded Polyline codec supporting standard precision 5 (Google, OSRM) and precision 6 (Valhalla, Mapbox), unescaping of backslashes, batch parsing with custom delimiters, interactive preview and coordinate inspector in Add Data UI, processing toolbox tools, layer export, and full Python package support with zero external dependencies.

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

* fix: Sequence was used in the type annotation of add_polyline but was never imported

Sequence was used in the type annotation of add_polyline() at line 1203, but was never imported.

* fix(polyline): resolve review findings across codec, export, and processing tools for Encoding Polyline #2029

- PolylineSource: use translated previewInfo string and reset delimiters on sample select
- tauri-io: deduplicate polyline decoding and validate coordinate ranges for precision detection
- vector-export / UI: thread precision option and add distinct precision 5 & 6 export menu actions
- core & python: prevent unsafe conversion of literal \n, \r, \t in unescapePolyline
- processing: validate polyline ASCII character range and handle MultiLineString roundtrips symmetrically
- python: import Sequence from collections.abc and align delta rounding to Math.round() behavior

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

* fix(polyline): localize UI labels and harden coordinate validation in processing and Python API #2029

- LayerPanel & AttributeTable: localize Encoded Polyline export options via dynamic precision catalog key
- PolylineSource: localize sample route labels and metric distance units via t()
- vector-tools: add bounds validation ([-180, 180], [-90, 90]) for decoded coordinates in decodePolylineTool
- tests: add null assertion on decoded geometry and test for out-of-bounds polyline input
- python/polyline: validate coordinate bounds in encode_polyline, check character byte range in decode_polyline, and default unescape to False

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

* fix(polyline): enhance decoder validation, geometry export restrictions, and UI formatting #2029

- packages/core: export `decodePolylineDetailed` to indicate whether polylines were fully consumed without truncation.
- packages/processing: update `decodePolylineTool` to skip features with incomplete coordinate pairs using `decodePolylineDetailed`.
- apps/geolibre-desktop:
  - Format distance preview using locale-invariant SI units (`m` / `km`).
  - Sanitize `baseProperties.source` to file basename instead of full local path.
  - Strict line-compatibility check in `layerSupportsPolylineExport` to reject mixed-geometry layers.
- python: validate cumulative lat/lon bounds `[-90, 90]` and `[-180, 180]` in `decode_polyline`.
- tests: add regression tests for truncated coordinate decoding, mixed geometry exports, and Python coordinate bounds.

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

* fix(polyline): reject overlong varint shifts and harden geometry export restrictions #2029

- packages/core: guard polyline varint decoding loops against shifts exceeding 30 bits or 32-bit payload overflows, returning incomplete status immediately.
- apps/geolibre-desktop: ensure layerSupportsPolylineExport strictly requires all feature geometries to be line-compatible.
- tests: add regression tests for the overlong "_______?" varint chunk in decodePolyline and decodePolylineTool.
- python: add tests covering coordinate bounds validation on latitude and longitude axes.

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

* fix(polyline): correct sample escaped data and harden coordinate validation #2029

- apps/geolibre-desktop:
- Update `sampleEscaped` sample polyline in `PolylineSource.tsx` with a valid string containing a properly escapable backslash varint sequence.
- Harden `hasValidPolylineCoordinates` in `tauri-io.ts` to reject empty coordinate lists and enforce non-empty total point counts.
- Document precision 5 vs 6 auto-detection heuristics and bounds handling during dropped-file ingestion.

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

* fix(polyline): enforce complete decoding in batch decoder and clean up imports #2029

- packages/core: require `complete === true` from `decodePolylineDetailed` in `batchDecodePolylines` before accepting features.
- apps/geolibre-desktop: remove unused `Info` and `MapPin` imports from `PolylineSource.tsx`.
- tests: add test verifying `batchDecodePolylines` skips lines with invalid characters or truncated varints.

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

* Address review feedback

- Fix the "Escaped backslashes (JSON string format)" sample, which failed to
  decode at all ("No valid polyline coordinates could be decoded"). The old
  value was not a real polyline: it carried one stray backslash that truncated
  a varint, and even ignoring that it decoded to coordinates off the planet
  (lat 130, lon 774). Replaced with a San Francisco -> Monterey route whose
  encoding genuinely contains a backslash, written doubled the way a JSON
  payload carries it, so the dialog's unescape step is what makes it decode.
- Move SAMPLE_POLYLINES out of PolylineSource.tsx into a leaf module,
  lib/polyline-samples.ts, so the samples can be decoded in a test rather than
  only in the UI, and add tests/polyline-samples.test.ts covering every sample
  and the escaped one's with/without-unescape behaviour.
- Document in parsePolylineFileLayers that the precision 5 -> 6 bounds check
  cannot resolve the ambiguous cases: the two decodes differ by exactly 10x, so
  in-bounds at precision 5 implies in-bounds at precision 6, and precision-6
  data near the prime meridian/equator still imports 10x too large.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: giswqs <giswqs@gmail.com>
2026-08-21 18:02:02 -04:00
Qiusheng Wu dc23f9f0e0 docs(python): document marimo workaround (#1788)
* docs(python): document marimo workaround

* docs(python): fetch marimo sample in browser

* docs(python): address marimo review feedback
2026-08-08 20:43:41 -04:00
Qiusheng Wu 7238e82b49 fix(python): trim Layer.source redaction cost and document save_project (#1772)
* Address Claude review feedback

- Sweep only the source field in Layer.source. redact_layer copied an inlined
  geojson blob first and then discarded it, which .data pays for deliberately
  but .source has no reason to.
- Document that the top-level save_project writes verbatim, credentials
  included, and why: it is the lossless primitive the MCP server round-trips a
  user's own file through, where redacting on every edit would strip their own
  API keys. Point at Map.save_project and redact_credentials for the sharing
  case.
- Sync docs/python.md's Map API table with the methods this PR adds; it had
  drifted from the expanded table in python/README.md.

* Address CodeRabbit review feedback

Fix the remove_layer row in python/README.md rather than docs/python.md. The two
tables disagreed and the README was the wrong one: the parameter is `layer_id`,
so `remove_layer(layer=...)` raises TypeError. Both now match the signature,
and the description already says it accepts an id, name, or handle.
2026-08-08 01:24:00 -04:00
Qiusheng Wu 750a80c52e feat(python): add an MCP server for authoring GeoLibre projects (#1734)
* feat(python): add an MCP server for authoring GeoLibre projects

Adds `geolibre-mcp`, a headless stdio MCP server that writes real
`.geolibre.json` projects from an AI client. No browser, no running app,
and no bundled web build: it composes projects with the same builders the
Python package already uses, so anything it writes opens unchanged in the
desktop app, the web app, and the Jupyter widget.

Layering, so nothing is duplicated:

- `project.py` keeps *building* pieces (a layer, a plugin-state blob).
- New `authoring.py` *applies* them to a whole project: add/remove/reorder
  and restyle layers, classify a choropleth, move the camera, compose the
  legend/colorbar/swipe controls, and summarize a project back.
- `Map` now delegates its split-map, legend, colorbar, and choropleth
  composition to `authoring.py` instead of holding its own copy, so the
  widget and the MCP server cannot drift.
- `to_html` splits into a module-level `render_project_html()` the server
  can call without a widget; `Map.to_html` delegates to it.

The server itself is `mcp/server.py` (21 tools), the only module that
imports the `mcp` SDK, kept behind the optional `geolibre[mcp]` extra.
`mcp/workspace.py` confines every read and write to roots given via
`--root` or `GEOLIBRE_MCP_ROOTS`, mirroring the sidecar's
`GEOLIBRE_CONVERSION_ROOTS`; symlinks out of a root are refused, writes
are limited to `.json`/`.html`, and an existing file needs `overwrite`.

Verified end to end: a client spawned the server over stdio, listed the
tools, and built a choropleth of 52 US states fetched from a live URL
plus COG, XYZ, legend, colorbar, and swipe layers; the resulting project
round-trips through the app's own `parseProject` with its symbology and
plugin state intact, and a workspace escape is refused over the wire.

`tests/test_mcp_server.py` skips itself without the SDK, so
publish-python.yml installs `mcp` explicitly rather than shipping the
server untested.

* test(python): cover every MCP tool, and fix add_vector_layer's documented render_mode

An audit of the tool surface found 6 of 21 tools with no test at the MCP
layer, and two more (add_tiles_layer, remove_layer) covered only on their
error paths. Some were covered one layer down in test_authoring.py, but
the argument plumbing in the tool wrappers was not.

Filling the gap turned up a real defect: add_vector_layer documented
render_mode as "geojson" or "vector-tiles", but project.vector_layer
accepts "geojson" or "tiles". A model following the docstring would have
hit a ValueError on every call. The docstring now names the real values,
and a test asserts the undocumented spelling is rejected, so the two
cannot drift again.

Adds happy-path coverage for every add_* tool (parametrized over the
builder each one plumbs, asserting the resulting layer type), plus
style_layer merge semantics, remove_layer, set_basemap, add_legend from
both a preset and paired lists, add_colorbar, and the legend/colorbar
coexistence in their shared settings blob.

* Address review feedback

- authoring.save_project: write to a temporary file in the destination's
  directory and os.replace it into place, so an interrupted write cannot
  truncate an existing project (the MCP edit path rewrites the whole file on
  every tool call).
- authoring.column_values: GeoJSON permits `"properties": null`, which made
  `.get(column)` raise AttributeError instead of the documented ValueError.
  Guard with isinstance, matching layer_properties.
- mcp/workspace.Workspace: refuse an empty root set at construction. A
  separators-only GEOLIBRE_MCP_ROOTS filtered down to no roots and every later
  relative-path resolve raised IndexError instead of WorkspaceError.
- mcp/workspace.resolve_output: require a non-empty stem before the suffix, so
  a bare dotfile named exactly `.json` or `.html` is refused as the comment
  already claimed.
- mcp/server.edit: run the destination through the same extension allowlist
  create_project writes through, and check the loaded JSON actually looks like
  a GeoLibre project. load_project accepts any JSON object and seeds `layers`,
  so a mutating tool could previously rewrite an unrelated `package.json`
  inside a workspace root as a map.
- mcp/server.create_project: route the initial zoom through authoring.set_view
  so it is clamped to [0, 24] like every other camera-mutating path.
- mcp/server.add_geojson_layer: resolve the destination before fetching or
  reading `data`, so a bad path fails without paying for the download first.
- mcp/workspace: note that `.geolibre.json` in PROJECT_SUFFIXES is a subset of
  `.json` and is listed to name the convention in the client-facing error.
- Tests for each of the above, and drop the dead `is_dir()` conditional in
  test_workspace_rejects_traversal_out_of_a_root.
- CLAUDE.md: use the repository-relative `python/tests/test_mcp_server.py`.
- docs/mcp.md: document the two new write guards.

* Address review feedback (round 2)

- render_project_html: require app_url to be an http(s) URL with a host, and
  post the project to that exact origin instead of "*". The MCP export_html
  tool takes app_url straight from a tool call, so a model reading untrusted
  content could be steered into posting a project — inlined features, layer
  URLs, camera — to an attacker's origin. Verified the default host
  (web.geolibre.app) serves the embed with no cross-origin redirect, so the
  strict targetOrigin does not break the default export.
- authoring.save_project: chmod the temporary file to the destination's mode
  before os.replace. NamedTemporaryFile creates at 0600, so the atomic write
  added in c97c1f8b would have narrowed a project the user had made
  group/world readable on its first edit.
- mcp/server.add_geojson_layer: load `data` inside the edit context, so the
  destination clears every check edit() makes — confinement, extension, and
  that the file really is a project — before the fetch or read is paid for.
  The previous pre-check only covered confinement and existence.
- mcp/server.add_geojson_layer: the docstring said `https://` URL; load_geojson
  accepts plain `http://` too.
- Tests for each of the above.

* Address Claude review feedback (round 3)

- mcp/server._require_project: drop the `or project.get("layers")` fallback.
  load_project normalizes `layers` onto whatever it loaded, and a top-level
  `layers` array is not exclusive to this format (map/style configs use one
  too), so the fallback let an unrelated config pass the guard the marker
  check exists to enforce. Requires `mapView` or `basemapStyleUrl` now, both
  of which every project this package or the app writes carries.
- authoring.set_view: clear `mapView.bbox` when `center` or `zoom` is set.
  fit_bounds records the bbox to describe the camera it computed; moving the
  camera by hand left it describing a different extent, which the app reads
  for the status bar's BBox readout. Bearing and pitch leave it alone, since
  neither changes the extent. `bbox` is optional in the schema and StatusBar
  renders "—" without it.
- Tests for both, plus the set_view tool docstring.

* Address Claude review feedback (round 4)

- mcp/server: route every layer builder through a new _build_layer, which
  reports a `style` key that names one of the builder's own parameters as a
  ValueError instead of an unhandled TypeError. `style` is free-form in the
  tool schema, so a model with no keyword feedback loop guessing `source_url`,
  `render_mode`, `colormap`, `tile_type`, ... is realistic. Collisions with
  keyword parameters are named explicitly; a collision with a positional one
  (`name`, `url`, `data`) is caught from the call itself.
- authoring.fit_bounds: accept a bbox crossing the antimeridian. RFC 7946
  section 5.2 writes those with min_lng > max_lng, so Fiji is
  [170, -20, -170, -10]; that was rejected as "inverted", leaving any
  180-crossing extent with no way to be framed. The longitude span now wraps
  and the center follows it back into [-180, 180]. Inverted latitudes are
  still an error, which still catches a swapped-argument mistake.
- authoring.update_layer: refuse to rename a layer to "__basemap__".
  resolve_layer_ids passes that sentinel through before consulting the layer
  list, so a layer wearing it as a display name became unaddressable and the
  swipe control would silently target the basemap instead.
- Tests for each of the above.

* Address Claude review feedback (round 5)

- Credential redaction no longer strips the first-party map controls. Both
  implementations blanked `plugins.settings` wholesale, so the documented
  compose-then-export flow (add_legend/add_colorbar/add_swipe -> export_html)
  produced a page with no legend, colorbar, or swipe — and Map.to_html and
  Map.save_project lost them too. A new PUBLISHABLE_PLUGIN_SETTINGS allowlist
  keeps the two first-party control plugins, whose state is known and carries
  no credentials, and still drops every external plugin's free-form blob, so
  the no-secret guarantee the wholesale wipe existed for is unchanged.
  Mirrored in packages/core/src/credentials.ts so the app's share/export path
  does not diverge from the Python one.
- project.load_featurecollection: cap literal GeoJSON text at 50 MB. Only the
  URL and file forms were bounded, so the "Capped at 50 MB" the MCP tool
  documents was not true for the one form an MCP client supplies directly.
- mcp/server.create_project: `overwrite=True` now means "replace the project
  there", not "replace whatever is there". PROJECT_SUFFIXES admits any `.json`,
  so an agent retrying after an "already exists" error could destroy an
  unrelated config with none of the safety net edit() applies.
- authoring.describe_project: report `swipe` only when its plugin is also in
  activePluginIds. A settings blob left behind by a deactivated control was
  reported as a live control. The legend and colorbar are drawn from settings
  alone, so they stay settings-only.
- authoring.column_values: distinguish a column absent from every feature from
  one present but null in all of them; only the first is worth retrying with a
  different name.
- Tests for each, including an export-keeps-its-controls test on both the MCP
  tool and Map.to_html, which is what the previous test missed.

* Address Claude review feedback (round 6)

Both findings were holes in the round-5 fixes.

- PUBLISHABLE_PLUGIN_SETTINGS is now plugin id -> allowed sub-keys, not a flat
  id allowlist, and the components plugin lists only `legend` and `colorbar`.
  Its `html` sub-key holds a custom panel the user authored by hand, so it can
  carry anything — including a URL with a token — and keeping the whole blob
  would have let that survive an export. The swipe blob is structured
  throughout (layer ids, orientation, position, flags) and is still kept whole.
  What survives now also goes through the same credential sweep layer
  configuration gets, rather than being trusted verbatim; a blob with an
  unexpected shape is dropped rather than passed through. Mirrored in
  packages/core/src/credentials.ts so the two implementations still agree.
- mcp/server.create_project: a ValueError from load_project now refuses the
  overwrite instead of skipping the guard. A file that cannot be read as a
  project at all — malformed, a JSON array, over MAX_PROJECT_BYTES — is the
  case to refuse hardest, not the one where the check falls away. The error
  says to delete the file if it is unwanted, so a corrupted project is not a
  dead end.
- Tests for each, on both the Python and TypeScript sides.

* Address Claude review feedback (round 7)

- authoring.set_view: reject non-finite camera values. `json.loads` turns an
  out-of-range literal like `1e400` into `inf` without raising, and
  `json.dumps` writes it back as a bare `Infinity` token — not valid JSON per
  RFC 8259, so the app's JSON.parse rejects the whole project. center, zoom,
  bearing, and pitch all go through a `_finite` check now, matching
  fit_bounds. save_project also passes allow_nan=False as a backstop, so no
  other path can write a project the app cannot read.
- authoring.remove_layer: drop the removed layer's id from a swipe control's
  leftLayers/rightLayers. The split kept a dangling reference with nothing
  surfacing it. The basemap pseudo-id is not a layer, so it is untouched.
- mcp/server._build_layer: check `style` against the builder's whole
  signature, not just the arguments the tool forwards. vector_layer takes
  `picker`/`ingest_mode` and vector_tiles_layer takes a singular
  `source_layer`, none of which the tools expose — so `style={"picker": False}`
  bound to real behavior while the docstring promised style overrides. This
  also catches the positional case up front, leaving the TypeError branch as a
  backstop.
- authoring.save_project: use stat.S_IMODE when carrying the destination's
  mode over, so only the permission bits are copied.
- Tests for each.

* Address Claude review feedback (round 8)

- geolibre-mcp now points at a package-level `geolibre.mcp:main` wrapper that
  translates a missing `mcp` extra into an actionable line ("Install it with:
  pip install \"geolibre[mcp]\"") and exit code 1, instead of a raw
  ModuleNotFoundError traceback. `python -m geolibre.mcp` goes through the same
  wrapper. A dependency missing from underneath an installed SDK still
  propagates, since that is a broken environment rather than a missing extra
  and its traceback is the useful thing to show.
- Tests for both branches, plus a real check that `geolibre-mcp --help` still
  works through the new entry point.

* Address Claude review feedback (round 9)

- authoring.add_layer: enforce the reserved "__basemap__" name at creation,
  not only on rename. The round-4 guard covered update_layer but a client
  could still create a layer wearing the sentinel, which resolve_layer_ids
  would then treat as the basemap for any name-based reference. The check is
  now one _reject_reserved_name helper both paths call.
- packages/core/src/credentials.ts: cast the redacted settings to
  Record<string, unknown>, matching the field's declared type, rather than
  Record<string, never> — which claims no property may have a value and only
  type-checked because the value flowed through `unknown`.
- docs/mcp.md: document `app_url` as a trust boundary on export_html. The
  exported page posts the project to exactly that origin, and the caller is a
  model that may be acting on content it has read. Credentials are already
  stripped so this is not a key leak, but the rest of the project travels, and
  the residual risk is worth stating rather than leaving implicit.
- Test that add_layer refuses the reserved name.

* fix(mcp): write tool descriptions so a model actually reaches for them

The server was registered and healthy, all 21 tools loaded, and a model
asked to "make me a choropleth of US state population density" still
wrote geopandas + matplotlib + folium by hand and never called it.

The descriptions said what each tool does, never when to use it.
`create_project` read "Create a new, empty GeoLibre project file" -- true,
and useless to a model deciding whether this is the right path for a map
request. Since create_project is the entry point, nothing downstream could
fire either.

Rewrites the server instructions and the four entry-point descriptions
(create_project, add_geojson_layer, classify_layer, export_html) to lead
with the user intents they serve -- "make me a map of X", "a choropleth of
Y", "show this on a map" -- and to say plainly when hand-written plotting
code is the better answer instead (a static figure for a paper, a
projection GeoLibre cannot render).

Measured, not assumed. Same prompt, same clean directory, before and
after: 0 geolibre calls before, 11 after, in the intended order --
list_catalog, create_project, add_geojson_layer, classify_layer, set_view,
add_legend, export_html, describe_project -- producing a valid project
with 51 states classified into 7 quantile breaks and a 127 KB standalone
page.
2026-08-06 11:52:28 -04:00
Qiusheng Wuandpre-commit-ci[bot] 43ae1b48cd fix: redact project credentials on egress (#1702)
* fix: redact project credentials on egress

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

* Address Copilot review feedback

- Fail closed when project configuration exceeds the redaction depth cap.
- Redact subscription-key URL parameters from Python project egress.

* Address CodeRabbit review feedback

- redact credential parameters from basemap style URLs

- report individual redacted fields and correct Turkish wording

* Handle projects without basemap style URLs

* Address final automated review feedback

- sanitize basemap and plugin manifest URLs across TypeScript and Python

- harden URL parameter and userinfo redaction edge cases

- align collaboration snapshots and credential counts

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

* Normalize decoded credential parameter names

* Close remaining credential egress gaps

- redact geocoder endpoints in TypeScript and Python

- fail closed at traversal depth in both implementations

- detach inline GeoJSON in redacted projects

* Unify credential URL redaction

- reuse the central URL sanitizer for Layer Library exports

- preserve legacy blank environment-variable counting

- document intentionally structural project fields

* Complete egress parity across export paths

- reuse the central structural-key registry for Layer Library exports

- redact Python layer source paths and document processing scope

* Preserve credentials in trusted widget sync

- distinguish the co-located anywidget host from external embed egress

- retain redaction for standalone HTML and iframe hosts

* Reconcile credential field and URL parameter registries

- Extend PROJECT_CREDENTIAL_FIELDS.layerConfiguration with the credential
  names the URL registry already stripped but the object-key denylist
  missed (secret, bearer, auth, authKey, sasToken, subscriptionKey,
  signature, pwd), so a layer source storing one as a plain JSON field is
  no longer shipped in a "redacted" export.
- Derive URL_CREDENTIAL_PARAMS from that same list plus the names that are
  credentials only in a query string (`key` and the Azure SAS positional
  parameters sv/sr/st/se/sp/sig/skoid), and document why they stay
  URL-only: as configuration field names they collide with ordinary state
  such as an ArcGIS `sr`.
- Match names through a shared normalizer so apiKey/api_key/api-key/APIKEY
  are one entry on both sides, and route the Layer Library export sweep
  through the same isCredentialFieldName predicate instead of its own
  lowercase set, so the two paths cannot drift.
- Cover the new spellings, and the deliberately-kept `sr`/`key` fields,
  in tests/project-credentials.test.ts.

* Address remaining review-summary items

- packages/core/src/layer-library.ts: `redactSourceValue` now fails closed
  past its depth cap instead of returning the value unswept, matching the
  fail-closed stance `redactConfigurationValue` takes in credentials.ts.
  Only the exported bundle loses the value; the local IndexedDB entry is
  untouched.
- useEmbedBridge.ts / embedHost.ts: rewrite the trust-model docs, which
  still claimed the host receives the entire project state. Snapshots are
  redacted by default, and the `trustedWidget` opt-in is documented as
  self-declared by the host — a fidelity switch for the co-located widget,
  not a security boundary; the boundary is still the framing context.
- useProjectFileActions.ts / ProjectFileDialogs.tsx: rename
  EnvStripPrompt/envStripPrompt/askStripEnvVars to the credential-neutral
  CredentialStripPrompt/credentialStripPrompt/askStripCredentials, since
  the prompt now covers every credential type, not just env vars.
- docs/python.md: document that to_project()/save_project()/to_html()
  redact by default and that keep_credentials=True opts back out.

* Cover the Layer Library export depth cap

- Add a regression test for the fail-closed depth cap in redactSourceValue:
  a credential nested past MAX_REDACT_DEPTH must not reach the exported
  bundle. Fails against the previous fail-open behavior.

* Mirror the credential registry into the Python widget

- python/src/geolibre/project.py: `_CREDENTIAL_FIELD_NAMES` was missing the
  names added to the TS registry (secret, bearer, auth, authKey, sasToken,
  subscriptionKey, signature, pwd), so `to_project()`/`save_project()`/
  `to_html()` could ship a credential the JS egress path strips. Derive
  `_CREDENTIAL_URL_PARAMS` from that same set plus the query-string-only
  names, and match through a `_normalize_credential_name` mirror of the JS
  normalizer, with a parity test in python/tests/test_scripting.py.
- useProjectFileActions.ts: reword the stale HTML-export comment — this call
  site no longer strips environment variables itself; buildProjectHtml runs
  the central redaction pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-04 17:35:18 -04:00
Qiusheng Wu 97825e8ea5 feat(python): add leafmap-parity data and layer helpers (#1600)
* feat(python): add leafmap-parity data and layer helpers

Notebook users coming from leafmap expect add_csv/add_gdf/add_heatmap,
KML and GeoPackage shortcuts, and name-based layer lookup and zoom
helpers. Local GeoPackage reads now honor the requested table, and
fit_bounds plus layer opacity reject malformed input instead of
silently producing a broken view.

* Address review feedback

- Fetch remote CSV through project.py's public-only opener with redirect
  re-validation and a 50 MB read bound, and bound local CSV files the same
  way, so add_csv/add_xy_data match the SSRF/DoS defenses used for GeoJSON.
- Give csv.DictReader a string restkey so a row with extra fields cannot put
  a None key in feature properties and break JSON serialization.
- Reject non-finite values in fit_bounds, add_heatmap radius/intensity, and
  add_xy_data coordinates, which NaN and infinity previously slipped past.
- Warn when source_layer is passed with a local (Geo)Parquet file, where
  read_parquet has no layer concept and the argument was silently dropped.
- Document that local add_kml/add_gpkg need the [all] extra, and correct the
  URL-fetching warning to cover add_csv/add_xy_data and the public-only rule.
- Tests: NaN/infinity cases, CSV restkey, non-public URL, oversized file,
  parquet source_layer warning, opacity lower bound, south > north bounds.
2026-07-31 22:32:18 -04:00
Qiusheng Wu 540b0866ee feat(python): split_map, add_legend, and add_colorbar helpers (#347) (#366)
Add thin Python wrappers over the app's built-in map-control plugins,
driven entirely through the synced project dict (no new RPC):

- Map.split_map: enable the Layer Swipe comparison slider between two
  layer sets (accepts ids, Layer objects, or lists; '__basemap__' valid).
- Map.add_legend: from a {label: color} dict, parallel labels/colors, or a
  builtin preset; ships NLCD and ESA WorldCover presets in legends.py.
- Map.add_colorbar / add_colormap: named colormap or custom colors for a
  continuous raster.

The legend/colorbar restore from their settings blob alone, so the
Components plugin is configured but not added to activePluginIds (which
would mount the full Components toolbar). A freshly created plugins block
is seeded with the app's default-active plugin ids so adding a control
does not tear down the layer control or the deck.gl raster overlay.
2026-06-14 15:30:31 -04:00
Qiusheng Wu 73a31b2b4e feat(python): typed read-back of selected/drawn features and to_html export (#365)
* feat(python): typed read-back of selected/drawn features and to_html export (#349)

* Address Claude review feedback

- to_html: insert embed=1 before any URL fragment so a "#..." in app_url no
  longer swallows the query param (the iframe would never activate the bridge).
- to_html: validate width/height as CSS dimensions (reject "{};:") so a value
  cannot close the <style> rule and inject CSS; _html_escape did not cover this.
- get_selected_features: document that only GeoJSON-layer selections read back;
  tile/service selections yield an empty list (use identify).
- tests: cover get_selected_features(as_gdf=True), the fragment-before-query
  case, and CSS-injection rejection.
2026-06-14 15:28:23 -04:00
Qiusheng Wu d7d3d38f0f feat(python): local raster, marker/cluster, and choropleth APIs (#348) (#360)
* feat(python): local raster, marker/cluster, and choropleth APIs (#348)

- add_raster/add_cog accept a kernel-side local GeoTIFF path, served by the
  bundled static server with HTTP Range support (geotiff.js needs ranges)
- add_marker/add_markers/add_circle_markers/add_marker_cluster build point
  GeoJSON layers; cluster enables the point cluster renderer
- add_choropleth/add_data compute graduated symbology from a numeric column
  via a new color_ramp module mirroring the app's color-ramp helpers

* Address CodeRabbit review feedback

- docs/python.md: clarify local-raster paths only work where the browser can
  reach the kernel's localhost (remote setups need a hosted URL)
- docs/python.md: add **style to the add_cog/add_raster table signatures
- color_ramp.get_color_ramp: return a copy so callers can't mutate the shared
  VECTOR_COLOR_RAMPS entries
- add_markers: reject non-Point/MultiPoint GeoJSON inputs instead of silently
  rendering polygons/lines through a point-only markers layer
- add_choropleth: raise when the column has no numeric value (was falling back
  to misleading index-based stops)
- test_server: shut down the static server on fixture teardown to avoid leaking
  threads/sockets across the suite
- tests: cover the two new validation errors

* Address Claude review feedback

- _server: add do_OPTIONS so a CORS preflight on the ranged COG fetch returns
  200 (Range is safelisted in modern browsers, but older browsers/proxies that
  preflight would otherwise hit the stdlib 501 and fail to render local rasters)
- _server.register_local_file: reuse the existing token for an already-registered
  file so repeated add_raster calls don't grow the registry unbounded
- color_ramp.graduated_stops: cap class_count at 12 (mirrors clampClassCount in
  StylePanel.tsx); use math.isfinite; drop the unreachable break/color mismatch
  branch (both schemes always return `count` breaks)
- add_choropleth: cap stored vectorStyleClassCount at 12; use math.isfinite
- tests: cover the class-count cap, ramp-copy immutability, token reuse, and
  the OPTIONS preflight
2026-06-14 14:20:38 -04:00
Qiusheng Wu 6f3f4ade7a feat(scripting): Python automation API + in-app Python Console (#327)
* feat(scripting): Python automation API + in-app Python Console

Add two PyQGIS-style ways to script GeoLibre programmatically.

Notebook API (geolibre.Map anywidget):
- Request/reply command channel + event channel over the anywidget custom
  message bus, relayed by _frontend.js into the embedded app.
- App-side dispatcher (useCommandBridge) backed by a shared scriptingApi
  factory; MapController gains flyTo() and identifyFeatures().
- Python request()/on() primitives (blocking via jupyter-ui-poll) plus
  get_center, get_bounds, fly_to, fit_bounds, identify, get_features,
  run_algorithm, list_algorithms, to_image, and a Layer/Feature model.

In-app Python Console:
- Dockable, resizable bottom panel (Processing -> Python Console), not a modal.
- Main-thread Pyodide runtime (minimal/fast startup, on-demand packages,
  async execution) exposing a `geolibre` facade that drives the live store and
  map, sharing the same scriptingApi handlers as the notebook bridge.

Extracts the embed-host handshake into embedHost.ts (shared by both bridges).

* Address Claude review feedback

- pyodide-console: destroy the result JsProxy in a finally so a throwing
  toString() can't leak it.
- map-controller (featureIdForLayer): return null when the rendered feature has
  no properties, instead of vacuously matching the first source feature.
- _frontend.js: cap the pre-ready pendingCommands queue at 500 as a defensive
  bound.
- console_api to_image: return PNG bytes to match the notebook Map.to_image()
  return type (host-path writing isn't available in-browser).

* Address CodeRabbit review feedback

- test_scripting: inject a fake jupyter_ui_poll via sys.modules instead of
  patching by string path, fixing the package-publish CI job which lacks the
  optional dependency (ModuleNotFoundError).
- map-controller (featureIdForLayer): only return a property-matched id for an
  unambiguous single hit (compare full key sets), else null — supersedes the
  empty-properties guard and avoids wrong ids for non-unique features.
- geolibre.request: move send() inside the try so a failing send() still pops
  the pending slot.
- console_api Layer: sync self._info in the visible/opacity setters so later
  reads on the same handle aren't stale; add -> None annotations.
- PythonConsolePanel: show runtime-init failures as an error (no spinner),
  separate from loading progress.
- embedHost: document the ?embed=1 trust model / deployment constraint.
- scriptingApi.toImage: note the synchronous PNG encode blocks the main thread.

* Address review feedback (round 2)

- embedHost.isEmbedded: auto-trust only a SAME-ORIGIN framing parent by probing
  window.parent.location.href (which throws cross-origin); `window.parent !==
  window` does not throw cross-origin, so the old check would auto-activate the
  bridge for a third-party framer. Doc corrected.
- useCommandBridge: dispatch only own-property handlers (Object.hasOwn), so an
  inherited member like "constructor"/"toString" can't be invoked as a command.
- scriptingApi.setBasemap: validate the url is an http(s)/root-relative string
  (rejects undefined -> "undefined" and javascript:/data: schemes).
- console_api.load_geojson: raise on non-ok HTTP responses instead of parsing an
  error body as GeoJSON.
- to_image (both notebook geolibre.py and console_api): raise on a data URL with
  no comma instead of silently returning empty bytes.
- PythonConsolePanel: tear down in-flight drag listeners on unmount.
- pyodide-console: drop the redundant scriptPromise reset in script.onerror.
- geolibre.request: register the pending slot inside the try.

* docs: add Python Console guide and interactive scripting API reference

- New user-guide page (user-guide/python-console.md) covering the in-app Python
  Console: opening/docking, first-run runtime load, driving the map, async
  await, on-demand packages, and a full geolibre/Layer/Feature API reference.
- python.md: document the interactive scripting surface (live queries, events,
  processing, to_image, Layer/Feature) that the notebook widget shares with the
  console, with a new API-reference subsection.
- mkdocs.yml: add the Python Console page to the User Guide nav.

* feat(python-console): command history, autocomplete, eraser icon

- Up/Down arrows recall previous/next submitted commands (only when the caret is
  on the first/last line, so multi-line editing still works); a draft is saved
  when history navigation starts.
- Tab / Ctrl+Space autocomplete the name or attribute at the caret, backed by a
  new _geolibre_complete() that introspects the live Pyodide namespace (real
  geolibre methods, user variables, builtins, keywords; private names hidden). A
  single match inserts directly; multiple show a dropdown (Up/Down to choose,
  Enter/Tab to accept, Esc to dismiss).
- Swap the Clear-output icon from Trash2 to Eraser.
- Docs: document the editing shortcuts in the Python Console guide.

* feat(python-console): QGIS-style script editor alongside the console

- "Show editor" toggle splits a script editor into the Python Console panel
  (left), with a draggable vertical divider; hidden by default.
- The editor runs in the SAME Pyodide interpreter as the console (shared
  globals), so scripts and the REPL share state. Run executes the whole buffer
  or the current selection; output goes to the console scrollback.
- Open/Save/Save As/New for .py files via the app's cross-platform file I/O
  (openLocalDataFileWithFallback / saveTextFileWithFallback); desktop Save writes
  in place via new tauri-io writeTextFileToPath, web downloads. Dirty marker +
  discard confirmation.
- Editor keys: Ctrl/Cmd+Enter run, Ctrl/Cmd+S save, Tab indent, Ctrl+Space
  autocomplete.
- Factor the console autocomplete into a shared usePyCompletion hook (with a
  tabTriggers option) reused by both the console input and the editor.
- Add i18n strings and document the script editor in the user guide.

* Address review feedback (round 3)

- featureIdForLayer: return null for zero-property features (no fake index id),
  and compare object/array property values by JSON (MapLibre re-parses them to
  fresh refs, so === always failed).
- scriptingApi.setOpacity: reject non-finite and clamp to [0,1].
- notebook Map: add set_basemap alias (name parity with the console facade).
- PythonConsolePanel: dispatch PANEL_RESIZE_END on unmount mid-drag so MapCanvas
  clears panelResizeActive.
- pyodide-console: serialize runs through a queue so overlapping calls can't
  clobber the instance-global stdout/stderr capture; document the merged
  stdout/stderr stream.
- usePyCompletion: clamp completion splice offsets against current code.
- geolibre._wait_for_result: check the deadline before and after poll() so a slow
  pump can't overrun a small timeout.

* feat(python-editor): clear button; address review feedback (round 4)

- Add a "Clear editor" eraser button to the script editor toolbar (empties the
  buffer, keeping the filename; confirms when there are unsaved changes).
- PythonConsolePanel: throttle the editor splitter drag through
  requestAnimationFrame with direct DOM writes (commit to state on mouseup), and
  give the vertical and horizontal drags separate cleanup refs so one can't
  overwrite the other's teardown.
- _frontend.js: warn when the pre-ready command queue is full instead of
  silently dropping, so a full queue is diagnosable.
- geolibre._wait_for_result: poll at 20 Hz (sleep 0.05) instead of 100 Hz to cut
  idle CPU; deadline is still checked each iteration.

* Address review feedback (round 5)

- PythonEditorPane: Tab now block-indents every selected line with 4 spaces
  (PEP 8) instead of replacing a multi-line selection with two spaces; the
  selection is preserved.
- scriptingApi: validate layerId is a non-empty string across the layer handlers
  (getLayerFeatures/removeLayer/setVisibility/setOpacity/setStyle/zoomToLayer)
  via a shared requireLayerId, matching identify.
- _frontend.js: post commands straight through once ready instead of a
  push→flush cycle; only buffer before ready.
- pyodide-console: remove the failed <script> element on load error so retries
  don't orphan dead tags in <head>.
2026-06-13 22:42:27 -04:00
Qiusheng Wu 94618bc707 feat(python): expand Map API to cover more Add Data types (#246) (#266)
* feat(python): expand Map API to cover more Add Data types (#246)

Add leafmap-style methods so data can be added programmatically for the
URL/cloud-native formats the Add Data menu supports, beyond the existing
GeoJSON/XYZ/COG:

- Web services: add_wms, add_wmts, add_wfs
- Vector files (URL via the in-browser vector control, or local file read
  with GeoPandas and inlined): add_vector, add_geoparquet, add_flatgeobuf,
  add_shp
- Tiled/cloud formats: add_vector_tiles, add_pmtiles
- 3D / media: add_3d_tiles, add_video
- add_raster (alias of add_cog)

Each method builds a project-layer record that mirrors the TypeScript
store-layer shapes, so the embedded app reconstructs the layer via the same
restore paths (restoreVectorLayers / restoreRasterLayers /
restoreThreeDTilesLayers) and core layer sync it uses for UI-added layers.

Local vector files are inlined via the optional GeoPandas dependency since
the browser cannot read a kernel-side file; remote URLs need no extras.

Adds builder + Map-method tests and documents the new methods.

* Address Claude and CodeRabbit review feedback

- video_layer: add `source.type:"video"`, `metadata.sourceKind:"video-url"`
  and the corner bbox `metadata.bounds` so "Zoom to layer"/fitLayer works,
  matching the UI-created video layer (Claude).
- video_layer: reject non-https:// URLs up front (browser media-src CSP is
  https-only), surfacing the problem at the call site instead of a silent
  blank render (Claude).
- add_wfs: persist the WFS protocol fields (service/typeName/version/
  outputFormat/srsName) on the source for round-trip editing and isWfsLayer(),
  and record metadata.featureCount like the UI does (Claude).
- _read_local_vector: dispatch (Geo)Parquet to geopandas.read_parquet (the
  GDAL Parquet driver may be absent) and cap the converted GeoJSON at the same
  50 MB limit as load_featurecollection (Claude).
- tests: assert the new video/WFS fields, add a non-https video rejection
  test, and narrow the new pytest.raises(ValueError) assertions with match=
  (CodeRabbit).

* Address Claude and CodeRabbit review feedback (round 2)

- pmtiles_layer: populate metadata.nativeLayerIds (raster ->
  [`<id>-raster`], vector -> [`<id>`]) — isExternalNativeLayer() gates on a
  non-empty list and "pmtiles" has no fallback dispatch in syncLayer, so an
  empty list meant PMTiles layers never rendered (Claude, real bug).
- _read_local_vector: accept a data_format hint that overrides suffix
  detection, and thread it from add_vector, so a GeoParquet under a
  non-standard name still uses geopandas.read_parquet (CodeRabbit).
- video_layer: reject non-string / empty URL entries instead of silently
  dropping them (Claude).
- add_vector: warn when render_mode / source_layer are passed for a local
  file, since they only apply to remote URLs (Claude).
- vector_layer: document why metadata.nativeLayerIds is intentionally empty
  and the render_mode="tiles" fall-through caveat (Claude).
- tests: assert pmtiles nativeLayerIds, non-string video URL rejection, the
  data_format threading, and the local-file ignored-kwargs warning.

* Address Claude review feedback (round 3)

- add_wfs: default max_features to 1000 (matching the UI) since the response
  is inlined; document that None requests every feature (Claude nit).
- wmts_layer: clarify the docstring that the template is WMTS REST
  {z}/{y}/{x} (row before column), unlike XYZ {z}/{x}/{y}, to prevent users
  migrating from add_tile_layer from swapping the order (Claude nit).

* Address Claude review feedback (round 4)

- _append_query: split off any URL "#fragment" before appending the query
  string and re-attach it, so a service endpoint carrying a fragment no longer
  produces "…#frag?SERVICE=…" where the params are never sent (Claude).
- add_vector: warn for ignored render_mode/data_format/source_layer on the
  __geo_interface__ path too, consistent with the local-file path (Claude).
- vector_tiles_layer: warn when both source_layers and source_layer are given
  (source_layer is dropped), instead of silently ignoring it (Claude).
- tests: cover the fragment handling and both new warnings.
2026-06-12 15:20:35 -04:00
Qiusheng Wuandpre-commit-ci[bot] 85644cfc6f fix(jupyter): probe both extension and server-proxy routes on remote hubs (#255)
* fix(jupyter): probe both extension and server-proxy routes on remote hubs (#250 follow-up)

#250 switched the JupyterHub path from jupyter-server-proxy to a bundled
Jupyter Server extension served at {base_url}geolibre/app/. That extension
registers from a startup config drop-in, so it only exists after the Jupyter
server restarts. A typical 'pip install geolibre' in a running notebook does
not restart the server, so the route 404s and the widget shows 'the bundled
app could not be loaded from the Jupyter server'. It also abandoned the
jupyter-server-proxy path that already worked on hubs that ship it.

Keep serving the localhost bundle under the remote path and have the front-end
probe two same-origin routes, using whichever is live:
- {base_url}geolibre/app/ (the server extension; needs a restart, but no
  jupyter-server-proxy), and
- {base_url}proxy/{port}/ (jupyter-server-proxy; works in the running server
  with no restart).

A host needs only one of them, so the extension covers locked-down hubs while
the proxy recovers hubs where it previously worked without a restart. _remote_mode
becomes 'remote' (probe both) instead of 'extension'; the localhost server is
always started so the proxy route has a target. The extension and its hardening
from #250/#252 are unchanged.

* chore(pre-commit): add nbstripout hook for Jupyter notebooks

* chore(pre-commit): format nbstripout hook entry in YAML configuration

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address Claude review feedback

- _frontend.js: probe jupyter-server-proxy before the extension. The proxy
  reaches the bundle in the running server with no restart and is the common
  working case, so trying it first avoids waiting on a failed extension probe
  (the extension only registers after a server restart) on hubs with slow auth
  middleware. (Claude, _frontend.js:49)
- _frontend.js: bound the whole candidate-probe sequence to one shared 15s
  AbortController owned by resolveBase, instead of a 15s timer per probe, so the
  worst-case wait when no route is reachable stays at 15s total rather than
  doubling to 30s. (Claude, _frontend.js:78)

The notebook-output comments (Claude + CodeRabbit on getting-started.ipynb) are
already resolved on the branch: the nbstripout pre-commit hook was added and
pre-commit.ci stripped the committed outputs.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-12 00:59:04 -04:00
Qiusheng Wu 7150603df3 fix(jupyter): serve embedded app via Jupyter Server extension on managed JupyterHub (#247) (#250)
* fix(jupyter): serve embedded app via Jupyter Server extension on hubs (#247)

The widget reached the bundled app on JupyterHub through jupyter-server-proxy's
raw-port route ({base_url}proxy/{port}/index.html). On managed/shared hubs that
route 404s because jupyter-server-proxy is often absent or raw-port proxying is
disabled, so the iframe stayed blank.

Ship a Jupyter Server extension in the wheel that serves the bundled app from
the notebook server's own origin at {base_url}geolibre/app/. It auto-enables on
`pip install geolibre` via an etc/jupyter/jupyter_server_config.d drop-in, needs
no jupyter-server-proxy and no extra port, and works on locked-down hubs. The
widget uses this route under JupyterHub (auto) or server_proxy=True, keeping the
localhost path for local Jupyter/VS Code and Colab's port proxy unchanged. The
front-end HEAD-probes the route and shows an actionable message if the extension
is not active.

Drops the now-unused [hub] extra. Adds extension serving/auto-enable tests.

* Address review feedback

- _extension.py: import tornado lazily inside load_jupyter_server_extension so
  `pytest python/tests` collects without tornado (CI installs only
  anywidget/traitlets/pytest); fixes the failing Test Python package jobs.
- _extension.py: log a warning when the bundled app dir is absent so a dev
  checkout (no `npm run build:embed`) gets a clear diagnostic instead of silent
  404s; rewrite handler docstring to describe its actual role.
- _server.py: validate the bundle only when booting the singleton, so a later
  serve_app() call with a missing/different path reuses the running server
  instead of raising FileNotFoundError.
- _frontend.js: abort the reachability HEAD probe after 5s so a stalled request
  cannot hang widget rendering; add "then re-run this cell" recovery hint.
- python/README.md: note that an already-running Jupyter server must be
  restarted for the auto-enabled extension to take effect.
- tests: split tornado-dependent route tests into test_extension_routes.py
  (guarded by importorskip) and add a JupyterHub-style base_url=/user/alice/
  case alongside the root case.

* Update Python version support and increment package version

* Address review feedback

- _frontend.js: show a "connecting…" placeholder while the reachability probe
  runs (the cell was blank during the await), and raise the HEAD-probe abort
  deadline from 5s to 15s so cold/loaded hubs are not flagged spuriously; mount
  the iframe with replaceChildren to clear the placeholder.
- _extension.py: correct the _AppStaticHandler docstring — a plain tornado
  handler sits outside Jupyter Server's per-request auth pipeline, so the
  public, data-free bundle is served unauthenticated by design (verified no
  "Unprotected handler" warning on jupyter_server 2.17).
- test_extension_routes.py: add a HEAD-method assertion per base_url so a
  GET-only regression of the probe path is caught.

* Update Python version requirement to 3.11 in pyproject.toml

* Address review feedback

- publish-python.yml: align the matrix comment with the raised minimum
  (requires-python >= 3.11); the matrix already starts at 3.11.
- _frontend.js: cache positive reachability results per base URL so a re-render
  skips the redundant HEAD probe; leave negatives uncached so re-running the
  cell after enabling the extension still re-probes and recovers.
- _server.py: clarify the first-boot-only validation comment (a later
  missing/different path 404s rather than raising, given the singleton always
  serves _STATIC_APP).

* Address review feedback

- _extension.py: when the bundled app dir is absent, emit only the warning and
  skip the 'Serving the bundled app at ...' info line, so the log no longer
  shows two contradictory messages.

* Address review feedback

- geolibre.py: fix a regression where Map(server_proxy=True) on Google Colab
  would switch to the server-extension route (which does not exist on Colab)
  and fail. _resolve_remote_mode now detects Colab and keeps the localhost +
  port-proxy path even when the extension is forced, so Colab works regardless
  of server_proxy. Adds tests for the Colab and non-Colab branches.
2026-06-12 00:05:31 -04:00
Qiusheng Wuandgiswqs 1348863383 docs: add conda-forge install and video tutorial (#205)
* docs: add conda-forge install and video tutorial

Document conda-forge installation alongside pip and add conda badges, plus
link the GeoLibre 1.0 video tutorial in the README and getting started guide.

* docs: add conda badges to getting started guide

* Address Claude review feedback

- Present the conda install in README as a separate code block with a prose
  intro instead of an inline bash comment, matching the other docs.
- Note in docs/python.md that optional extras are pip-only and how to add them
  in a conda environment.

---------

Co-authored-by: giswqs <giswqs@users.noreply.github.com>
2026-06-10 18:13:52 -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