gmaprium 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.
gmaprium/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Folium-style Python helpers for Google Maps."""
2
+
3
+ from .elements import Circle, GeoJson, GoogleMapsError, HeatMap, LayerControl, Map, Marker, Polygon, Polyline
4
+ from .streamlit import st_google_map
5
+ from .tiles import GoogleMapType, add_google_tiles, google_tiles_url
6
+
7
+ __all__ = [
8
+ "Circle",
9
+ "GeoJson",
10
+ "GoogleMapType",
11
+ "GoogleMapsError",
12
+ "HeatMap",
13
+ "LayerControl",
14
+ "Map",
15
+ "Marker",
16
+ "Polygon",
17
+ "Polyline",
18
+ "add_google_tiles",
19
+ "google_tiles_url",
20
+ "st_google_map",
21
+ ]
gmaprium/elements.py ADDED
@@ -0,0 +1,692 @@
1
+ """Folium-style map elements for Google Maps output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Iterable, Sequence
9
+
10
+
11
+ Location = Sequence[float]
12
+ _MAP_TYPES = {"roadmap", "satellite", "hybrid", "terrain"}
13
+ _DEMO_MAP_ID = "DEMO_MAP_ID"
14
+
15
+
16
+ class GoogleMapsError(RuntimeError):
17
+ """Raised when a map cannot be rendered with the supplied configuration."""
18
+
19
+
20
+ class Element:
21
+ """Base class for objects that can be added to a map."""
22
+
23
+ def add_to(self, map_obj: "Map") -> "Element":
24
+ map_obj.add_child(self)
25
+ return self
26
+
27
+ def to_spec(self) -> dict[str, Any]:
28
+ raise NotImplementedError
29
+
30
+
31
+ class Map:
32
+ """A Folium-style Google Maps HTML renderer."""
33
+
34
+ def __init__(
35
+ self,
36
+ location: Location,
37
+ zoom_start: int = 10,
38
+ *,
39
+ api_key: str | None = None,
40
+ map_type: str = "roadmap",
41
+ width: str | int = "100%",
42
+ height: str | int = "100%",
43
+ map_id: str | None = None,
44
+ options: dict[str, Any] | None = None,
45
+ ) -> None:
46
+ self.location = _location(location)
47
+ self.zoom_start = zoom_start
48
+ self.api_key = api_key
49
+ if map_type not in _MAP_TYPES:
50
+ supported = ", ".join(sorted(_MAP_TYPES))
51
+ raise ValueError(f"Unsupported map_type {map_type!r}. Expected one of: {supported}.")
52
+ self.map_type = map_type
53
+ self.width = _css_size(width)
54
+ self.height = _css_size(height)
55
+ self.map_id = map_id
56
+ self.options = options or {}
57
+ self.children: list[Element] = []
58
+ self._id = f"fgm_{id(self):x}"
59
+
60
+ def add_child(self, child: Element) -> Element:
61
+ self.children.append(child)
62
+ return child
63
+
64
+ def render_fragment(self) -> str:
65
+ api_key = self._resolve_api_key()
66
+ specs = [child.to_spec() for child in self.children]
67
+ needs_marker = any(spec["type"] == "marker" for spec in specs)
68
+ layer_control = any(spec["type"] == "layer_control" for spec in specs)
69
+ drawable_specs = [spec for spec in specs if spec["type"] != "layer_control"]
70
+ config = {
71
+ "center": {"lat": self.location[0], "lng": self.location[1]},
72
+ "zoom": self.zoom_start,
73
+ "mapTypeId": self.map_type,
74
+ **self.options,
75
+ }
76
+ if self.map_id or needs_marker:
77
+ config["mapId"] = self.map_id or _DEMO_MAP_ID
78
+
79
+ context = {
80
+ "api_key": api_key,
81
+ "callback": f"{self._id}_init",
82
+ "config": config,
83
+ "height": self.height,
84
+ "map_id": self._id,
85
+ "specs": drawable_specs,
86
+ "width": self.width,
87
+ "layer_control": layer_control,
88
+ }
89
+ return _render_fragment(context)
90
+
91
+ def render_html(self) -> str:
92
+ fragment = self.render_fragment()
93
+ return "\n".join(
94
+ [
95
+ "<!doctype html>",
96
+ '<html lang="en">',
97
+ "<head>",
98
+ ' <meta charset="utf-8">',
99
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">',
100
+ " <title>Google Map</title>",
101
+ "</head>",
102
+ "<body>",
103
+ fragment,
104
+ "</body>",
105
+ "</html>",
106
+ ]
107
+ )
108
+
109
+ def save(self, path: str | os.PathLike[str]) -> None:
110
+ Path(path).write_text(self.render_html(), encoding="utf-8")
111
+
112
+ def _repr_html_(self) -> str:
113
+ return self.render_fragment()
114
+
115
+ def _resolve_api_key(self) -> str:
116
+ api_key = self.api_key or os.environ.get("GOOGLE_MAPS_API_KEY")
117
+ if not api_key:
118
+ raise GoogleMapsError("Google Maps API key is required. Pass api_key=... or set GOOGLE_MAPS_API_KEY.")
119
+ return api_key
120
+
121
+
122
+ class Marker(Element):
123
+ def __init__(
124
+ self,
125
+ location: Location,
126
+ *,
127
+ popup: str | None = None,
128
+ tooltip: str | None = None,
129
+ icon: str | None = None,
130
+ draggable: bool = False,
131
+ name: str | None = None,
132
+ ) -> None:
133
+ self.location = _location(location)
134
+ self.popup = popup
135
+ self.tooltip = tooltip
136
+ self.icon = icon
137
+ self.draggable = draggable
138
+ self.name = name
139
+
140
+ def to_spec(self) -> dict[str, Any]:
141
+ return {
142
+ "type": "marker",
143
+ "name": self.name,
144
+ "position": {"lat": self.location[0], "lng": self.location[1]},
145
+ "popup": self.popup,
146
+ "tooltip": self.tooltip,
147
+ "icon": self.icon,
148
+ "draggable": self.draggable,
149
+ }
150
+
151
+
152
+ class Polyline(Element):
153
+ def __init__(
154
+ self,
155
+ locations: Iterable[Location],
156
+ *,
157
+ color: str = "#3388ff",
158
+ weight: int = 3,
159
+ opacity: float = 1.0,
160
+ name: str | None = None,
161
+ ) -> None:
162
+ self.locations = [_lat_lng(location) for location in locations]
163
+ self.color = color
164
+ self.weight = weight
165
+ self.opacity = opacity
166
+ self.name = name
167
+
168
+ def to_spec(self) -> dict[str, Any]:
169
+ return {
170
+ "type": "polyline",
171
+ "name": self.name,
172
+ "path": self.locations,
173
+ "options": {"strokeColor": self.color, "strokeWeight": self.weight, "strokeOpacity": self.opacity},
174
+ }
175
+
176
+
177
+ class Polygon(Element):
178
+ def __init__(
179
+ self,
180
+ locations: Iterable[Location] | Iterable[Iterable[Location]],
181
+ *,
182
+ color: str = "#3388ff",
183
+ fill_color: str | None = None,
184
+ fill_opacity: float = 0.2,
185
+ weight: int = 3,
186
+ name: str | None = None,
187
+ ) -> None:
188
+ self.locations = _polygon_paths(locations)
189
+ self.color = color
190
+ self.fill_color = fill_color or color
191
+ self.fill_opacity = fill_opacity
192
+ self.weight = weight
193
+ self.name = name
194
+
195
+ def to_spec(self) -> dict[str, Any]:
196
+ return {
197
+ "type": "polygon",
198
+ "name": self.name,
199
+ "paths": self.locations,
200
+ "options": {
201
+ "strokeColor": self.color,
202
+ "strokeWeight": self.weight,
203
+ "fillColor": self.fill_color,
204
+ "fillOpacity": self.fill_opacity,
205
+ },
206
+ }
207
+
208
+
209
+ class Circle(Element):
210
+ def __init__(
211
+ self,
212
+ location: Location,
213
+ radius: float,
214
+ *,
215
+ color: str = "#3388ff",
216
+ fill_color: str | None = None,
217
+ fill_opacity: float = 0.2,
218
+ weight: int = 3,
219
+ name: str | None = None,
220
+ ) -> None:
221
+ self.location = _location(location)
222
+ self.radius = radius
223
+ self.color = color
224
+ self.fill_color = fill_color or color
225
+ self.fill_opacity = fill_opacity
226
+ self.weight = weight
227
+ self.name = name
228
+
229
+ def to_spec(self) -> dict[str, Any]:
230
+ return {
231
+ "type": "circle",
232
+ "name": self.name,
233
+ "center": {"lat": self.location[0], "lng": self.location[1]},
234
+ "radius": self.radius,
235
+ "options": {
236
+ "strokeColor": self.color,
237
+ "strokeWeight": self.weight,
238
+ "fillColor": self.fill_color,
239
+ "fillOpacity": self.fill_opacity,
240
+ },
241
+ }
242
+
243
+
244
+ class GeoJson(Element):
245
+ def __init__(self, data: Any, *, name: str | None = None, style_function: Any | None = None) -> None:
246
+ self.data = _geojson_data(data)
247
+ self.name = name
248
+ self.style_function = style_function
249
+
250
+ def to_spec(self) -> dict[str, Any]:
251
+ style = None
252
+ if self.style_function:
253
+ style = self.style_function(_sample_geojson_feature(self.data))
254
+ return {"type": "geojson", "name": self.name, "data": self.data, "style": style}
255
+
256
+
257
+ class HeatMap(Element):
258
+ def __init__(
259
+ self,
260
+ data: Iterable[Any],
261
+ *,
262
+ name: str | None = None,
263
+ radius: int = 25,
264
+ blur: int = 15,
265
+ min_opacity: float = 0.05,
266
+ max_zoom: int | None = 18,
267
+ max_value: float = 1.0,
268
+ gradient: dict[float, str] | None = None,
269
+ opacity: float = 1.0,
270
+ intensity: float = 1.0,
271
+ threshold: float = 0.03,
272
+ scale_radius_with_zoom: bool = False,
273
+ min_radius: int = 6,
274
+ max_radius: int = 240,
275
+ ) -> None:
276
+ self.data = [_heatmap_point(point) for point in data]
277
+ self.name = name
278
+ self.radius = radius
279
+ self.blur = blur
280
+ self.min_opacity = min_opacity
281
+ self.max_zoom = max_zoom
282
+ self.max_value = max_value
283
+ self.gradient = gradient or {0.4: "blue", 0.6: "cyan", 0.7: "lime", 0.8: "yellow", 1.0: "red"}
284
+ self.opacity = opacity
285
+ self.intensity = intensity
286
+ self.threshold = threshold
287
+ self.scale_radius_with_zoom = scale_radius_with_zoom
288
+ self.min_radius = min_radius
289
+ self.max_radius = max_radius
290
+
291
+ def to_spec(self) -> dict[str, Any]:
292
+ return {
293
+ "type": "heatmap",
294
+ "name": self.name,
295
+ "data": self.data,
296
+ "options": {
297
+ "radiusPixels": self.radius,
298
+ "blurPixels": self.blur,
299
+ "minOpacity": self.min_opacity,
300
+ "maxZoom": self.max_zoom,
301
+ "max": self.max_value,
302
+ "gradient": self.gradient,
303
+ "opacity": self.opacity,
304
+ "intensity": self.intensity,
305
+ "threshold": self.threshold,
306
+ "scaleRadiusWithZoom": self.scale_radius_with_zoom,
307
+ "minRadiusPixels": self.min_radius,
308
+ "maxRadiusPixels": self.max_radius,
309
+ },
310
+ }
311
+
312
+
313
+ class LayerControl(Element):
314
+ def to_spec(self) -> dict[str, Any]:
315
+ return {"type": "layer_control"}
316
+
317
+
318
+ def _location(value: Location) -> tuple[float, float]:
319
+ if len(value) != 2:
320
+ raise ValueError("Location must be a [lat, lng] pair.")
321
+ return (float(value[0]), float(value[1]))
322
+
323
+
324
+ def _lat_lng(value: Location) -> dict[str, float]:
325
+ lat, lng = _location(value)
326
+ return {"lat": lat, "lng": lng}
327
+
328
+
329
+ def _css_size(value: str | int) -> str:
330
+ if isinstance(value, int):
331
+ return f"{value}px"
332
+ return value
333
+
334
+
335
+ def _polygon_paths(locations: Iterable[Location] | Iterable[Iterable[Location]]) -> list[Any]:
336
+ items = list(locations)
337
+ if not items:
338
+ return []
339
+ first = items[0]
340
+ if _looks_like_location(first):
341
+ return [_lat_lng(item) for item in items] # type: ignore[arg-type]
342
+ return [[_lat_lng(point) for point in path] for path in items] # type: ignore[arg-type]
343
+
344
+
345
+ def _looks_like_location(value: Any) -> bool:
346
+ return isinstance(value, Sequence) and len(value) == 2 and all(isinstance(item, (int, float)) for item in value)
347
+
348
+
349
+ def _geojson_data(data: Any) -> dict[str, Any]:
350
+ if isinstance(data, (str, os.PathLike)):
351
+ return json.loads(Path(data).read_text(encoding="utf-8"))
352
+ if isinstance(data, dict):
353
+ return data
354
+ if hasattr(data, "__geo_interface__"):
355
+ return data.__geo_interface__
356
+ if hasattr(data, "to_json"):
357
+ return json.loads(data.to_json())
358
+ raise TypeError("GeoJson data must be a dict, path, __geo_interface__ object, or object with to_json().")
359
+
360
+
361
+ def _sample_geojson_feature(data: dict[str, Any]) -> dict[str, Any]:
362
+ if data.get("type") == "FeatureCollection" and data.get("features"):
363
+ return data["features"][0]
364
+ if data.get("type") == "Feature":
365
+ return data
366
+ return {"type": "Feature", "properties": {}, "geometry": None}
367
+
368
+
369
+ def _heatmap_point(point: Any) -> dict[str, Any]:
370
+ if isinstance(point, dict):
371
+ location = point.get("location")
372
+ if location is None:
373
+ location = [point.get("lat"), point.get("lng")]
374
+ lat, lng = _location(location)
375
+ return {"position": [lng, lat], "weight": float(point.get("weight", 1))}
376
+
377
+ if isinstance(point, Sequence) and len(point) in {2, 3}:
378
+ lat, lng = _location(point[:2])
379
+ weight = float(point[2]) if len(point) == 3 else 1.0
380
+ return {"position": [lng, lat], "weight": weight}
381
+
382
+ raise TypeError("HeatMap points must be [lat, lng], [lat, lng, weight], or {'location': [lat, lng], 'weight': n}.")
383
+
384
+
385
+ def _json(value: Any) -> str:
386
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
387
+
388
+
389
+ def _render_fragment(context: dict[str, Any]) -> str:
390
+ specs_json = _json(context["specs"])
391
+ config_json = _json(context["config"])
392
+ api_key = _json(context["api_key"])
393
+ map_id = context["map_id"]
394
+ callback = context["callback"]
395
+ control_style = (
396
+ f"<style>#{map_id}_layers{{background:#fff;"
397
+ "border:1px solid #dadce0;border-radius:4px;padding:8px;font:13px Arial,sans-serif;"
398
+ "box-shadow:0 1px 4px rgba(0,0,0,.2);margin:10px}"
399
+ f"#{map_id}_layers label{{display:block;white-space:nowrap;margin:4px 0}}</style>\n"
400
+ if context["layer_control"]
401
+ else ""
402
+ )
403
+ control_div = f'<div id="{map_id}_layers" hidden></div>' if context["layer_control"] else ""
404
+ return f"""<div id="{map_id}_wrap" style="position:relative;width:{context['width']};height:{context['height']};">
405
+ <div id="{map_id}" style="width:100%;height:100%;"></div>
406
+ {control_div}
407
+ </div>
408
+ {control_style}<script>
409
+ function {callback}_loadScript(src, test) {{
410
+ if (test()) return Promise.resolve();
411
+ return new Promise((resolve, reject) => {{
412
+ const existing = Array.from(document.scripts).find(script => script.dataset.fgmSrc === src);
413
+ if (existing) {{
414
+ existing.addEventListener("load", resolve, {{ once: true }});
415
+ existing.addEventListener("error", reject, {{ once: true }});
416
+ if (test()) resolve();
417
+ return;
418
+ }}
419
+ const script = document.createElement("script");
420
+ script.async = true;
421
+ script.dataset.fgmSrc = src;
422
+ script.src = src;
423
+ script.addEventListener("load", resolve, {{ once: true }});
424
+ script.addEventListener("error", reject, {{ once: true }});
425
+ document.head.appendChild(script);
426
+ }});
427
+ }}
428
+
429
+ window.{callback} = async function() {{
430
+ const specs = {specs_json};
431
+ const config = {config_json};
432
+ const namedLayers = [];
433
+ const {{ Map, InfoWindow, Polyline, Polygon, Circle }} = await google.maps.importLibrary("maps");
434
+ const {{ AdvancedMarkerElement }} = await google.maps.importLibrary("marker");
435
+ const map = new Map(document.getElementById("{map_id}"), config);
436
+ const panorama = map.getStreetView();
437
+ let streetViewVisible = panorama.getVisible();
438
+
439
+ function track(name, layer, setVisible, options = {{}}) {{
440
+ if (!name) return;
441
+ const entry = {{
442
+ name,
443
+ layer,
444
+ setVisible,
445
+ hideInStreetView: Boolean(options.hideInStreetView),
446
+ layerVisible: true,
447
+ applyVisibility() {{
448
+ setVisible(this.layerVisible && !(this.hideInStreetView && streetViewVisible));
449
+ }}
450
+ }};
451
+ namedLayers.push(entry);
452
+ }}
453
+
454
+ for (const spec of specs) {{
455
+ if (spec.type === "marker") {{
456
+ const markerOptions = {{
457
+ map,
458
+ position: spec.position,
459
+ title: spec.tooltip || undefined,
460
+ gmpClickable: Boolean(spec.popup),
461
+ gmpDraggable: spec.draggable || false
462
+ }};
463
+ if (spec.icon) {{
464
+ const img = document.createElement("img");
465
+ img.src = spec.icon;
466
+ markerOptions.content = img;
467
+ }}
468
+ const marker = new AdvancedMarkerElement(markerOptions);
469
+ if (spec.popup) {{
470
+ const info = new InfoWindow({{ content: spec.popup }});
471
+ marker.addListener("click", () => info.open({{ anchor: marker, map }}));
472
+ }}
473
+ track(spec.name, marker, visible => marker.map = visible ? map : null);
474
+ }} else if (spec.type === "polyline") {{
475
+ const layer = new Polyline({{ map, path: spec.path, ...spec.options }});
476
+ track(spec.name, layer, visible => layer.setMap(visible ? map : null));
477
+ }} else if (spec.type === "polygon") {{
478
+ const layer = new Polygon({{ map, paths: spec.paths, ...spec.options }});
479
+ track(spec.name, layer, visible => layer.setMap(visible ? map : null));
480
+ }} else if (spec.type === "circle") {{
481
+ const layer = new Circle({{ map, center: spec.center, radius: spec.radius, ...spec.options }});
482
+ track(spec.name, layer, visible => layer.setMap(visible ? map : null));
483
+ }} else if (spec.type === "geojson") {{
484
+ const layer = new google.maps.Data({{ map }});
485
+ layer.addGeoJson(spec.data);
486
+ if (spec.style) layer.setStyle(spec.style);
487
+ track(spec.name, layer, visible => layer.setMap(visible ? map : null));
488
+ }} else if (spec.type === "heatmap") {{
489
+ class CanvasHeatmapOverlay extends google.maps.OverlayView {{
490
+ constructor(data, options, baseZoom) {{
491
+ super();
492
+ this.data = data;
493
+ this.options = options;
494
+ this.baseZoom = baseZoom;
495
+ this.canvas = null;
496
+ this.listeners = [];
497
+ this.circle = null;
498
+ this.circleRadius = null;
499
+ this.circleBlur = null;
500
+ this.gradientPixels = null;
501
+ this.gradientKey = null;
502
+ }}
503
+
504
+ onAdd() {{
505
+ this.canvas = document.createElement("canvas");
506
+ this.canvas.style.position = "absolute";
507
+ this.canvas.style.inset = "0";
508
+ this.canvas.style.zIndex = "5";
509
+ this.canvas.style.pointerEvents = "none";
510
+ this.canvas.dataset.fgmHeatmap = "true";
511
+ this.getMap().getDiv().appendChild(this.canvas);
512
+ this.listeners.push(google.maps.event.addListener(this.getMap(), "idle", () => this.draw()));
513
+ this.listeners.push(google.maps.event.addListener(this.getMap(), "bounds_changed", () => this.draw()));
514
+ this.listeners.push(google.maps.event.addListener(this.getMap(), "zoom_changed", () => this.draw()));
515
+ }}
516
+
517
+ draw() {{
518
+ if (!this.canvas) return;
519
+ const projection = this.getProjection();
520
+ const mapDiv = this.getMap().getDiv();
521
+ if (!projection || !mapDiv) return;
522
+ const width = Math.max(1, mapDiv.clientWidth);
523
+ const height = Math.max(1, mapDiv.clientHeight);
524
+ this.canvas.width = width;
525
+ this.canvas.height = height;
526
+ this.canvas.style.width = width + "px";
527
+ this.canvas.style.height = height + "px";
528
+
529
+ const ctx = this.canvas.getContext("2d", {{ willReadFrequently: true }});
530
+ ctx.clearRect(0, 0, width, height);
531
+
532
+ const radius = this.options.radiusPixels || 25;
533
+ const blur = this.options.blurPixels === undefined ? 15 : this.options.blurPixels;
534
+ const drawRadius = radius + blur;
535
+ const circle = this.getCircle(radius, blur);
536
+ const zoom = this.getMap().getZoom() ?? this.baseZoom;
537
+ const maxZoom = this.options.maxZoom ?? 18;
538
+ const zoomIntensity = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - zoom, 12)));
539
+ const maxValue = this.options.max || 1;
540
+ const minOpacity = this.options.minOpacity ?? 0.05;
541
+ const bounds = {{
542
+ minX: -drawRadius,
543
+ minY: -drawRadius,
544
+ maxX: width + drawRadius,
545
+ maxY: height + drawRadius
546
+ }};
547
+ const cellSize = drawRadius / 2;
548
+ const grid = [];
549
+
550
+ for (const point of this.data) {{
551
+ const lng = point.position[0];
552
+ const lat = point.position[1];
553
+ const pixel = projection.fromLatLngToContainerPixel(new google.maps.LatLng(lat, lng));
554
+ if (!pixel) continue;
555
+ if (pixel.x < bounds.minX || pixel.x > bounds.maxX || pixel.y < bounds.minY || pixel.y > bounds.maxY) continue;
556
+ const x = Math.floor(pixel.x / cellSize) + 2;
557
+ const y = Math.floor(pixel.y / cellSize) + 2;
558
+ const value = (point.weight === undefined ? 1 : point.weight) * zoomIntensity;
559
+ grid[y] = grid[y] || [];
560
+ const cell = grid[y][x];
561
+ if (!cell) {{
562
+ grid[y][x] = [pixel.x, pixel.y, value];
563
+ }} else {{
564
+ cell[0] = (cell[0] * cell[2] + pixel.x * value) / (cell[2] + value);
565
+ cell[1] = (cell[1] * cell[2] + pixel.y * value) / (cell[2] + value);
566
+ cell[2] += value;
567
+ }}
568
+ }}
569
+
570
+ for (const row of grid) {{
571
+ if (!row) continue;
572
+ for (const cell of row) {{
573
+ if (!cell) continue;
574
+ ctx.globalAlpha = Math.min(Math.max(cell[2] / maxValue, minOpacity), 1);
575
+ ctx.drawImage(circle, Math.round(cell[0]) - drawRadius, Math.round(cell[1]) - drawRadius);
576
+ }}
577
+ }}
578
+ ctx.globalAlpha = 1;
579
+
580
+ const image = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
581
+ this.colorize(image.data, this.getGradient());
582
+ ctx.putImageData(image, 0, 0);
583
+ }}
584
+
585
+ getCircle(radius, blur) {{
586
+ if (this.circle && this.circleRadius === radius && this.circleBlur === blur) return this.circle;
587
+ const drawRadius = radius + blur;
588
+ const circle = document.createElement("canvas");
589
+ const ctx = circle.getContext("2d");
590
+ circle.width = circle.height = drawRadius * 2;
591
+ ctx.shadowOffsetX = ctx.shadowOffsetY = drawRadius * 2;
592
+ ctx.shadowBlur = blur;
593
+ ctx.shadowColor = "black";
594
+ ctx.beginPath();
595
+ ctx.arc(-drawRadius, -drawRadius, radius, 0, Math.PI * 2, true);
596
+ ctx.closePath();
597
+ ctx.fill();
598
+ this.circle = circle;
599
+ this.circleRadius = radius;
600
+ this.circleBlur = blur;
601
+ return circle;
602
+ }}
603
+
604
+ getGradient() {{
605
+ const gradient = this.options.gradient || {{ 0.4: "blue", 0.6: "cyan", 0.7: "lime", 0.8: "yellow", 1.0: "red" }};
606
+ const key = JSON.stringify(gradient);
607
+ if (this.gradientPixels && this.gradientKey === key) return this.gradientPixels;
608
+ const canvas = document.createElement("canvas");
609
+ const ctx = canvas.getContext("2d", {{ willReadFrequently: true }});
610
+ const linearGradient = ctx.createLinearGradient(0, 0, 0, 256);
611
+ canvas.width = 1;
612
+ canvas.height = 256;
613
+ for (const stop in gradient) {{
614
+ linearGradient.addColorStop(Number(stop), gradient[stop]);
615
+ }}
616
+ ctx.fillStyle = linearGradient;
617
+ ctx.fillRect(0, 0, 1, 256);
618
+ this.gradientPixels = ctx.getImageData(0, 0, 1, 256).data;
619
+ this.gradientKey = key;
620
+ return this.gradientPixels;
621
+ }}
622
+
623
+ colorize(pixels, gradient) {{
624
+ for (let i = 0; i < pixels.length; i += 4) {{
625
+ const j = pixels[i + 3] * 4;
626
+ if (j) {{
627
+ pixels[i] = gradient[j];
628
+ pixels[i + 1] = gradient[j + 1];
629
+ pixels[i + 2] = gradient[j + 2];
630
+ }}
631
+ }}
632
+ }}
633
+
634
+ onRemove() {{
635
+ for (const listener of this.listeners) {{
636
+ google.maps.event.removeListener(listener);
637
+ }}
638
+ this.listeners = [];
639
+ if (this.canvas) {{
640
+ this.canvas.remove();
641
+ this.canvas = null;
642
+ }}
643
+ }}
644
+ }}
645
+
646
+ const layer = new CanvasHeatmapOverlay(spec.data, spec.options, config.zoom);
647
+ layer.setMap(map);
648
+ track(spec.name, layer, visible => layer.setMap(visible ? map : null), {{ hideInStreetView: true }});
649
+ }}
650
+ }}
651
+
652
+ panorama.addListener("visible_changed", () => {{
653
+ streetViewVisible = panorama.getVisible();
654
+ for (const entry of namedLayers) {{
655
+ entry.applyVisibility();
656
+ }}
657
+ }});
658
+
659
+ const control = document.getElementById("{map_id}_layers");
660
+ if (control && namedLayers.length) {{
661
+ control.hidden = false;
662
+ for (const entry of namedLayers) {{
663
+ const label = document.createElement("label");
664
+ const checkbox = document.createElement("input");
665
+ checkbox.type = "checkbox";
666
+ checkbox.checked = true;
667
+ checkbox.addEventListener("change", () => {{
668
+ entry.layerVisible = checkbox.checked;
669
+ entry.applyVisibility();
670
+ }});
671
+ label.appendChild(checkbox);
672
+ label.appendChild(document.createTextNode(" " + entry.name));
673
+ control.appendChild(label);
674
+ }}
675
+ map.controls[google.maps.ControlPosition.TOP_RIGHT].push(control);
676
+ }}
677
+ }};
678
+ </script>
679
+ <script>
680
+ (function() {{
681
+ if (window.google && google.maps && google.maps.importLibrary) {{
682
+ window.{callback}();
683
+ return;
684
+ }}
685
+ if (document.querySelector("script[data-fgm-google='{callback}']")) return;
686
+ const script = document.createElement("script");
687
+ script.async = true;
688
+ script.dataset.fgmGoogle = "{callback}";
689
+ script.src = "https://maps.googleapis.com/maps/api/js?key=" + encodeURIComponent({api_key}) + "&loading=async&callback={callback}";
690
+ document.head.appendChild(script);
691
+ }})();
692
+ </script>"""
gmaprium/streamlit.py ADDED
@@ -0,0 +1,40 @@
1
+ """Streamlit integration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .elements import Map
8
+
9
+
10
+ def st_google_map(
11
+ map_obj: Map,
12
+ *,
13
+ height: int | None = None,
14
+ width: int | None = None,
15
+ scrolling: bool = False,
16
+ **kwargs: Any,
17
+ ) -> Any:
18
+ """Render a gmaprium map in Streamlit."""
19
+ try:
20
+ import streamlit.components.v1 as components
21
+ except ImportError as exc:
22
+ raise RuntimeError('streamlit is required. Install with: pip install "gmaprium[streamlit]"') from exc
23
+
24
+ component_height = height or _height_to_pixels(map_obj.height)
25
+ return components.html(
26
+ map_obj.render_html(),
27
+ height=component_height,
28
+ width=width,
29
+ scrolling=scrolling,
30
+ **kwargs,
31
+ )
32
+
33
+
34
+ def _height_to_pixels(value: str) -> int:
35
+ if value.endswith("px"):
36
+ try:
37
+ return int(value[:-2])
38
+ except ValueError:
39
+ return 500
40
+ return 500
gmaprium/tiles.py ADDED
@@ -0,0 +1,58 @@
1
+ """Utilities for adding Google Maps tile layers to Folium maps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+ from urllib.parse import urlencode
7
+
8
+ GoogleMapType = Literal["roadmap", "satellite", "terrain", "hybrid"]
9
+
10
+ _GOOGLE_TILE_ENDPOINT = "https://mt1.google.com/vt/lyrs={layer}&x={x}&y={y}&z={z}"
11
+ _MAP_TYPE_TO_LAYER: dict[GoogleMapType, str] = {
12
+ "roadmap": "m",
13
+ "satellite": "s",
14
+ "terrain": "p",
15
+ "hybrid": "y",
16
+ }
17
+
18
+
19
+ def google_tiles_url(map_type: GoogleMapType = "roadmap", api_key: str | None = None) -> str:
20
+ """Return a Google Maps tile URL template for Folium."""
21
+ layer = _MAP_TYPE_TO_LAYER.get(map_type)
22
+ if layer is None:
23
+ supported = ", ".join(sorted(_MAP_TYPE_TO_LAYER))
24
+ raise ValueError(f"Unsupported map_type {map_type!r}. Expected one of: {supported}.")
25
+
26
+ url = _GOOGLE_TILE_ENDPOINT.format(layer=layer, x="{x}", y="{y}", z="{z}")
27
+ if api_key:
28
+ url = f"{url}&{urlencode({'key': api_key})}"
29
+ return url
30
+
31
+
32
+ def add_google_tiles(
33
+ map_obj: object,
34
+ *,
35
+ api_key: str | None = None,
36
+ map_type: GoogleMapType = "roadmap",
37
+ name: str | None = None,
38
+ attr: str = "Google",
39
+ overlay: bool = False,
40
+ control: bool = True,
41
+ show: bool = True,
42
+ ) -> object:
43
+ """Add a Google Maps tile layer to a Folium map and return the layer."""
44
+ try:
45
+ import folium
46
+ except ImportError as exc: # pragma: no cover - dependency metadata should install folium
47
+ raise RuntimeError("folium is required to add Google Maps tiles.") from exc
48
+
49
+ layer = folium.TileLayer(
50
+ tiles=google_tiles_url(map_type=map_type, api_key=api_key),
51
+ name=name or f"Google {map_type.title()}",
52
+ attr=attr,
53
+ overlay=overlay,
54
+ control=control,
55
+ show=show,
56
+ )
57
+ layer.add_to(map_obj)
58
+ return layer
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: gmaprium
3
+ Version: 0.1.0
4
+ Summary: Folium-style Python helpers for rendering Google Maps.
5
+ Project-URL: Homepage, https://github.com/KentaroAOKI/gmaprium
6
+ Project-URL: Repository, https://github.com/KentaroAOKI/gmaprium
7
+ Project-URL: Issues, https://github.com/KentaroAOKI/gmaprium/issues
8
+ Author: Kentaro
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: folium,geojson,google-maps,heatmap,maps,streamlit
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: GIS
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: jinja2>=3.1
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == 'dev'
29
+ Requires-Dist: pytest>=8; extra == 'dev'
30
+ Requires-Dist: twine>=5; extra == 'dev'
31
+ Provides-Extra: streamlit
32
+ Requires-Dist: streamlit>=1.30; extra == 'streamlit'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # gmaprium
36
+
37
+ Folium-style Python helpers for rendering Google Maps HTML.
38
+
39
+ gmaprium can be used to create standalone HTML maps, display maps in Jupyter Notebook, add Google tile layers to existing Folium maps, and embed maps in Streamlit apps.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install gmaprium
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ from gmaprium import GeoJson, HeatMap, LayerControl, Map, Marker, Polygon
51
+
52
+ m = Map(
53
+ location=[35.6812, 139.7671],
54
+ zoom_start=12,
55
+ api_key="YOUR_GOOGLE_MAPS_API_KEY",
56
+ map_type="roadmap",
57
+ height="500px",
58
+ )
59
+
60
+ Marker([35.6812, 139.7671], popup="Tokyo Station", tooltip="Tokyo", name="Stations").add_to(m)
61
+ Polygon(
62
+ [[35.70, 139.70], [35.70, 139.82], [35.62, 139.82], [35.62, 139.70]],
63
+ name="Area",
64
+ ).add_to(m)
65
+ HeatMap([[35.6812, 139.7671, 2], [35.6895, 139.6917, 1]], name="Heat").add_to(m)
66
+ LayerControl().add_to(m)
67
+
68
+ # Full HTML document.
69
+ m.save("map.html")
70
+
71
+ # Embeddable HTML fragment for web apps.
72
+ html = m.render_fragment()
73
+ ```
74
+
75
+ You can also pass the API key with the `GOOGLE_MAPS_API_KEY` environment variable.
76
+
77
+ Markers use Google's `AdvancedMarkerElement`. If you add markers without passing `map_id=...`, the renderer uses Google's `DEMO_MAP_ID` for local testing.
78
+
79
+ ## Supported Elements
80
+
81
+ - `Map`
82
+ - `Marker`
83
+ - `Polyline`
84
+ - `Polygon`
85
+ - `Circle`
86
+ - `GeoJson`
87
+ - `HeatMap`
88
+ - `LayerControl`
89
+
90
+ `GeoJson` accepts GeoJSON dictionaries, JSON file paths, objects with `__geo_interface__`, and GeoPandas-like objects with `to_json()`.
91
+
92
+ `HeatMap` uses a canvas overlay instead of Google's deprecated JavaScript `HeatmapLayer`.
93
+ It ports the Leaflet.heat/simpleheat rendering model, including `radius`, `blur`, `min_opacity`, `max_zoom`, `max_value`, and `gradient`.
94
+ The defaults match Leaflet.heat/simpleheat on a typical OSM map: `radius=25`, `blur=15`, `min_opacity=0.05`, `max_zoom=18`, `max_value=1.0`, and the blue-cyan-lime-yellow-red gradient.
95
+ Lower zoom levels are intentionally faded by Leaflet.heat's `max_zoom - zoom` intensity scale. Set `max_zoom` closer to your initial zoom if the heat fades too quickly.
96
+
97
+ The legacy `google_tiles_url()` and `add_google_tiles()` helpers are still available for projects that want to add Google tile URLs to an existing Folium map.
98
+
99
+ ## Notebook
100
+
101
+ In Jupyter Notebook, display the map by returning the `Map` object as the last expression in a cell.
102
+
103
+ ```python
104
+ import os
105
+
106
+ from gmaprium import HeatMap, LayerControl, Map, Marker
107
+
108
+ os.environ["GOOGLE_MAPS_API_KEY"] = "your-api-key"
109
+
110
+ m = Map(location=[35.6812, 139.7671], zoom_start=12, height="500px")
111
+ Marker([35.6812, 139.7671], popup="Tokyo Station", name="Markers").add_to(m)
112
+ HeatMap([[35.6812, 139.7671, 1.0]], name="Heat", max_zoom=14).add_to(m)
113
+ LayerControl().add_to(m)
114
+
115
+ m
116
+ ```
117
+
118
+ You can also display explicitly:
119
+
120
+ ```python
121
+ from IPython.display import HTML, display
122
+
123
+ display(HTML(m.render_fragment()))
124
+ ```
125
+
126
+ The notebook example is available at `examples/example.ipynb`.
127
+
128
+ ## Folium Extension
129
+
130
+ If you already use Folium, add Google tile layers to a `folium.Map` with `add_google_tiles()`.
131
+
132
+ ```python
133
+ import folium
134
+
135
+ from gmaprium import add_google_tiles
136
+
137
+ m = folium.Map(location=[35.6812, 139.7671], zoom_start=12, tiles=None)
138
+ add_google_tiles(m, api_key="your-api-key", map_type="roadmap", name="Google Roadmap")
139
+ add_google_tiles(m, api_key="your-api-key", map_type="satellite", name="Google Satellite", show=False)
140
+
141
+ folium.LayerControl().add_to(m)
142
+ m.save("folium_google_tiles.html")
143
+ ```
144
+
145
+ This Folium extension only adds Google tile layers. It does not enable the Google Maps JavaScript API renderer or gmaprium's `HeatMap`.
146
+
147
+ The Folium example is available at `examples/folium_extension.py`.
148
+
149
+ ## Streamlit
150
+
151
+ Use `st_google_map()` to render a `gmaprium.Map` inside a Streamlit app.
152
+
153
+ ```python
154
+ import os
155
+
156
+ import streamlit as st
157
+
158
+ from gmaprium import Map, Marker, st_google_map
159
+
160
+ api_key = os.environ["GOOGLE_MAPS_API_KEY"]
161
+
162
+ m = Map(location=[35.6812, 139.7671], zoom_start=12, api_key=api_key, height="600px")
163
+ Marker([35.6812, 139.7671], popup="Tokyo Station").add_to(m)
164
+
165
+ st_google_map(m)
166
+ ```
167
+
168
+ Install the optional dependency and run the example app:
169
+
170
+ ```bash
171
+ python -m pip install -e ".[dev,streamlit]"
172
+ export GOOGLE_MAPS_API_KEY="your-api-key"
173
+ streamlit run examples/streamlit_app.py
174
+ ```
175
+
176
+ The Streamlit example is available at `examples/streamlit_app.py`.
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ python -m pip install -e ".[dev]"
182
+ pytest
183
+ ```
@@ -0,0 +1,8 @@
1
+ gmaprium/__init__.py,sha256=RsseaUbwHy638v9s4Ro84JeF4IWM4oDHyTh5eBK66bg,516
2
+ gmaprium/elements.py,sha256=6LFCe3suTBGgVbwIWwI20wp2nbXdwKpzHQjcW1_7ZOg,24794
3
+ gmaprium/streamlit.py,sha256=rd-RhUXjBqBPy5K8SsvkiP9lrgzPJqUT-7i5sc13zIw,958
4
+ gmaprium/tiles.py,sha256=1pEhpsm3_kBxnCT9U3NkVHzecTFM8vBMBrO2KL7PH6I,1835
5
+ gmaprium-0.1.0.dist-info/METADATA,sha256=PyZvyPnuidbizmeTvfkbxlt-kjiF0-wqCqvu5ZEM1yA,5753
6
+ gmaprium-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ gmaprium-0.1.0.dist-info/licenses/LICENSE,sha256=3I50LCak8rl9pvb4R1Ij_06bwTDMJ2d7HHVRiCNKuyE,1069
8
+ gmaprium-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kentaro Aoki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.