ipyd3geo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ipyd3geo/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .widget import Map, Projection, GeoJSON, VectorStyle
2
+
3
+ __all__ = ["Map", "Projection", "GeoJSON", "VectorStyle"]
4
+
ipyd3geo/py.typed ADDED
File without changes
@@ -0,0 +1,156 @@
1
+ import * as d3 from "https://esm.sh/d3-geo@3.1.1";
2
+ import { zoom } from "https://esm.sh/d3-zoom@3.0.0";
3
+ import { select } from "https://esm.sh/d3-selection@3.0.0";
4
+
5
+ async function render({ model, el }) {
6
+
7
+ if (model.get("hamburger")) {
8
+ const menuButton = document.createElement("button");
9
+ menuButton.textContent = "☰";
10
+ menuButton.style.display = "block";
11
+ el.appendChild(menuButton);
12
+ const menuContent = document.createElement("div");
13
+ menuContent.style.display = "none";
14
+ menuContent.style.position = "absolute";
15
+ menuContent.style.color = "black";
16
+ menuContent.style.cursor = "pointer";
17
+ menuContent.style.backgroundColor = "lightgray";
18
+ menuContent.style.padding = "5px";
19
+ menuContent.style.border = "1px solid black";
20
+
21
+ const exportSvgButton = document.createElement("div");
22
+ exportSvgButton.textContent = "Export SVG";
23
+ exportSvgButton.addEventListener('click', () => {
24
+ const svgString = new XMLSerializer().serializeToString(svg);
25
+ const blob = new Blob([svgString], { type: "image/svg+xml" });
26
+ const url = URL.createObjectURL(blob);
27
+ const a = document.createElement("a");
28
+ a.href = url;
29
+ a.download = "map.svg";
30
+ a.click();
31
+ URL.revokeObjectURL(url);
32
+ });
33
+ menuContent.appendChild(exportSvgButton);
34
+
35
+ //TODO this is the wrong approach to toggling graticule visibility, bc it doesn't toggle on if it didn't start visible.
36
+ const graticuleButton = document.createElement("div");
37
+ graticuleButton.textContent = `Toggle Graticule${model.get("graticule") ? " ✓" : ""}`;
38
+ graticuleButton.addEventListener('click', () => {
39
+ if (graticule) {
40
+ const isShown = graticule.style.display === 'none';
41
+ graticule.style.display = isShown ? 'block' : 'none';
42
+ model.set("graticule", isShown);
43
+ model.save_changes();
44
+ graticuleButton.textContent = `Toggle Graticule${isShown ? ' ✓' : ''}`;
45
+ }
46
+ });
47
+ menuContent.appendChild(graticuleButton);
48
+
49
+ el.appendChild(menuContent);
50
+
51
+ menuButton.addEventListener('click', () => {
52
+ menuContent.style.display = menuContent.style.display === 'none' ? 'block' : 'none';
53
+ });
54
+ }
55
+
56
+ const countries = await fetch(
57
+ "https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector/geojson/ne_110m_admin_0_countries.geojson"
58
+ ).then((res) => res.json());
59
+
60
+ const projection = d3[model.get("projection")]();
61
+ projection.rotate(model.get("projection_rotate"));
62
+ const center = model.get("projection_center");
63
+ if (center) projection.center(center);
64
+ const precision = model.get("projection_precision");
65
+ if (precision != null) projection.precision(precision);
66
+ const parallels = model.get("projection_parallels");
67
+ if (parallels) projection.parallels(parallels);
68
+ const path = d3.geoPath(projection);
69
+ const baseScale = projection.scale();
70
+ const baseTranslate = projection.translate();
71
+ const basemap = model.get("basemap");
72
+
73
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
74
+ svg.setAttribute("viewBox", "0 0 960 500");
75
+ svg.setAttribute("width", "960");
76
+ svg.setAttribute("height", "500");
77
+ const sphere = document.createElementNS("http://www.w3.org/2000/svg", "path");
78
+ sphere.setAttribute("fill", "none");
79
+ sphere.setAttribute("stroke", model.get("border") ? "black" : "none");
80
+ svg.appendChild(sphere);
81
+
82
+ let naturalEarth = null;
83
+ if (basemap === "naturalearth") {
84
+ naturalEarth = document.createElementNS("http://www.w3.org/2000/svg", "path");
85
+ naturalEarth.setAttribute("fill", "none");
86
+ naturalEarth.setAttribute("stroke", "black");
87
+ svg.appendChild(naturalEarth);
88
+ }
89
+
90
+ const layerGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
91
+ svg.appendChild(layerGroup);
92
+ function drawLayers() {
93
+ layerGroup.replaceChildren();
94
+ for (const layerData of model.get("layers")) {
95
+ for (const feature of layerData.features) {
96
+ const style = feature.properties.__ipyd3geo_style__ || {};
97
+ const layer = document.createElementNS("http://www.w3.org/2000/svg", "path");
98
+ layer.setAttribute("d", path(feature));
99
+ layer.setAttribute("fill", style.fill_color || "none");
100
+ layer.setAttribute("stroke", style.stroke_color || "#000000");
101
+ layer.setAttribute("stroke-width", style.weight || 3);
102
+ layer.setAttribute("stroke-opacity", style.stroke_opacity || 1.0);
103
+ layer.setAttribute("fill-opacity", style.fill_opacity || 0.2);
104
+ layerGroup.appendChild(layer);
105
+ }
106
+ }
107
+ }
108
+ model.on("change:layers", drawLayers);
109
+
110
+ let graticule = null;
111
+ if (model.get("graticule")) {
112
+ graticule = document.createElementNS("http://www.w3.org/2000/svg", "path");
113
+ graticule.setAttribute("fill", "none");
114
+ graticule.setAttribute("stroke", "lightgray");
115
+ graticule.setAttribute("opacity", "0.5");
116
+ svg.appendChild(graticule);
117
+ }
118
+
119
+ function redraw() {
120
+ sphere.setAttribute("d", path({ type: "Sphere" }));
121
+ if (naturalEarth) naturalEarth.setAttribute("d", path(countries));
122
+ if (graticule) graticule.setAttribute("d", path(d3.geoGraticule()()));
123
+ drawLayers();
124
+ }
125
+ redraw();
126
+
127
+ const dynamic = model.get("dynamic");
128
+ if (dynamic !== "none") {
129
+ let prev = { x: 0, y: 0 };
130
+ const zoomBehavior = zoom().on("zoom", (event) => {
131
+ const t = event.transform;
132
+ projection.scale(baseScale * t.k);
133
+ const isDrag = /^(mouse|touch)move$/.test(event.sourceEvent?.type);
134
+ if (dynamic === "rotate") {
135
+ if (isDrag) {
136
+ const [lambda, phi, gamma] = projection.rotate();
137
+ const degPerPixel = 180 / (Math.PI * projection.scale());
138
+ projection.rotate([
139
+ lambda + (t.x - prev.x) * degPerPixel,
140
+ phi - (t.y - prev.y) * degPerPixel,
141
+ gamma,
142
+ ]);
143
+ }
144
+ } else {
145
+ projection.translate([t.x + baseTranslate[0] * t.k, t.y + baseTranslate[1] * t.k]);
146
+ }
147
+ prev = { x: t.x, y: t.y };
148
+ redraw();
149
+ });
150
+ select(svg).call(zoomBehavior);
151
+ }
152
+
153
+ el.appendChild(svg);
154
+ }
155
+
156
+ export default { render };
ipyd3geo/widget.py ADDED
@@ -0,0 +1,192 @@
1
+ import inspect
2
+ import pathlib
3
+
4
+ import anywidget
5
+ import traitlets
6
+
7
+ import json
8
+
9
+ class Map(anywidget.AnyWidget):
10
+ """Defines a D3-Geo backed map widget for Jupyter notebooks.
11
+
12
+ Args:
13
+ center (tuple): center of the map (longitude, latitude). **Not implemented yet.**
14
+ zoom (float): The zoom level of the map. **Not implemented yet.**
15
+ projection (Projection or str): The projection to use for the map. If a string is provided, it will be used to create a Projection object. Defaults to "geoEqualEarth".
16
+ graticule (bool): Whether to show the graticule.
17
+ border (bool): Whether to draw a border frame around the map.
18
+ basemap (str): The basemap to use. **Not implemented yet. Currently defaults to Natural Earth small scale borders.**
19
+ hamburger (bool): Whether to show the hamburger menu. **Not implemented yet.**
20
+ dynamic (str or bool): Whether the map is dynamically draggable. Value can be True, False, "pan", or "rotate". If True, the map will be draggable, and choose a default mode. If False, the map will not be draggable. If "pan", the map will pan post-projection. If "rotate", the map will be rotatable pre-projection.
21
+ """
22
+
23
+ _esm = pathlib.Path(__file__).parent / "static" / "widget.js"
24
+
25
+ projection = traitlets.Unicode("geoEqualEarth").tag(sync=True)
26
+ border = traitlets.Bool(True).tag(sync=True)
27
+ graticule = traitlets.Bool(False).tag(sync=True)
28
+ projection_rotate = traitlets.Tuple(traitlets.Float(), traitlets.Float(), traitlets.Float(), default_value=(0, 0, 0)).tag(sync=True)
29
+ projection_center = traitlets.Tuple(traitlets.Float(), traitlets.Float(), allow_none=True, default_value=None).tag(sync=True)
30
+ projection_precision = traitlets.Float(allow_none=True, default_value=None).tag(sync=True)
31
+ projection_parallels = traitlets.Tuple(traitlets.Float(), traitlets.Float(), allow_none=True, default_value=None).tag(sync=True)
32
+ layers = traitlets.List().tag(sync=True)
33
+ dynamic = traitlets.Unicode("pan").tag(sync=True)
34
+ basemap = traitlets.Unicode("").tag(sync=True)
35
+ hamburger = traitlets.Bool(True).tag(sync=True)
36
+
37
+ class Export:
38
+ def __init__(self, map):
39
+ self._map = map
40
+
41
+ def svg(self, path):
42
+ raise NotImplementedError
43
+
44
+ def __init__(self, center=(0.0, 0.0), zoom=1.0, projection=None, graticule=False, border=True, basemap=False, hamburger=True, dynamic=True):
45
+ if center != (0.0, 0.0):
46
+ raise NotImplementedError
47
+ if zoom != 1.0:
48
+ raise NotImplementedError
49
+ if projection is None:
50
+ projection = Projection()
51
+ elif isinstance(projection, str):
52
+ projection = Projection(projection)
53
+ super().__init__()
54
+ self.projection = projection.name
55
+ self.projection_rotate = projection.rotate
56
+ self.projection_center = projection.center
57
+ self.projection_precision = projection.precision
58
+ self.projection_parallels = projection.parallels
59
+ self.border = border
60
+ self.graticule = graticule
61
+ if basemap == "naturalearth" or basemap == True:
62
+ self.basemap = "naturalearth"
63
+ elif basemap == False:
64
+ self.basemap = ""
65
+ else:
66
+ raise ValueError(f"basemap must be 'naturalearth', True, or False")
67
+ self.hamburger = hamburger
68
+ if dynamic is True or dynamic is False:
69
+ self.dynamic = "pan" if dynamic else "none" #TODO: figure out how to make it decide mode based on whether or not the projection is globe-like
70
+ elif dynamic == "pan":
71
+ self.dynamic = "pan"
72
+ elif dynamic == "rotate":
73
+ self.dynamic = "rotate"
74
+ else:
75
+ raise ValueError(f"dynamic must be True, False, 'pan', or 'rotate'")
76
+ self.export = Map.Export(self)
77
+
78
+ def add(self, layer):
79
+ """Add a layer to the map. Only GeoJSON layers are supported so far."""
80
+ self.layers = [*self.layers, layer.data]
81
+
82
+ def remove(self, layer):
83
+ """Remove a layer from the map. **Not implemented yet.**"""
84
+ raise NotImplementedError
85
+
86
+ Map.__signature__ = inspect.Signature(list(inspect.signature(Map.__init__).parameters.values())[1:])
87
+
88
+ class Projection:
89
+ """Defines a D3-Geo projection for the map widget.
90
+
91
+ Args:
92
+ name (str): The name of the projection. Defaults to "geoEqualEarth". See `d3-geo geoProjection docs <https://d3js.org/d3-geo/projection#geoProjection>`_ for options.
93
+ rotate (tuple): The rotation of the projection.
94
+ center (tuple): The center of the projection.
95
+ precision (float): The precision of the projection.
96
+ parallels (tuple): The parallels of the projection. Only applicable for conic projections.
97
+ """
98
+ def __init__(self, name="geoEqualEarth", rotate=(0, 0, 0), center=None, precision=None, parallels=None):
99
+ self.name = name
100
+ self.rotate = tuple(rotate)
101
+ self.center = center
102
+ self.precision = precision
103
+ self.parallels = parallels
104
+
105
+ class VectorStyle:
106
+
107
+ def __init__(self, stroke_color="#000000", fill_color="#ffffff", weight=3, stroke_opacity=1.0, fill_opacity=1.0):
108
+ self.stroke_color = stroke_color
109
+ self.fill_color = fill_color
110
+ self.weight = weight
111
+ self.stroke_opacity = stroke_opacity
112
+ self.fill_opacity = fill_opacity
113
+
114
+ def resolve(self, properties):
115
+ """Return the concrete style for one feature. Callables receive the feature's properties."""
116
+ return {
117
+ attribute: value(properties) if callable(value) else value
118
+ for attribute, value in vars(self).items()
119
+ }
120
+
121
+ class Layer:
122
+ """Base class for all layers in the map widget.
123
+
124
+ Args:
125
+ name (str): The name of the layer.
126
+ """
127
+ def __init__(self, name=""):
128
+ self.name = name
129
+
130
+ class Point(Layer):
131
+ """A point layer on the map. **Not implemented yet.**"""
132
+ def __init__(self, location, color="#3388ff", name=""):
133
+ raise NotImplementedError
134
+
135
+ class Polygon(Layer):
136
+ """A polygon layer on the map. **Not implemented yet.**"""
137
+ def __init__(self, locations, color="#3388ff", fill_color="#3388ff", name=""):
138
+ raise NotImplementedError
139
+
140
+ class Line(Layer):
141
+ """A line layer on the map. **Not implemented yet.**"""
142
+ def __init__(self, locations, color="#3388ff", name=""):
143
+ raise NotImplementedError
144
+
145
+ class GeoJSON(Layer):
146
+ """A GeoJSON layer on the map.
147
+
148
+ Args:
149
+ data (dict, str, or pathlib.Path): GeoJSON as a dict, a JSON string, or a path to a GeoJSON file.
150
+ name (str): The name of the layer.
151
+ style (VectorStyle): The style of the layer.
152
+ """
153
+ def __init__(self, data, name="",style=None):
154
+ super().__init__(name)
155
+ if isinstance(data, pathlib.Path):
156
+ with open(data, "r") as f:
157
+ data = json.load(f)
158
+ elif type(data) is str:
159
+ if not data.strip().startswith("{"):
160
+ if pathlib.Path(data).exists():
161
+ with open(data, "r") as f:
162
+ data = json.load(f)
163
+ else:
164
+ raise ValueError(f"data string is not a valid JSON or file path: {data}")
165
+ else:
166
+ data = json.loads(data)
167
+ elif not isinstance(data, dict):
168
+ raise TypeError("data must be a dict, str, or pathlib.Path")
169
+ style = style or VectorStyle()
170
+ features = []
171
+ for f in data.get("features", []):
172
+ props = f.get("properties") or {}
173
+ features.append({**f, "properties": {**props, "__ipyd3geo_style__": style.resolve(props)}})
174
+ self.data = {**data, "features": features}
175
+
176
+ class GeoData(Layer):
177
+ """A GeoDataFrame (such as from geopandas) layer on the map. **Not implemented yet.**"""
178
+ def __init__(self, data, name=""):
179
+ raise NotImplementedError
180
+
181
+ class Raster(Layer):
182
+ """A local raster layer on the map. **Not implemented yet.**"""
183
+ def __init__(self, data, name=""):
184
+ raise NotImplementedError
185
+
186
+ class TileService(Layer):
187
+ """Raster tiles from a service. **Not implemented yet.**"""
188
+ def __init__(self, url, name=""):
189
+ raise NotImplementedError
190
+
191
+ if __name__ == "__main__":
192
+ pass
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.5
2
+ Name: ipyd3geo
3
+ Version: 0.1.0
4
+ Summary: An interactive map widget for Jupyter that uses D3 Geo as its renderer.
5
+ Project-URL: Repository, https://github.com/geojupyter/prototype-ipyd3geo
6
+ Project-URL: Issues, https://github.com/geojupyter/prototype-ipyd3geo/issues
7
+ Author-email: Benjamin Szeghy <benjamin.szeghy@berkeley.edu>
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Jupyter
12
+ Classifier: Framework :: Jupyter :: JupyterLab
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: GIS
16
+ Classifier: Topic :: Scientific/Engineering :: Visualization
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: anywidget>=0.11.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # ipyd3geo
22
+
23
+ A Jupyter widget for D3-based geospatial visualization.
24
+
25
+ It is designed to work similarly to other map renderer widgets, but uses D3-Geo as its renderer.
26
+
27
+ Using D3-Geo over other map renderers has a number of key benefits:
28
+ - Renders directly to an SVG. This package acts as a bridge between Jupyter-based geospatial tools, and cartography in design software.
29
+ - Maps can be arbitrarily styled with CSS or D3
30
+ - Native vector output means no pixelation, so rendered maps are infinitely scalable.
31
+ - Native best in class projection support. There are few if any conventionally defined projections that D3-Geo does not support (including certain very obscure ones)
32
+
33
+ However, there are a few tradeoffs:
34
+ - Due to its lack of virtualization, it is far less performant with large datasets than alternatives.
35
+ - It is intentionally client-side only, so it does not have any tiling server functionality
36
+
37
+
38
+ </br></br>
39
+ > [!WARNING]
40
+ > For now this project is experimental. We cannot guarantee further development or ongoing maintenance.
41
+ >
42
+ > If you or your organization are interested in supporting this project's development, either financially or in kind, please email Benny@BennySzeghy.com
43
+
44
+ ## Installation
45
+
46
+ ```sh
47
+ pip install ipyd3geo
48
+ ```
49
+ or
50
+ ```sh
51
+ uv add ipyd3geo
52
+ ```
53
+
54
+ ## Development
55
+
56
+ ```sh
57
+ uv sync
58
+ pnpm install ## No build step is actually needed yet. This will be added in the future
59
+ ```
60
+
61
+ See [examples/example.ipynb](examples/example.ipynb) for usage.
@@ -0,0 +1,8 @@
1
+ ipyd3geo/__init__.py,sha256=7QMFqtOzNXM9i_myefxhIk1o1_3TtN-ktA0hPIcF6Rk,118
2
+ ipyd3geo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ ipyd3geo/widget.py,sha256=bxLgFmudMY9V78jKAS6JVJwGbyBOn6JfxBxNtOVCo78,8223
4
+ ipyd3geo/static/widget.js,sha256=UoEssn9A5uaF8e3H2CwvkEuKyIuDC2v6LJLA1nCpuvo,6049
5
+ ipyd3geo-0.1.0.dist-info/METADATA,sha256=-I3ta5_rsLmZohj5nmEQypFZHE6b59cYOjyDcYuzhy4,2285
6
+ ipyd3geo-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ ipyd3geo-0.1.0.dist-info/licenses/LICENSE,sha256=JH0Ek_-sgQDxVCJYeuGrQ521uTRXxpZQEVOXprvwyng,1529
8
+ ipyd3geo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, ipyd3geo contributors
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.