Commit Graph
28 Commits
Author SHA1 Message Date
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