|
| 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 | +""" |
0 commit comments