Skip to content

Commit 2938e27

Browse files
authored
feat(kepler-jupyter): restore save_to_html() using kepler.gl UMD bundle from CDN (#3382)
1 parent 595bd90 commit 2938e27

16 files changed

Lines changed: 868 additions & 48 deletions

File tree

.github/workflows/build-publish-pypi.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ jobs:
1919
steps:
2020
- uses: actions/checkout@v4
2121

22+
- name: Verify CDN version matches package.json
23+
run: |
24+
MAJOR=$(node -p "require('./package.json').version.split('.')[0]")
25+
CDN=$(grep -Po '(?<=DEFAULT_KEPLER_GL_CDN_VERSION = ")[^"]+' bindings/python/keplergl/_html_export.py)
26+
echo "package.json major: $MAJOR"
27+
echo "Python CDN version: $CDN"
28+
if [ "$MAJOR" != "$CDN" ]; then
29+
echo "::error::DEFAULT_KEPLER_GL_CDN_VERSION ($CDN) in _html_export.py does not match major version ($MAJOR) from package.json. Please update it."
30+
exit 1
31+
fi
32+
2233
- name: Install uv
2334
uses: astral-sh/setup-uv@v4
2435

bindings/python/DEVELOPMENT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ The version in `pyproject.toml` determines what type of release you can publish:
102102
1. **Update the version** in both files:
103103
- `pyproject.toml` (line: `version = "x.x.x"`)
104104
- `keplergl/_version.py` (line: `__version__ = "x.x.x"`)
105+
- On a kepler.gl **major** release, also update `DEFAULT_KEPLER_GL_CDN_VERSION` in `keplergl/_html_export.py` to match the new major version.
105106

106107
2. **Go to GitHub Actions** → "Build and Publish KeplerGL Python Package" workflow
107108

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright contributors to the kepler.gl project
3+
4+
"""Generate standalone HTML export using the kepler.gl UMD bundle from CDN."""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
from typing import Optional
10+
11+
import pandas as pd
12+
import geopandas as gpd
13+
14+
DEFAULT_KEPLER_GL_CDN_VERSION = "3"
15+
16+
17+
def _dataset_to_csv(data) -> Optional[str]:
18+
"""Convert a dataset value to CSV string for kepler.gl processCsvData."""
19+
if isinstance(data, gpd.GeoDataFrame):
20+
import shapely.wkt
21+
22+
if data.crs and data.crs.to_epsg() != 4326:
23+
data = data.to_crs(4326)
24+
df = pd.DataFrame(data)
25+
geom_col = data.geometry.name
26+
df[geom_col] = data.geometry.apply(shapely.wkt.dumps)
27+
return df.to_csv(index=False)
28+
elif isinstance(data, pd.DataFrame):
29+
return data.to_csv(index=False)
30+
elif isinstance(data, str):
31+
return data
32+
else:
33+
return None
34+
35+
36+
def _dataset_to_geojson(data) -> Optional[dict]:
37+
"""Try to convert dataset to GeoJSON dict."""
38+
if isinstance(data, gpd.GeoDataFrame):
39+
if data.crs and data.crs.to_epsg() != 4326:
40+
data = data.to_crs(4326)
41+
return json.loads(data.to_json())
42+
elif isinstance(data, dict):
43+
geojson_types = {
44+
"FeatureCollection", "Feature", "Point", "MultiPoint",
45+
"LineString", "MultiLineString", "Polygon", "MultiPolygon",
46+
"GeometryCollection",
47+
}
48+
if data.get("type") in geojson_types:
49+
return data
50+
elif isinstance(data, str):
51+
try:
52+
parsed = json.loads(data)
53+
if isinstance(parsed, dict) and parsed.get("type") in {
54+
"FeatureCollection", "Feature",
55+
}:
56+
return parsed
57+
except (json.JSONDecodeError, TypeError):
58+
pass
59+
return None
60+
61+
62+
def _serialize_datasets_for_html(data: dict) -> str:
63+
"""Serialize datasets dict into JS code that calls addDataToMap.
64+
65+
Generates a JS snippet that processes each dataset using kepler.gl's
66+
processCsvData or processGeojson, then dispatches addDataToMap.
67+
"""
68+
snippets = []
69+
for name, dataset in data.items():
70+
geojson = _dataset_to_geojson(dataset)
71+
if geojson is not None:
72+
snippets.append(
73+
f" datasets.push({{info: {{id: {json.dumps(name, ensure_ascii=False)}, label: {json.dumps(name, ensure_ascii=False)}}}, "
74+
f"data: keplerGl.processGeojson({json.dumps(geojson, ensure_ascii=False)})}});"
75+
)
76+
else:
77+
csv_str = _dataset_to_csv(dataset)
78+
if csv_str is not None:
79+
snippets.append(
80+
f" datasets.push({{info: {{id: {json.dumps(name, ensure_ascii=False)}, label: {json.dumps(name, ensure_ascii=False)}}}, "
81+
f"data: keplerGl.processCsvData({json.dumps(csv_str, ensure_ascii=False)})}});"
82+
)
83+
return "\n".join(snippets)
84+
85+
86+
def export_map_html(
87+
data: dict,
88+
config: dict,
89+
read_only: bool = False,
90+
center_map: bool = False,
91+
mapbox_token: str = "",
92+
kepler_gl_version: str = DEFAULT_KEPLER_GL_CDN_VERSION,
93+
) -> str:
94+
"""Generate a standalone HTML string that renders a kepler.gl map.
95+
96+
Loads all dependencies from CDN (unpkg). No local JS build required.
97+
"""
98+
dataset_js = _serialize_datasets_for_html(data)
99+
config_json = json.dumps(config, ensure_ascii=False) if config else "{}"
100+
mapbox_token_json = json.dumps(mapbox_token)
101+
read_only_js = "true" if read_only else "false"
102+
center_map_js = "true" if center_map else "false"
103+
104+
return f"""\
105+
<!DOCTYPE html>
106+
<html>
107+
<head>
108+
<meta charset="UTF-8"/>
109+
<title>Kepler.gl embedded map</title>
110+
111+
<!--Uber Font-->
112+
<link rel="stylesheet" href="https://d1a3f4spazzrp4.cloudfront.net/kepler.gl/uber-fonts/4.0.0/superfine.css">
113+
114+
<!--Kepler css-->
115+
<link href="https://unpkg.com/kepler.gl@{kepler_gl_version}/umd/keplergl.min.css" rel="stylesheet">
116+
117+
<!--MapBox css-->
118+
<link href="https://api.tiles.mapbox.com/mapbox-gl-js/v1.1.1/mapbox-gl.css" rel="stylesheet">
119+
<link href="https://unpkg.com/maplibre-gl@^3/dist/maplibre-gl.css" rel="stylesheet">
120+
121+
<!-- Load React/Redux -->
122+
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
123+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
124+
<script src="https://unpkg.com/redux@4.2.1/dist/redux.js" crossorigin></script>
125+
<script src="https://unpkg.com/react-redux@8.1.2/dist/react-redux.min.js" crossorigin></script>
126+
<script src="https://unpkg.com/styled-components@6.1.8/dist/styled-components.min.js" crossorigin></script>
127+
128+
<!-- Load Kepler.gl -->
129+
<script src="https://unpkg.com/kepler.gl@{kepler_gl_version}/umd/keplergl.min.js" crossorigin></script>
130+
131+
<style type="text/css">
132+
body {{margin: 0; padding: 0; overflow: hidden;}}
133+
</style>
134+
135+
<script>
136+
const MAPBOX_TOKEN = {mapbox_token_json};
137+
</script>
138+
</head>
139+
<body>
140+
<div id="app"></div>
141+
142+
<script>
143+
/** STORE **/
144+
const reducers = (function createReducers(redux, keplerGl) {{
145+
return redux.combineReducers({{
146+
keplerGl: keplerGl.keplerGlReducer.initialState({{
147+
uiState: {{
148+
readOnly: {read_only_js},
149+
currentModal: null
150+
}}
151+
}})
152+
}});
153+
}}(Redux, KeplerGl));
154+
155+
const middleWares = (function createMiddlewares(keplerGl) {{
156+
return keplerGl.enhanceReduxMiddleware([]);
157+
}}(KeplerGl));
158+
159+
const enhancers = (function createEnhancers(redux, middles) {{
160+
return redux.applyMiddleware(...middles);
161+
}}(Redux, middleWares));
162+
163+
const store = (function createStore(redux, enhancers) {{
164+
return redux.createStore(reducers, {{}}, redux.compose(enhancers));
165+
}}(Redux, enhancers));
166+
/** END STORE **/
167+
168+
/** COMPONENTS **/
169+
var KeplerElement = (function makeKeplerElement(react, keplerGl, mapboxToken) {{
170+
return function App() {{
171+
var rootElm = react.useRef(null);
172+
var _useState = react.useState({{
173+
width: window.innerWidth,
174+
height: window.innerHeight
175+
}});
176+
var windowDimension = _useState[0];
177+
var setDimension = _useState[1];
178+
react.useEffect(function sideEffect() {{
179+
function handleResize() {{
180+
setDimension({{width: window.innerWidth, height: window.innerHeight}});
181+
}}
182+
window.addEventListener('resize', handleResize);
183+
return function() {{ window.removeEventListener('resize', handleResize); }};
184+
}}, []);
185+
return react.createElement(
186+
'div',
187+
{{style: {{position: 'absolute', left: 0, width: '100vw', height: '100vh'}}}},
188+
react.createElement(keplerGl.KeplerGl, {{
189+
mapboxApiAccessToken: mapboxToken,
190+
id: "map",
191+
width: windowDimension.width,
192+
height: windowDimension.height
193+
}})
194+
);
195+
}};
196+
}}(React, KeplerGl, MAPBOX_TOKEN));
197+
198+
const app = (function createReactReduxProvider(react, reactRedux, KeplerElement) {{
199+
return react.createElement(
200+
reactRedux.Provider,
201+
{{store}},
202+
react.createElement(KeplerElement, null)
203+
);
204+
}}(React, ReactRedux, KeplerElement));
205+
/** END COMPONENTS **/
206+
207+
/** Render **/
208+
(function render(react, reactDOM, app) {{
209+
const container = document.getElementById('app');
210+
const root = reactDOM.createRoot(container);
211+
root.render(app);
212+
}}(React, ReactDOM, app));
213+
</script>
214+
215+
<script>
216+
(function customize(keplerGl, store) {{
217+
var datasets = [];
218+
{dataset_js}
219+
220+
var config = {config_json};
221+
222+
window.setTimeout(function() {{
223+
store.dispatch(keplerGl.addDataToMap({{
224+
datasets: datasets,
225+
config: config,
226+
options: {{
227+
centerMap: {center_map_js}
228+
}}
229+
}}));
230+
}}, 500);
231+
}}(KeplerGl, store));
232+
</script>
233+
</body>
234+
</html>
235+
"""

bindings/python/keplergl/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33

44
"""Version information."""
55

6-
__version__ = "0.4.0rc1"
6+
__version__ = "0.4.0rc2"

bindings/python/keplergl/serializers.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ def _debug(msg):
1717
print(f"[keplergl-serializer] {msg}")
1818

1919

20+
def _arrow_table_to_base64(table: pa.Table) -> str:
21+
"""Serialize an Arrow table to a base64-encoded IPC stream."""
22+
sink = pa.BufferOutputStream()
23+
with pa.ipc.new_stream(sink, table.schema) as writer:
24+
writer.write_table(table)
25+
return base64.b64encode(sink.getvalue().to_pybytes()).decode("utf-8")
26+
27+
2028
def _try_parse_geojson(data: str) -> Optional[dict]:
2129
"""Try to parse a string as GeoJSON. Returns parsed dict if valid GeoJSON, None otherwise."""
2230
import json
@@ -37,10 +45,11 @@ def _try_parse_geojson(data: str) -> Optional[dict]:
3745
def data_to_json(data: dict, widget) -> dict:
3846
"""Serialize Python data for JavaScript consumption."""
3947
_debug(f"data_to_json called with {len(data)} datasets")
48+
use_arrow = getattr(widget, '_use_arrow', False) if widget else False
4049
result = {}
4150
for name, dataset in data.items():
4251
_debug(f" Serializing dataset '{name}' of type {type(dataset).__name__}")
43-
result[name] = serialize_dataset(dataset, name)
52+
result[name] = serialize_dataset(dataset, name, use_arrow=use_arrow)
4453
_debug(f" Result format: {result[name].get('format')}")
4554
_debug(f"data_to_json result: {list(result.keys())}")
4655
return result
@@ -51,32 +60,34 @@ def data_from_json(data: dict, widget) -> dict:
5160
return data
5261

5362

54-
def serialize_dataset(data: Any, name: str) -> dict:
63+
def serialize_dataset(data: Any, name: str, use_arrow: bool = False) -> dict:
5564
"""Serialize a single dataset."""
5665
if isinstance(data, gpd.GeoDataFrame):
5766
return serialize_geodataframe(data, name)
5867
elif isinstance(data, pd.DataFrame):
59-
return serialize_dataframe(data, name)
68+
return serialize_dataframe(data, name, use_arrow=use_arrow)
6069
elif isinstance(data, str):
61-
# Try to detect if the string is GeoJSON
6270
geojson_data = _try_parse_geojson(data)
6371
if geojson_data is not None:
6472
_debug(f" Detected GeoJSON string for '{name}'")
6573
return {"id": name, "data": geojson_data, "format": "geojson"}
66-
# Otherwise treat as CSV
6774
return {"id": name, "data": data, "format": "csv"}
6875
elif isinstance(data, dict):
6976
return {"id": name, "data": data, "format": "geojson"}
7077
else:
7178
raise ValueError(f"Unsupported data type: {type(data)}")
7279

7380

74-
def serialize_dataframe(df: pd.DataFrame, name: str) -> dict:
75-
"""Serialize DataFrame to JSON format for kepler.gl.
76-
77-
We use JSON format instead of Arrow to avoid version mismatch issues
78-
between the apache-arrow package used here and the one bundled with kepler.gl.
81+
def serialize_dataframe(df: pd.DataFrame, name: str, use_arrow: bool = False) -> dict:
82+
"""Serialize DataFrame for kepler.gl.
83+
84+
When use_arrow=True, serializes as Arrow IPC (base64-encoded) which is more
85+
compact and preserves types better for large numeric datasets.
86+
When use_arrow=False (default), serializes as JSON columns+rows.
7987
"""
88+
if use_arrow:
89+
return _serialize_dataframe_arrow(df, name)
90+
8091
columns = df.columns.tolist()
8192
rows = df.values.tolist()
8293
_debug(f"serialize_dataframe: {len(columns)} columns, {len(rows)} rows")
@@ -92,6 +103,17 @@ def serialize_dataframe(df: pd.DataFrame, name: str) -> dict:
92103
}
93104

94105

106+
def _serialize_dataframe_arrow(df: pd.DataFrame, name: str) -> dict:
107+
"""Serialize DataFrame to Arrow IPC format (base64-encoded)."""
108+
table = pa.Table.from_pandas(df)
109+
_debug(f"_serialize_dataframe_arrow: {table.num_columns} columns, {table.num_rows} rows")
110+
return {
111+
"id": name,
112+
"data": _arrow_table_to_base64(table),
113+
"format": "arrow",
114+
}
115+
116+
95117
def serialize_geodataframe(gdf: gpd.GeoDataFrame, name: str) -> dict:
96118
"""Serialize GeoDataFrame to GeoArrow format."""
97119
import geoarrow.pyarrow as ga
@@ -107,12 +129,8 @@ def serialize_geodataframe(gdf: gpd.GeoDataFrame, name: str) -> dict:
107129
col_names = non_geom_cols + [geom_col]
108130

109131
table = pa.table(dict(zip(col_names, arrays)))
110-
sink = pa.BufferOutputStream()
111-
with pa.ipc.new_stream(sink, table.schema) as writer:
112-
writer.write_table(table)
113-
arrow_bytes = sink.getvalue().to_pybytes()
114132
return {
115133
"id": name,
116-
"data": base64.b64encode(arrow_bytes).decode("utf-8"),
134+
"data": _arrow_table_to_base64(table),
117135
"format": "geoarrow",
118136
}

0 commit comments

Comments
 (0)