-
-
Notifications
You must be signed in to change notification settings - Fork 655
Expand file tree
/
Copy pathgeolibre.py
More file actions
2594 lines (2260 loc) · 102 KB
/
Copy pathgeolibre.py
File metadata and controls
2594 lines (2260 loc) · 102 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""The GeoLibre Jupyter widget and its leafmap-style Python API."""
from __future__ import annotations
import base64
import copy
import csv
import html as _html
import io
import json
import math
import os
import pathlib
import re
import time
import urllib.parse
import uuid
import warnings
from typing import Any, Callable
from urllib.error import URLError
import anywidget
import traitlets
from . import authoring as _authoring
from . import project as _project
from ._server import app_port, register_local_file, serve_app
from .basemaps import resolve_basemap
_HERE = pathlib.Path(__file__).parent
_STATIC_APP = _HERE / "static" / "app"
# Accepted values for the constructor's layout/theme args, validated up front so
# a typo surfaces immediately instead of silently falling back in the front-end.
_VALID_LAYOUTS = frozenset({"embed", "full", "maponly"})
_VALID_THEMES = frozenset({"light", "dark"})
# CSV/tabular input is inlined into the project exactly like GeoJSON is, so the
# same 50 MB ceiling applies to a fetched response or a local file.
_MAX_TABULAR_BYTES = _project._MAX_GEOJSON_BYTES
# Column name for CSV fields beyond the header row. csv.DictReader's default
# restkey is ``None``, which would put a non-string key in the feature
# properties and break JSON serialization on the way to the widget.
_CSV_RESTKEY = "_extra"
def _read_local_vector(
path: Any,
data_format: str | None = None,
source_layer: str | None = None,
) -> dict[str, Any]:
"""Read a local vector file into a GeoJSON FeatureCollection via GeoPandas.
The browser cannot read a file that lives on the kernel host, so a local
vector dataset is read here and inlined as GeoJSON (reprojected to EPSG:4326)
instead of being streamed by the in-browser vector control. GeoPandas is an
optional dependency, imported lazily so the rest of the API works without it.
Args:
path: Filesystem path to a vector file (Shapefile, GeoParquet,
FlatGeobuf, GeoPackage, ...).
data_format: Optional format hint (e.g. ``"parquet"``) that overrides
filename-suffix detection, so a GeoParquet file saved under a
non-standard name still uses the dedicated Parquet reader.
source_layer: Optional layer/table name for a multi-layer container such
as a GeoPackage.
Returns:
A GeoJSON FeatureCollection dict in EPSG:4326.
Raises:
ValueError: If the file does not exist or, after conversion to GeoJSON,
exceeds the 50 MB size limit.
ImportError: If GeoPandas is not installed.
"""
file_path = pathlib.Path(str(path)).expanduser()
if not file_path.exists():
raise ValueError(f"Vector file not found: {path}")
try:
import geopandas
except ImportError as exc:
raise ImportError(
"Reading a local vector file requires GeoPandas. Install it with "
"`pip install geopandas`, or pass a URL to a hosted dataset instead."
) from exc
# GeoPandas' GDAL-backed read_file may lack the Parquet driver depending on
# the GDAL build, so dispatch (Geo)Parquet to the dedicated reader. Honour an
# explicit format hint so a Parquet file under a non-standard name still works.
is_parquet = (data_format or "").lower() in ("parquet", "geoparquet") or (
file_path.suffix.lower() in (".parquet", ".geoparquet", ".pq")
)
if is_parquet:
# read_parquet has no layer concept, so a source_layer here is a no-op.
if source_layer is not None:
warnings.warn(
"source_layer is ignored for (Geo)Parquet files; it only applies "
"to multi-layer containers such as GeoPackage.",
stacklevel=2,
)
gdf = geopandas.read_parquet(file_path)
else:
gdf = geopandas.read_file(file_path, **({"layer": source_layer} if source_layer else {}))
if gdf.crs is not None:
gdf = gdf.to_crs(epsg=4326)
# Round-trip through GeoPandas' own GeoJSON writer so numpy/datetime property
# values become plain JSON the widget bus can serialize.
geojson = gdf.to_json()
# Cap the inlined payload like load_featurecollection does for URL/file
# GeoJSON; a format like Shapefile can expand sharply once converted.
if len(geojson.encode("utf-8")) > _project._MAX_GEOJSON_BYTES:
raise ValueError(
f"Vector file exceeds the 50 MB GeoJSON size limit after conversion: {path}"
)
return json.loads(geojson)
def _html_escape(value: str) -> str:
"""Escape a string for safe interpolation into HTML attributes/text."""
return _html.escape(str(value), quote=True)
# A CSS length/percentage value (e.g. "100%", "800px", "calc(100% - 2rem)"). The
# allowed set deliberately excludes the structural CSS characters ("{};:") so a
# to_html() width/height cannot close the <style> rule and inject CSS.
_CSS_DIMENSION_RE = re.compile(r"^[\w%.+\-\s()]+$")
# Standalone export shell: an iframe hosting the GeoLibre app plus a script that
# replays the inlined project into it once the app announces it is ready, using
# the same postMessage protocol useEmbedBridge/useCommandBridge speak. The
# project is carried in a JSON <script> block rather than a JS string literal so
# it needs no JS-string escaping. {0}-style fields are filled by str.format, so
# literal CSS/JS braces are doubled.
_HTML_EXPORT_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<style>
html, body {{ margin: 0; padding: 0; height: 100%; }}
#geolibre-frame {{ border: 0; display: block; width: {width}; height: {height}; }}
</style>
</head>
<body>
<iframe id="geolibre-frame" src="{iframe_src}" allow="fullscreen" allowfullscreen></iframe>
<script type="application/json" id="geolibre-project">{project_json}</script>
<script>
(function () {{
var frame = document.getElementById("geolibre-frame");
var project = JSON.parse(
document.getElementById("geolibre-project").textContent
);
var loaded = false;
function load() {{
if (loaded || !frame.contentWindow) return;
loaded = true;
frame.contentWindow.postMessage(
{{ type: "geolibre:load-project", project: project, seq: 1 }},
{app_origin}
);
}}
// The app posts "geolibre:ready" once mounted; reply with the project. Guard
// on the frame as the source so an unrelated message cannot trigger the load.
window.addEventListener("message", function (event) {{
if (event.source !== frame.contentWindow) return;
var data = event.data;
if (data && data.type === "geolibre:ready") load();
}});
}})();
</script>
</body>
</html>
"""
# Where a standalone export loads the app from by default: the hosted viewer, so
# the exported file stays portable once the kernel is gone.
DEFAULT_HTML_APP_URL = "https://web.geolibre.app/"
def render_project_html(
project: dict[str, Any],
*,
title: str = "GeoLibre Map",
width: str = "100%",
height: str = "800px",
app_url: str | None = None,
) -> str:
"""Render a project dict as a standalone HTML page.
The page embeds the GeoLibre app in an ``<iframe>`` and injects the project
into it over the same ``postMessage`` bridge the widget uses, so it renders
the map as configured. Credentials are stripped from the inlined project on
the way out, exactly as :meth:`Map.to_html` does.
This is the widget-free half of :meth:`Map.to_html`; the MCP server calls it
to export a project that was never attached to a live map.
Args:
project: The project dict to embed.
title: The exported page's ``<title>``.
width: CSS width of the embedded map (e.g. ``"100%"`` or ``"800px"``).
height: CSS height of the embedded map.
app_url: Base URL of the GeoLibre app to embed. Defaults to
:data:`DEFAULT_HTML_APP_URL`.
Returns:
The HTML document as a string.
Raises:
ValueError: If ``width`` or ``height`` is not a plain CSS dimension, or
``app_url`` is not an ``http``/``https`` URL.
"""
base_url = app_url or DEFAULT_HTML_APP_URL
# The project is posted into the frame, so the app URL decides where it
# lands. Pin it to http(s) with a real host, and post to that exact origin
# rather than "*": the MCP server takes app_url straight from a tool call,
# and a model can pick an argument up from content it is reading. A
# redacted project still carries inlined features and layer URLs.
origin = urllib.parse.urlsplit(base_url)
if origin.scheme not in ("http", "https") or not origin.netloc:
raise ValueError(f"to_html: app_url must be an http(s) URL, got {base_url!r}")
app_origin = f"{origin.scheme}://{origin.netloc}"
# Force the embed bridge on (isEmbedded() honours ?embed=1). Insert the
# parameter into the query string *before* any URL fragment: a "#..."
# fragment would otherwise swallow a trailing "?embed=1" (browsers read it
# as part of the fragment), so the app never sees the flag. partition keeps
# the fragment and its "#" intact when present and yields "" when absent.
base, hash_sep, fragment = base_url.partition("#")
separator = "&" if "?" in base else "?"
iframe_src = f"{base}{separator}embed=1{hash_sep}{fragment}"
# width/height land inside a <style> rule; _html_escape does not neutralise
# CSS metacharacters like "}" or ";", so validate them as plain CSS
# dimensions to keep a stray value from closing the rule and injecting CSS.
if not _CSS_DIMENSION_RE.match(width):
raise ValueError(f"to_html: invalid CSS width value {width!r}")
if not _CSS_DIMENSION_RE.match(height):
raise ValueError(f"to_html: invalid CSS height value {height!r}")
# Inline the project inside a JSON <script> block and escape "<" so a
# property value can never break out of the script element; "<" is valid
# JSON that JSON.parse restores to "<".
project_json = json.dumps(_project.redact_credentials(project)).replace("<", "\\u003c")
return _HTML_EXPORT_TEMPLATE.format(
title=_html_escape(title),
width=_html_escape(width),
height=_html_escape(height),
iframe_src=_html_escape(iframe_src),
project_json=project_json,
# json.dumps supplies the surrounding quotes, so the template field is
# the whole JS string literal.
app_origin=json.dumps(app_origin),
)
class Map(anywidget.AnyWidget):
"""An interactive GeoLibre map for Jupyter notebooks.
The widget embeds the full GeoLibre GIS app (menus, panels, processing
tools) and exposes a small Python API to add data and drive the view. State
is synchronized both ways through a single ``.geolibre.json`` project, so
edits made in the UI are readable from Python via :meth:`to_project`.
Example:
>>> from geolibre import Map
>>> m = Map(center=(-100, 40), zoom=4)
>>> m.add_geojson("https://example.com/data.geojson", name="Data")
>>> m
"""
_esm = _HERE / "_frontend.js"
# The serialized project is the single source of truth synced over the
# bridge. Edits in the UI flow back into this trait.
project = traitlets.Dict().tag(sync=True)
# Base URL of the localhost server hosting the bundled app.
_app_url = traitlets.Unicode("").tag(sync=True)
# Port of that server, so the front-end can route through a host proxy (e.g.
# google.colab.kernel.proxyPort) when localhost is not reachable from the
# browser, as on Google Colab.
_app_port = traitlets.Int(0).tag(sync=True)
# How the front-end reaches the app on a remote server. "" means the direct
# localhost path (local Jupyter, VS Code). "remote" means the browser cannot
# reach the kernel's localhost, so the front-end probes two same-origin
# routes and uses whichever is live: the bundled Jupyter Server extension at
# `{base_url}geolibre/app/`, and jupyter-server-proxy at
# `{base_url}proxy/{_app_port}/`. Either one works on JupyterHub and other
# remote servers; the localhost bundle is always served so the proxy route
# has a target. Google Colab is detected in the front-end and uses its own
# port proxy.
_remote_mode = traitlets.Unicode("").tag(sync=True)
height = traitlets.Unicode("800px").tag(sync=True)
# "embed" (compact chrome), "full" (desktop chrome), or "maponly".
layout = traitlets.Unicode("embed").tag(sync=True)
theme = traitlets.Unicode("light").tag(sync=True)
# Bumped on every Python-initiated project change; echoed by the app.
_seq = traitlets.Int(0).tag(sync=True)
# Last error reported by the app (e.g. an invalid project).
error = traitlets.Unicode("").tag(sync=True)
def __init__(
self,
center: list[float] | tuple[float, float] | None = None,
zoom: float | None = None,
*,
basemap: str | None = None,
height: str = "800px",
layout: str = "embed",
theme: str = "light",
server_proxy: bool | str = "auto",
**kwargs: Any,
) -> None:
"""Create a GeoLibre map.
Args:
center: Initial ``[lng, lat]`` map center.
zoom: Initial zoom level.
basemap: A basemap name or MapLibre style URL for the background.
height: CSS height of the widget (e.g. ``"800px"``).
layout: ``"embed"`` (compact UI), ``"full"`` (full desktop UI), or
``"maponly"`` (map without chrome).
theme: ``"light"`` or ``"dark"``.
server_proxy: How the browser reaches the bundled app.
``"auto"`` (default) serves the app directly from localhost for
local Jupyter and VS Code, and switches to a remote-aware path
when running under JupyterHub (detected via
``JUPYTERHUB_SERVICE_PREFIX``). On that path the front-end probes
two same-origin routes and uses whichever is live: the bundled
GeoLibre Jupyter Server extension at ``{base_url}geolibre/app/``
(needs no ``jupyter-server-proxy`` but only registers after the
Jupyter Server restarts) and ``jupyter-server-proxy`` at
``{base_url}proxy/{port}/`` (works in the running server without a
restart). Pass ``True`` to force the remote path on any other
remote server (Binder, remote JupyterLab), or ``False`` to force
the direct localhost path. Google Colab is detected separately and
always uses its own port proxy.
**kwargs: Forwarded to ``anywidget.AnyWidget``.
"""
if layout not in _VALID_LAYOUTS:
raise ValueError(f"layout must be one of {sorted(_VALID_LAYOUTS)}, got {layout!r}")
if theme not in _VALID_THEMES:
raise ValueError(f"theme must be one of {sorted(_VALID_THEMES)}, got {theme!r}")
super().__init__(**kwargs)
self.height = height
self.layout = layout
self.theme = theme
self._remote_mode = self._resolve_remote_mode(server_proxy)
# Always start the localhost bundle server. Locally it is the app origin;
# under "remote" it backs the jupyter-server-proxy route (and serves the
# same directory the Jupyter Server extension exposes), so the front-end
# has a live target whether or not the extension has been loaded yet.
self._app_url = serve_app(_STATIC_APP)
self._app_port = app_port() or 0
self.project = _project.build_empty_project(
center=center,
zoom=zoom,
basemap_url=resolve_basemap(basemap) if basemap else None,
)
# Scripting RPC state. Command/result and event traffic ride anywidget's
# custom message channel (self.send / on_msg), kept off the project trait
# so the project sync loop guard is untouched. `_pending` maps an
# in-flight requestId to its result slot; `_event_handlers` maps an event
# name to its registered callbacks.
self._pending: dict[str, dict[str, Any]] = {}
self._event_handlers: dict[str, list[Callable[[Any], None]]] = {}
self.on_msg(self._on_custom_msg)
@staticmethod
def _running_on_colab() -> bool:
"""Return True when running inside a Google Colab kernel."""
try:
import google.colab # noqa: F401
except ImportError:
return False
return True
@staticmethod
def _resolve_remote_mode(server_proxy: bool | str) -> str:
"""Decide how the front-end reaches the bundled app.
Args:
server_proxy: ``True`` to force the remote path (the front-end probes
the server-extension and jupyter-server-proxy routes) on any
remote server, ``False`` to force the direct localhost path, or
``"auto"`` to use the remote path only when a JupyterHub
single-user server is detected (via the
``JUPYTERHUB_SERVICE_PREFIX`` environment variable).
Returns:
``"remote"`` to have the front-end probe the server-extension and
jupyter-server-proxy routes, or ``""`` for the direct localhost path.
"""
if isinstance(server_proxy, bool):
mode = "remote" if server_proxy else ""
elif server_proxy == "auto":
mode = "remote" if os.environ.get("JUPYTERHUB_SERVICE_PREFIX") else ""
else:
raise ValueError("server_proxy must be True, False, or 'auto'")
# Google Colab reaches the app through its own port proxy (resolved in
# the front-end), which needs the localhost server running and a
# populated _app_port. Never route Colab through the remote path, even
# when server_proxy=True is passed explicitly.
if mode == "remote" and Map._running_on_colab():
return ""
return mode
# -- internal --------------------------------------------------------
def _update_project(self, mutate: Callable[[dict[str, Any]], None]) -> None:
"""Mutate the project off a deep copy and reassign it.
traitlets only fires a sync on identity change, so an in-place edit of
``self.project`` would not reach the app. Each mutation works on a copy,
bumps the sequence counter, and reassigns the trait.
Args:
mutate: Callback that mutates the project dict in place.
"""
proj = copy.deepcopy(self.project)
mutate(proj)
self._seq += 1
self.project = proj
def _add_layer(self, layer: dict[str, Any]) -> str:
self._update_project(lambda p: p["layers"].append(layer))
return layer["id"]
# -- scripting RPC ---------------------------------------------------
def _on_custom_msg(self, _widget: Any, content: Any, _buffers: Any) -> None:
"""Handle out-of-band messages from the app (results and events).
Args:
_widget: The widget instance (unused; required by the on_msg API).
content: The decoded message payload.
_buffers: Binary buffers (unused).
"""
if not isinstance(content, dict):
return
msg_type = content.get("type")
if msg_type == "geolibre:result":
slot = self._pending.get(content.get("requestId"))
if slot is None:
# A reply for a request that already timed out / was cleaned up.
return
slot["ok"] = bool(content.get("ok"))
slot["value"] = content.get("value")
slot["error"] = content.get("error")
slot["done"] = True
elif msg_type == "geolibre:event":
self._dispatch_event(content.get("event"), content.get("payload"))
def _dispatch_event(self, event: Any, payload: Any) -> None:
"""Invoke every callback registered for an event, isolating failures."""
for handler in list(self._event_handlers.get(event, ())):
try:
handler(payload)
except Exception as exc: # noqa: BLE001 - never let one callback kill the bus
warnings.warn(
f"GeoLibre event handler for {event!r} raised: {exc}",
stacklevel=2,
)
@staticmethod
def _wait_for_result(slot: dict[str, Any], method: str, timeout: float) -> None:
"""Block the kernel until a result slot resolves or the timeout elapses.
Jupyter comms are asynchronous, so the kernel must keep processing
incoming messages while the calling cell blocks. ``jupyter_ui_poll``
pumps the kernel's event loop re-entrantly (handling the ipykernel
version differences) so the ``on_msg`` reply lands and fills the slot.
Args:
slot: The pending request slot, resolved in place by ``_on_custom_msg``.
method: Command name, for error messages.
timeout: Seconds to wait before giving up.
Raises:
TimeoutError: If no reply arrives within ``timeout`` seconds.
RuntimeError: If ``jupyter_ui_poll`` is not installed.
"""
try:
from jupyter_ui_poll import ui_events
except ImportError as exc:
raise RuntimeError(
"Interactive GeoLibre queries require the 'jupyter_ui_poll' "
"package. Install it with `pip install jupyter_ui_poll`."
) from exc
deadline = time.monotonic() + timeout
def _check_deadline() -> None:
if time.monotonic() > deadline:
raise TimeoutError(
f"GeoLibre command {method!r} timed out after {timeout}s. "
"The map must be displayed and loaded before it can "
"answer; show the map, then retry or pass a larger "
"timeout=."
)
with ui_events() as poll:
while not slot["done"]:
# Check before and after pumping: a slow poll() with a large event
# backlog could otherwise overrun a very small timeout.
_check_deadline()
poll(10)
if slot["done"]:
break
_check_deadline()
# 20 Hz: imperceptible latency, far less CPU than a 100 Hz spin
# (jupyter_ui_poll already pumps 10 kernel events per iteration).
time.sleep(0.05)
def request(
self,
method: str,
params: dict[str, Any] | None = None,
*,
timeout: float = 10.0,
) -> Any:
"""Send a command to the running app and block for its reply.
This is the low-level primitive behind the query/processing methods; call
it directly to reach a command without a dedicated wrapper.
Args:
method: The command name (e.g. ``"getCenter"``).
params: Command parameters.
timeout: Seconds to wait for the reply.
Returns:
The command's result value.
Raises:
TimeoutError: If the app does not reply in time.
RuntimeError: If the app reports the command failed.
"""
request_id = uuid.uuid4().hex
slot: dict[str, Any] = {
"done": False,
"ok": False,
"value": None,
"error": None,
}
try:
# Register and send inside the try so a failing send() still cleans
# up the slot in finally.
self._pending[request_id] = slot
self.send(
{
"type": "geolibre:command",
"requestId": request_id,
"method": method,
"params": params or {},
}
)
self._wait_for_result(slot, method, timeout)
finally:
self._pending.pop(request_id, None)
if not slot["ok"]:
raise RuntimeError(slot["error"] or f"GeoLibre command {method!r} failed")
return slot["value"]
def on(self, event: str, callback: Callable[[Any], None]) -> Callable[[], None]:
"""Register a callback for an app event.
Events are delivered when the map is displayed and the user interacts
with it. The known events are ``"click"`` (payload
``{"lngLat": [lng, lat], "features": [...]}``), ``"selection-change"``
(``{"layerId", "featureId"}``), and ``"layer-change"``
(``{"layerIds": [...]}``).
Args:
event: The event name.
callback: Called with the event payload.
Returns:
A function that unregisters this callback.
"""
self._event_handlers.setdefault(event, []).append(callback)
def _off() -> None:
handlers = self._event_handlers.get(event)
if handlers and callback in handlers:
handlers.remove(callback)
return _off
def on_click(self, callback: Callable[[Any], None]) -> Callable[[], None]:
"""Register a callback fired when the user clicks the map."""
return self.on("click", callback)
def on_selection_change(self, callback: Callable[[Any], None]) -> Callable[[], None]:
"""Register a callback fired when the selected layer/feature changes."""
return self.on("selection-change", callback)
def on_layer_change(self, callback: Callable[[Any], None]) -> Callable[[], None]:
"""Register a callback fired when layers are added or removed."""
return self.on("layer-change", callback)
# -- live queries / view --------------------------------------------
def get_view(self, *, timeout: float = 10.0) -> dict[str, Any]:
"""Return the live camera ``{center, zoom, bearing, pitch, bbox}``."""
return self.request("getView", timeout=timeout)
def get_center(self, *, timeout: float = 10.0) -> list[float]:
"""Return the live map center as ``[lng, lat]``."""
return self.request("getCenter", timeout=timeout)
def get_bounds(self, *, timeout: float = 10.0) -> list[float]:
"""Return the live viewport bounds as ``[west, south, east, north]``."""
return self.request("getBounds", timeout=timeout)
def fly_to(
self,
lng: float | None = None,
lat: float | None = None,
*,
zoom: float | None = None,
bearing: float | None = None,
pitch: float | None = None,
duration: float | None = None,
timeout: float = 10.0,
) -> None:
"""Animate the camera. Only the provided fields change.
Args:
lng: Target longitude (pass with ``lat`` to recenter).
lat: Target latitude.
zoom: Target zoom level.
bearing: Target bearing in degrees.
pitch: Target pitch in degrees.
duration: Animation duration in milliseconds.
timeout: Seconds to wait for acknowledgement.
"""
params: dict[str, Any] = {}
if lng is not None and lat is not None:
params["center"] = [float(lng), float(lat)]
if zoom is not None:
params["zoom"] = float(zoom)
if bearing is not None:
params["bearing"] = float(bearing)
if pitch is not None:
params["pitch"] = float(pitch)
if duration is not None:
params["duration"] = float(duration)
self.request("flyTo", params, timeout=timeout)
def fit_bounds(
self,
bounds: list[float] | tuple[float, float, float, float],
*,
timeout: float = 10.0,
) -> None:
"""Fit the camera to ``[west, south, east, north]``."""
values = [float(b) for b in bounds]
if len(values) != 4:
raise ValueError("bounds must contain [west, south, east, north]")
if not all(math.isfinite(value) for value in values):
raise ValueError("bounds must contain finite numbers")
west, south, east, north = values
if west > east or south > north:
raise ValueError("bounds must satisfy west <= east and south <= north")
self.request("fitBounds", {"bounds": values}, timeout=timeout)
def zoom_to_bounds(
self,
bounds: list[float] | tuple[float, float, float, float],
*,
timeout: float = 10.0,
) -> None:
"""Fit the map to bounds (leafmap-style alias of :meth:`fit_bounds`)."""
self.fit_bounds(bounds, timeout=timeout)
def zoom_to_layer(self, layer: str | Layer, *, timeout: float = 10.0) -> None:
"""Fit the map to a layer, addressed by id, name, or layer handle."""
resolved = self._resolve_layer(layer)
self.request("zoomToLayer", {"layerId": resolved.id}, timeout=timeout)
def identify(
self,
lng: float,
lat: float,
*,
layer_id: str | None = None,
timeout: float = 10.0,
) -> list[dict[str, Any]]:
"""Query rendered features at a geographic point (like clicking it).
Args:
lng: Longitude of the query point.
lat: Latitude of the query point.
layer_id: Restrict the query to one layer; omit to query all layers.
timeout: Seconds to wait for the reply.
Returns:
One ``{"layerId", "featureId", "properties", "geometry"}`` dict per
matched feature, topmost first.
"""
params: dict[str, Any] = {"lngLat": [float(lng), float(lat)]}
if layer_id is not None:
params["layerId"] = layer_id
return self.request("identify", params, timeout=timeout)
def get_features(self, layer_id: str, *, timeout: float = 10.0) -> list[Feature]:
"""Return a layer's features as :class:`Feature` (GeoJSON) objects.
Reads the live store, so features added or edited in the UI are
included. Only vector (GeoJSON) layers carry inline features; a tiled or
remote layer returns an empty list — use :meth:`identify` for those.
Args:
layer_id: The layer id.
timeout: Seconds to wait for the reply.
Returns:
A list of :class:`Feature` objects (each also a plain GeoJSON dict).
"""
features = self.request("getLayerFeatures", {"layerId": layer_id}, timeout=timeout)
return [Feature(f) for f in features or []]
@staticmethod
def _features_to_gdf(features: list[Feature]) -> Any:
"""Build an EPSG:4326 GeoDataFrame from GeoJSON features.
Args:
features: The features to wrap (each a GeoJSON Feature mapping).
Returns:
A ``geopandas.GeoDataFrame`` in EPSG:4326.
Raises:
ImportError: If GeoPandas is not installed.
"""
try:
import geopandas
except ImportError as exc:
raise ImportError(
"Returning features as a GeoDataFrame requires GeoPandas. Install "
"it with `pip install geopandas`, or omit as_gdf=True to get a list "
"of Feature objects instead."
) from exc
# from_features accepts plain GeoJSON mappings (Feature is a dict subclass)
# and yields an empty frame for an empty list, so no special-casing.
return geopandas.GeoDataFrame.from_features(features, crs="EPSG:4326")
def get_selected_features(
self, *, as_gdf: bool = False, timeout: float = 10.0
) -> list[Feature] | Any:
"""Return the features currently selected in the app.
Reads the live selection (the layer/feature highlighted by clicking a
feature in the UI). Selection is a single feature, so the result is a
list of zero or one :class:`Feature`; the list shape leaves room for
future multi-select.
Args:
as_gdf: Return a ``geopandas.GeoDataFrame`` instead of a list of
:class:`Feature` objects (requires GeoPandas).
timeout: Seconds to wait for the reply.
Returns:
A list of :class:`Feature` objects, or a ``GeoDataFrame`` when
``as_gdf`` is true.
Note:
Only features in vector (GeoJSON) layers can be read back. A feature
selected in a tile or service layer carries no inline geometry, so
the result is an empty list; use :meth:`identify` for those layers.
"""
features = self.request("getSelectedFeatures", timeout=timeout)
feats = [Feature(f) for f in features or []]
return self._features_to_gdf(feats) if as_gdf else feats
def get_drawn_features(
self, *, as_gdf: bool = False, timeout: float = 10.0
) -> list[Feature] | Any:
"""Return the features the user drew with the Geo Editor.
Gathers the features from the app's "Sketches" layer(s) (the regions of
interest drawn with the drawing tools), so a notebook can read back what
was sketched on the map without knowing which layer it landed in.
Args:
as_gdf: Return a ``geopandas.GeoDataFrame`` instead of a list of
:class:`Feature` objects (requires GeoPandas).
timeout: Seconds to wait for the reply.
Returns:
A list of :class:`Feature` objects, or a ``GeoDataFrame`` when
``as_gdf`` is true.
"""
features = self.request("getDrawnFeatures", timeout=timeout)
feats = [Feature(f) for f in features or []]
return self._features_to_gdf(feats) if as_gdf else feats
@property
def user_rois(self) -> dict[str, Any]:
"""The user-drawn regions of interest as a GeoJSON FeatureCollection.
A leafmap-style accessor over :meth:`get_drawn_features`; reading it
round-trips to the running app, so display the map first.
"""
return {
"type": "FeatureCollection",
"features": [dict(f) for f in self.get_drawn_features()],
}
def list_algorithms(self, *, timeout: float = 10.0) -> list[dict[str, Any]]:
"""List the available client-side processing algorithms.
Returns:
One ``{"id", "name", "group", "description", "parameters"}`` dict per
algorithm, suitable for discovering ids and parameters to pass to
:meth:`run_algorithm`.
"""
return self.request("listAlgorithms", timeout=timeout)
def run_algorithm(
self,
algorithm_id: str,
parameters: dict[str, Any] | None = None,
*,
timeout: float = 120.0,
) -> dict[str, Any]:
"""Run a processing algorithm in the app and add its result layers.
Args:
algorithm_id: An id from :meth:`list_algorithms` (e.g. ``"buffer"``).
parameters: The algorithm's parameters (see its ``parameters`` from
:meth:`list_algorithms`). Layer parameters take a layer id.
timeout: Seconds to wait; raise this for large inputs.
Returns:
``{"logs": [...], "resultLayerIds": [...]}`` — the algorithm's log
lines and the ids of any layers it added to the map.
"""
return self.request(
"runAlgorithm",
{"id": algorithm_id, "params": parameters or {}},
timeout=timeout,
)
def to_image(self, path: str | None = None, *, timeout: float = 30.0) -> bytes | None:
"""Capture the current map view as a PNG.
Args:
path: If given, write the PNG here (parent dirs are created) and
return ``None``. Otherwise return the PNG bytes.
timeout: Seconds to wait for the capture.
Returns:
The PNG bytes, or ``None`` when written to ``path``.
"""
data_url = self.request("toImage", timeout=timeout)
_, sep, encoded = str(data_url).partition(",")
if not sep:
raise ValueError(f"toImage returned an unexpected value: {data_url!r}")
png = base64.b64decode(encoded)
if path is not None:
out = pathlib.Path(path).expanduser()
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(png)
return None
return png
def to_html(
self,
path: str | None = None,
*,
title: str = "GeoLibre Map",
width: str = "100%",
height: str | None = None,
app_url: str | None = None,
) -> str | None:
"""Export the current map as a standalone HTML page.
The page embeds the GeoLibre app in an ``<iframe>`` and injects the
current project into it over the same ``postMessage`` bridge the widget
uses, so it renders the map exactly as configured here. Unlike
:meth:`to_image` this needs no running kernel to view; by default it
loads the hosted GeoLibre app over the network so the file stays
portable.
Args:
path: If given, write the HTML here (parent dirs are created) and
return ``None``. Otherwise return the HTML string.
title: The exported page's ``<title>``.
width: CSS width of the embedded map (e.g. ``"100%"`` or ``"800px"``).
height: CSS height of the embedded map; defaults to this map's
:attr:`height`.
app_url: Base URL of the GeoLibre app to embed. Defaults to the
hosted viewer so the export is portable. Pass a self-hosted
deployment URL to pin a specific version, or this map's live
``_app_url`` to embed the session-bound localhost bundle.
Returns:
The HTML string, or ``None`` when written to ``path``.
Note:
Layers backed by kernel-side local files (e.g. a local GeoTIFF added
via :meth:`add_cog`) are served only for this kernel session, so the
exported page cannot reach them once the kernel stops. Use hosted
URLs or tile sources for a fully self-contained export.
"""
html = render_project_html(
self.project,
title=title,
width=width,
height=height or self.height,
app_url=app_url,
)
if path is not None:
out = pathlib.Path(path).expanduser()
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(html, encoding="utf-8")
return None
return html
# -- layer object model ---------------------------------------------
@property
def layers(self) -> list[Layer]:
"""The current layers as :class:`Layer` objects, in draw order."""
return [
Layer(self, layer["id"])
for layer in self.project.get("layers", [])
if isinstance(layer, dict) and "id" in layer
]
@property
def layer_names(self) -> list[str]:
"""Return layer display names in map order."""
return [str(layer.name) for layer in self.layers]
def get_layer(self, layer_id: str) -> Layer:
"""Return a :class:`Layer` handle for ``layer_id``.
Raises:
ValueError: If no layer with that id exists.
"""
for layer in self.project.get("layers", []):
if isinstance(layer, dict) and layer.get("id") == layer_id:
return Layer(self, layer_id)
raise ValueError(f"No layer with id {layer_id!r}")
def find_layer(self, name: str) -> Layer | None:
"""Return the first layer named ``name``, or ``None`` when absent."""
return next((layer for layer in self.layers if layer.name == name), None)
def find_layer_index(self, name: str) -> int:
"""Return the index of the first layer named ``name``, or ``-1``."""
return next((i for i, layer in enumerate(self.layers) if layer.name == name), -1)
def _resolve_layer(self, layer: str | Layer) -> Layer:
"""Resolve a layer handle, id, or display name to a live layer."""
if isinstance(layer, Layer):
if layer._map is not self:
raise ValueError("Layer belongs to a different map")
# Access verifies that a stale handle has not been removed.
layer._layer()
return layer
# Share the authoring resolver so scripting and the MCP tools agree on
# what a reference means: an id wins outright, then an exact name, then a
# case-insensitive one, and a name several layers share is an error rather
# than an arbitrary pick. `find_layer` returns the first name match by
# design (leafmap compatibility), so it is not the resolver for mutations.
return Layer(self, str(_authoring.find_layer(self.project, str(layer))["id"]))
def set_layer_visibility(self, layer: str | Layer, visible: bool = True) -> None:
"""Show or hide a layer addressed by id, name, or layer handle."""
self._resolve_layer(layer).visible = visible
def set_layer_opacity(self, layer: str | Layer, opacity: float) -> None:
"""Set a layer's opacity in ``[0, 1]``."""
self._resolve_layer(layer).opacity = opacity
def rename_layer(self, layer: str | Layer, name: str) -> None:
"""Rename a layer addressed by id, name, or handle.
Args:
layer: The layer to rename, by id, name, or handle.
name: The new display name, surrounding whitespace stripped.
Raises:
ValueError: If ``name`` is blank or the reserved basemap pseudo-id.
"""
handle = self._resolve_layer(layer)
clean = self._clean_layer_name(name)
self._update_project(lambda p: _authoring.update_layer(p, handle.id, name=clean))
@staticmethod
def _clean_layer_name(name: str) -> str:
"""Strip a display name and refuse a blank one.
`authoring.update_layer` guards only the reserved basemap pseudo-id, so
emptiness is checked here, matching the `name` setter. A layer named ""
or " " renders as a blank row that cannot be referenced back by name.
"""
clean = str(name).strip()