multilayer-sdk 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.
- multilayer/__init__.py +51 -0
- multilayer/cli.py +134 -0
- multilayer/core.py +232 -0
- multilayer/grid.py +81 -0
- multilayer/html_builder.py +542 -0
- multilayer/layers.py +271 -0
- multilayer/py.typed +1 -0
- multilayer/symbology.py +224 -0
- multilayer_sdk-0.1.0.dist-info/METADATA +193 -0
- multilayer_sdk-0.1.0.dist-info/RECORD +14 -0
- multilayer_sdk-0.1.0.dist-info/WHEEL +5 -0
- multilayer_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- multilayer_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- multilayer_sdk-0.1.0.dist-info/top_level.txt +1 -0
multilayer/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
multilayer — Pure-Python Synchronized Multi-Panel Map Visualization & Spatial Comparison Engine.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from multilayer.core import MultiMap, Panel, SwipeMap, create_multimap
|
|
9
|
+
from multilayer.grid import GridLayout, GridPreset
|
|
10
|
+
from multilayer.layers import (
|
|
11
|
+
Layer,
|
|
12
|
+
RasterLayer,
|
|
13
|
+
TileLayer,
|
|
14
|
+
TileProvider,
|
|
15
|
+
VectorLayer,
|
|
16
|
+
get_tile_provider,
|
|
17
|
+
)
|
|
18
|
+
from multilayer.symbology import (
|
|
19
|
+
CategoricalStyle,
|
|
20
|
+
Choropleth,
|
|
21
|
+
ClassificationMethod,
|
|
22
|
+
ColorRamp,
|
|
23
|
+
GraduatedStyle,
|
|
24
|
+
StyleRule,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
__author__ = "Yusuf Eminoğlu"
|
|
29
|
+
__email__ = "yusufeminoglu@gmail.com"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"MultiMap",
|
|
33
|
+
"Panel",
|
|
34
|
+
"SwipeMap",
|
|
35
|
+
"create_multimap",
|
|
36
|
+
"GridLayout",
|
|
37
|
+
"GridPreset",
|
|
38
|
+
"Layer",
|
|
39
|
+
"VectorLayer",
|
|
40
|
+
"RasterLayer",
|
|
41
|
+
"TileLayer",
|
|
42
|
+
"TileProvider",
|
|
43
|
+
"get_tile_provider",
|
|
44
|
+
"Choropleth",
|
|
45
|
+
"ClassificationMethod",
|
|
46
|
+
"ColorRamp",
|
|
47
|
+
"StyleRule",
|
|
48
|
+
"GraduatedStyle",
|
|
49
|
+
"CategoricalStyle",
|
|
50
|
+
"__version__",
|
|
51
|
+
]
|
multilayer/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Command Line Interface for multilayer-sdk."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import webbrowser
|
|
11
|
+
|
|
12
|
+
from multilayer import __version__
|
|
13
|
+
from multilayer.core import MultiMap, SwipeMap
|
|
14
|
+
from multilayer.layers import TileProvider, VectorLayer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(args: list[str] | None = None) -> int:
|
|
18
|
+
"""Main CLI entrypoint."""
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="multilayer",
|
|
21
|
+
description="Pure-Python Synchronized Multi-Panel Map Visualization & Spatial Comparison CLI.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
24
|
+
|
|
25
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
26
|
+
|
|
27
|
+
# Subcommand: build
|
|
28
|
+
build_parser = subparsers.add_parser("build", help="Build synchronized multi-panel HTML dashboard")
|
|
29
|
+
build_parser.add_argument("--layers", "-l", required=True, help="Comma-separated list of GeoJSON layer paths")
|
|
30
|
+
build_parser.add_argument("--grid", "-g", default="2x2", help="Grid preset: 1x2, 2x1, 1x3, 2x2, 2x3, 2x4 (default: 2x2)")
|
|
31
|
+
build_parser.add_argument("--title", "-t", default="MultiMap Workspace", help="Dashboard title")
|
|
32
|
+
build_parser.add_argument("--basemap", "-b", default="carto-dark-matter", help="Basemap provider (default: carto-dark-matter)")
|
|
33
|
+
build_parser.add_argument("--theme", default="dark", choices=["dark", "light"], help="Color theme (default: dark)")
|
|
34
|
+
build_parser.add_argument("--out", "-o", default="multimap_dashboard.html", help="Output HTML file path")
|
|
35
|
+
build_parser.add_argument("--open", action="store_true", help="Open generated HTML dashboard in default browser")
|
|
36
|
+
|
|
37
|
+
# Subcommand: compare (Swipe Map)
|
|
38
|
+
compare_parser = subparsers.add_parser("compare", help="Create split-screen curtain swipe map")
|
|
39
|
+
compare_parser.add_argument("left_layer", help="Left / Before GeoJSON file path")
|
|
40
|
+
compare_parser.add_argument("right_layer", help="Right / After GeoJSON file path")
|
|
41
|
+
compare_parser.add_argument("--left-title", default="Before", help="Left layer title")
|
|
42
|
+
compare_parser.add_argument("--right-title", default="After", help="Right layer title")
|
|
43
|
+
compare_parser.add_argument("--out", "-o", default="swipe_map.html", help="Output HTML file path")
|
|
44
|
+
compare_parser.add_argument("--open", action="store_true", help="Open generated swipe map in browser")
|
|
45
|
+
|
|
46
|
+
# Subcommand: inspect
|
|
47
|
+
inspect_parser = subparsers.add_parser("inspect", help="Inspect GeoJSON layer features, bounding box, and properties")
|
|
48
|
+
inspect_parser.add_argument("input_file", help="GeoJSON file path to inspect")
|
|
49
|
+
|
|
50
|
+
# Subcommand: tiles
|
|
51
|
+
subparsers.add_parser("tiles", help="List all built-in web map tile basemaps")
|
|
52
|
+
|
|
53
|
+
parsed_args = parser.parse_args(args)
|
|
54
|
+
|
|
55
|
+
if not parsed_args.command:
|
|
56
|
+
parser.print_help()
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
if parsed_args.command == "build":
|
|
60
|
+
layer_paths = [p.strip() for p in parsed_args.layers.split(",") if p.strip()]
|
|
61
|
+
mm = MultiMap(
|
|
62
|
+
grid=parsed_args.grid,
|
|
63
|
+
title=parsed_args.title,
|
|
64
|
+
theme=parsed_args.theme,
|
|
65
|
+
basemap=parsed_args.basemap,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for idx, lp in enumerate(layer_paths):
|
|
69
|
+
if not os.path.exists(lp):
|
|
70
|
+
print(f"Error: Layer file not found: {lp}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
if idx < mm.panel_count:
|
|
73
|
+
vlyr = VectorLayer.from_geojson(lp)
|
|
74
|
+
mm.panel(idx).add_layer(vlyr)
|
|
75
|
+
mm.panel(idx).title = vlyr.name
|
|
76
|
+
|
|
77
|
+
out_path = os.path.abspath(parsed_args.out)
|
|
78
|
+
mm.to_html(out_path)
|
|
79
|
+
print(f"✓ Synchronized MultiMap dashboard generated: {out_path} ({os.path.getsize(out_path):,} bytes)")
|
|
80
|
+
|
|
81
|
+
if parsed_args.open:
|
|
82
|
+
webbrowser.open(f"file://{out_path}")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
elif parsed_args.command == "compare":
|
|
86
|
+
if not os.path.exists(parsed_args.left_layer) or not os.path.exists(parsed_args.right_layer):
|
|
87
|
+
print("Error: One or both layer files not found.", file=sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
sm = SwipeMap(
|
|
91
|
+
left_layer=parsed_args.left_layer,
|
|
92
|
+
right_layer=parsed_args.right_layer,
|
|
93
|
+
left_title=parsed_args.left_title,
|
|
94
|
+
right_title=parsed_args.right_title,
|
|
95
|
+
)
|
|
96
|
+
out_path = os.path.abspath(parsed_args.out)
|
|
97
|
+
sm.to_html(out_path)
|
|
98
|
+
print(f"✓ Split-screen Swipe Map generated: {out_path} ({os.path.getsize(out_path):,} bytes)")
|
|
99
|
+
|
|
100
|
+
if parsed_args.open:
|
|
101
|
+
webbrowser.open(f"file://{out_path}")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
elif parsed_args.command == "inspect":
|
|
105
|
+
if not os.path.exists(parsed_args.input_file):
|
|
106
|
+
print(f"Error: File not found: {parsed_args.input_file}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
with open(parsed_args.input_file, "r", encoding="utf-8") as f:
|
|
109
|
+
data = json.load(f)
|
|
110
|
+
v = VectorLayer.from_geojson(data, name=parsed_args.input_file)
|
|
111
|
+
feats = data.get("features", [])
|
|
112
|
+
bounds = v.get_bounds()
|
|
113
|
+
props_keys = set()
|
|
114
|
+
for ft in feats:
|
|
115
|
+
props_keys.update(ft.get("properties", {}).keys())
|
|
116
|
+
|
|
117
|
+
print(f"Layer File : {parsed_args.input_file}")
|
|
118
|
+
print(f"Feature Count : {len(feats):,}")
|
|
119
|
+
print(f"Bounding Box : {bounds}")
|
|
120
|
+
print(f"Properties ({len(props_keys)}) : {', '.join(sorted(props_keys))}")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
elif parsed_args.command == "tiles":
|
|
124
|
+
print("Available Built-in Tile Providers in multilayer-sdk:")
|
|
125
|
+
for tp in TileProvider:
|
|
126
|
+
if tp != TileProvider.NONE:
|
|
127
|
+
print(f" - {tp.value:<20} : {tp.url_template}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|
multilayer/core.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Core MultiMap and Panel container classes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from multilayer.grid import GridLayout, GridPreset
|
|
11
|
+
from multilayer.html_builder import build_multimap_html, build_swipe_map_html
|
|
12
|
+
from multilayer.layers import Layer, TileLayer, VectorLayer, get_tile_provider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Panel:
|
|
17
|
+
"""Individual map viewport within a MultiMap grid."""
|
|
18
|
+
|
|
19
|
+
title: str = "Map Panel"
|
|
20
|
+
basemap: TileLayer = field(default_factory=lambda: get_tile_provider("carto-dark-matter"))
|
|
21
|
+
layers: list[Layer] = field(default_factory=list)
|
|
22
|
+
opacity: float = 1.0
|
|
23
|
+
sync_nav: bool = True
|
|
24
|
+
|
|
25
|
+
def add_layer(self, layer: Layer | str | dict[str, Any], **kwargs: Any) -> Panel:
|
|
26
|
+
"""Add a vector or tile layer to this panel."""
|
|
27
|
+
if isinstance(layer, Layer):
|
|
28
|
+
self.layers.append(layer)
|
|
29
|
+
elif isinstance(layer, (str, dict)):
|
|
30
|
+
v_layer = VectorLayer.from_geojson(layer, **kwargs)
|
|
31
|
+
self.layers.append(v_layer)
|
|
32
|
+
else:
|
|
33
|
+
raise TypeError(f"Unsupported layer type: {type(layer)}")
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def set_basemap(self, basemap_or_name: TileLayer | str) -> Panel:
|
|
37
|
+
"""Set or change the background basemap provider for this panel."""
|
|
38
|
+
if isinstance(basemap_or_name, TileLayer):
|
|
39
|
+
self.basemap = basemap_or_name
|
|
40
|
+
elif isinstance(basemap_or_name, str):
|
|
41
|
+
self.basemap = get_tile_provider(basemap_or_name)
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"title": self.title,
|
|
47
|
+
"basemap": self.basemap.to_dict() if self.basemap else None,
|
|
48
|
+
"layers": [lyr.to_dict() for lyr in self.layers],
|
|
49
|
+
"opacity": self.opacity,
|
|
50
|
+
"sync_nav": self.sync_nav,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def get_bounds(self) -> tuple[float, float, float, float] | None:
|
|
54
|
+
"""Calculate union bounding box of all layers in this panel."""
|
|
55
|
+
boxes = [lyr.get_bounds() for lyr in self.layers if lyr.get_bounds() is not None]
|
|
56
|
+
if not boxes:
|
|
57
|
+
return None
|
|
58
|
+
min_lon = min(b[0] for b in boxes)
|
|
59
|
+
min_lat = min(b[1] for b in boxes)
|
|
60
|
+
max_lon = max(b[2] for b in boxes)
|
|
61
|
+
max_lat = max(b[3] for b in boxes)
|
|
62
|
+
return min_lon, min_lat, max_lon, max_lat
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MultiMap:
|
|
66
|
+
"""Master synchronized multi-panel map container."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
grid: str | GridPreset | GridLayout = "2x2",
|
|
71
|
+
title: str = "MultiMap Spatial Workspace",
|
|
72
|
+
theme: str = "dark",
|
|
73
|
+
basemap: str | TileLayer = "carto-dark-matter",
|
|
74
|
+
initial_center: tuple[float, float] | None = None,
|
|
75
|
+
initial_zoom: int | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
if isinstance(grid, str):
|
|
78
|
+
self.grid = GridLayout.from_string(grid)
|
|
79
|
+
elif isinstance(grid, GridPreset):
|
|
80
|
+
self.grid = GridLayout(preset=grid)
|
|
81
|
+
elif isinstance(grid, GridLayout):
|
|
82
|
+
self.grid = grid
|
|
83
|
+
else:
|
|
84
|
+
self.grid = GridLayout(preset=GridPreset.FOUR_GRID)
|
|
85
|
+
|
|
86
|
+
self.title = title
|
|
87
|
+
self.theme = theme
|
|
88
|
+
self.default_basemap = get_tile_provider(basemap) if isinstance(basemap, str) else basemap
|
|
89
|
+
self.initial_center = initial_center
|
|
90
|
+
self.initial_zoom = initial_zoom
|
|
91
|
+
|
|
92
|
+
# Initialize panels to match grid layout
|
|
93
|
+
self.panels: list[Panel] = []
|
|
94
|
+
for i in range(self.grid.panel_count):
|
|
95
|
+
self.panels.append(
|
|
96
|
+
Panel(
|
|
97
|
+
title=f"Panel {i + 1}",
|
|
98
|
+
basemap=self.default_basemap,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def panel_count(self) -> int:
|
|
104
|
+
return len(self.panels)
|
|
105
|
+
|
|
106
|
+
def panel(self, index: int) -> Panel:
|
|
107
|
+
"""Access a specific panel by 0-based index."""
|
|
108
|
+
if 0 <= index < len(self.panels):
|
|
109
|
+
return self.panels[index]
|
|
110
|
+
raise IndexError(f"Panel index {index} out of range (0 to {len(self.panels)-1})")
|
|
111
|
+
|
|
112
|
+
def add_layer(self, layer: Layer | str | dict[str, Any], panel_index: int | None = None, **kwargs: Any) -> MultiMap:
|
|
113
|
+
"""Add a layer to a specific panel or to all panels if panel_index is None."""
|
|
114
|
+
if panel_index is not None:
|
|
115
|
+
self.panel(panel_index).add_layer(layer, **kwargs)
|
|
116
|
+
else:
|
|
117
|
+
for p in self.panels:
|
|
118
|
+
p.add_layer(layer, **kwargs)
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def set_basemap(self, basemap_or_name: TileLayer | str, panel_index: int | None = None) -> MultiMap:
|
|
122
|
+
"""Set basemap for a specific panel or all panels."""
|
|
123
|
+
if panel_index is not None:
|
|
124
|
+
self.panel(panel_index).set_basemap(basemap_or_name)
|
|
125
|
+
else:
|
|
126
|
+
for p in self.panels:
|
|
127
|
+
p.set_basemap(basemap_or_name)
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def get_bounds(self) -> tuple[float, float, float, float] | None:
|
|
131
|
+
"""Calculate union bounding box of all panels."""
|
|
132
|
+
boxes = [p.get_bounds() for p in self.panels if p.get_bounds() is not None]
|
|
133
|
+
if not boxes:
|
|
134
|
+
return None
|
|
135
|
+
min_lon = min(b[0] for b in boxes)
|
|
136
|
+
min_lat = min(b[1] for b in boxes)
|
|
137
|
+
max_lon = max(b[2] for b in boxes)
|
|
138
|
+
max_lat = max(b[3] for b in boxes)
|
|
139
|
+
return min_lon, min_lat, max_lon, max_lat
|
|
140
|
+
|
|
141
|
+
def to_html(self, output_path: str | None = None) -> str:
|
|
142
|
+
"""Compile MultiMap to standalone interactive HTML and optionally save to file."""
|
|
143
|
+
html_str = build_multimap_html(self)
|
|
144
|
+
if output_path:
|
|
145
|
+
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
|
146
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
147
|
+
f.write(html_str)
|
|
148
|
+
return html_str
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict[str, Any]:
|
|
151
|
+
"""Convert MultiMap configuration to dictionary."""
|
|
152
|
+
return {
|
|
153
|
+
"title": self.title,
|
|
154
|
+
"theme": self.theme,
|
|
155
|
+
"grid": self.grid.preset.value,
|
|
156
|
+
"panels": [p.to_dict() for p in self.panels],
|
|
157
|
+
"bounds": self.get_bounds(),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def _repr_html_(self) -> str:
|
|
161
|
+
"""Direct inline HTML representation inside Jupyter Notebook / Google Colab."""
|
|
162
|
+
html_data = self.to_html()
|
|
163
|
+
escaped = html_data.replace('"', '"')
|
|
164
|
+
return f'<iframe srcdoc="{escaped}" width="100%" height="600px" style="border:1px solid #1e293b;border-radius:8px;"></iframe>'
|
|
165
|
+
|
|
166
|
+
def show(self) -> None:
|
|
167
|
+
"""Display inline widget in Jupyter Notebook environments."""
|
|
168
|
+
try:
|
|
169
|
+
from IPython.display import HTML, display
|
|
170
|
+
display(HTML(self._repr_html_()))
|
|
171
|
+
except ImportError:
|
|
172
|
+
print(f"MultiMap({self.grid.preset.value}, panels={len(self.panels)}, title='{self.title}')")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SwipeMap:
|
|
176
|
+
"""Split-screen 2-panel curtain swipe comparison map."""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
left_layer: Layer | str | dict[str, Any] | None = None,
|
|
181
|
+
right_layer: Layer | str | dict[str, Any] | None = None,
|
|
182
|
+
left_title: str = "Before (2020)",
|
|
183
|
+
right_title: str = "After (2026)",
|
|
184
|
+
title: str = "Swipe Map Comparison",
|
|
185
|
+
basemap: str | TileLayer = "carto-dark-matter",
|
|
186
|
+
) -> None:
|
|
187
|
+
self.left_layer = VectorLayer.from_geojson(left_layer) if isinstance(left_layer, (str, dict)) else left_layer
|
|
188
|
+
self.right_layer = VectorLayer.from_geojson(right_layer) if isinstance(right_layer, (str, dict)) else right_layer
|
|
189
|
+
self.left_title = left_title
|
|
190
|
+
self.right_title = right_title
|
|
191
|
+
self.title = title
|
|
192
|
+
self.basemap = get_tile_provider(basemap) if isinstance(basemap, str) else basemap
|
|
193
|
+
|
|
194
|
+
def get_bounds(self) -> tuple[float, float, float, float] | None:
|
|
195
|
+
boxes: list[tuple[float, float, float, float]] = []
|
|
196
|
+
if self.left_layer and self.left_layer.get_bounds():
|
|
197
|
+
boxes.append(self.left_layer.get_bounds()) # type: ignore
|
|
198
|
+
if self.right_layer and self.right_layer.get_bounds():
|
|
199
|
+
boxes.append(self.right_layer.get_bounds()) # type: ignore
|
|
200
|
+
if not boxes:
|
|
201
|
+
return None
|
|
202
|
+
return min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)
|
|
203
|
+
|
|
204
|
+
def to_html(self, output_path: str | None = None) -> str:
|
|
205
|
+
html_str = build_swipe_map_html(self)
|
|
206
|
+
if output_path:
|
|
207
|
+
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
|
208
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
209
|
+
f.write(html_str)
|
|
210
|
+
return html_str
|
|
211
|
+
|
|
212
|
+
def _repr_html_(self) -> str:
|
|
213
|
+
html_data = self.to_html()
|
|
214
|
+
escaped = html_data.replace('"', '"')
|
|
215
|
+
return f'<iframe srcdoc="{escaped}" width="100%" height="600px" style="border:1px solid #1e293b;border-radius:8px;"></iframe>'
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def create_multimap(
|
|
219
|
+
layers: list[Layer | str | dict[str, Any]] | None = None,
|
|
220
|
+
grid: str = "2x2",
|
|
221
|
+
title: str = "MultiMap Workspace",
|
|
222
|
+
basemap: str = "carto-dark-matter",
|
|
223
|
+
) -> MultiMap:
|
|
224
|
+
"""Convenience factory to quickly create a MultiMap and assign layers to panels."""
|
|
225
|
+
m = MultiMap(grid=grid, title=title, basemap=basemap)
|
|
226
|
+
if layers:
|
|
227
|
+
for idx, lyr in enumerate(layers):
|
|
228
|
+
if idx < m.panel_count:
|
|
229
|
+
m.panel(idx).add_layer(lyr)
|
|
230
|
+
if hasattr(lyr, "name") and lyr.name:
|
|
231
|
+
m.panel(idx).title = lyr.name
|
|
232
|
+
return m
|
multilayer/grid.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Grid layout definitions and matrix configuration for MultiMap panels."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GridPreset(str, Enum):
|
|
11
|
+
"""Supported coordinated multi-panel grid presets."""
|
|
12
|
+
|
|
13
|
+
TWO_HORIZONTAL = "1x2"
|
|
14
|
+
TWO_VERTICAL = "2x1"
|
|
15
|
+
THREE_HORIZONTAL = "1x3"
|
|
16
|
+
FOUR_GRID = "2x2"
|
|
17
|
+
SIX_GRID = "2x3"
|
|
18
|
+
EIGHT_GRID = "2x4"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def rows(self) -> int:
|
|
22
|
+
return int(self.value.split("x")[0])
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def cols(self) -> int:
|
|
26
|
+
return int(self.value.split("x")[1])
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def panel_count(self) -> int:
|
|
30
|
+
return self.rows * self.cols
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class GridLayout:
|
|
35
|
+
"""Manages panel geometry, CSS grid configurations, and responsive matrix sizing."""
|
|
36
|
+
|
|
37
|
+
preset: GridPreset = GridPreset.FOUR_GRID
|
|
38
|
+
custom_rows: int | None = None
|
|
39
|
+
custom_cols: int | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def rows(self) -> int:
|
|
43
|
+
return self.custom_rows if self.custom_rows is not None else self.preset.rows
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def cols(self) -> int:
|
|
47
|
+
return self.custom_cols if self.custom_cols is not None else self.preset.cols
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def panel_count(self) -> int:
|
|
51
|
+
return self.rows * self.cols
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_string(cls, grid_str: str) -> GridLayout:
|
|
55
|
+
"""Parse grid string such as '1x2', '2x2', '2x3', '2x4' or '4'."""
|
|
56
|
+
clean = grid_str.strip().lower()
|
|
57
|
+
if clean in [e.value for e in GridPreset]:
|
|
58
|
+
return cls(preset=GridPreset(clean))
|
|
59
|
+
if clean in ["2", "two"]:
|
|
60
|
+
return cls(preset=GridPreset.TWO_HORIZONTAL)
|
|
61
|
+
if clean in ["3", "three"]:
|
|
62
|
+
return cls(preset=GridPreset.THREE_HORIZONTAL)
|
|
63
|
+
if clean in ["4", "four"]:
|
|
64
|
+
return cls(preset=GridPreset.FOUR_GRID)
|
|
65
|
+
if clean in ["6", "six"]:
|
|
66
|
+
return cls(preset=GridPreset.SIX_GRID)
|
|
67
|
+
if clean in ["8", "eight"]:
|
|
68
|
+
return cls(preset=GridPreset.EIGHT_GRID)
|
|
69
|
+
|
|
70
|
+
if "x" in clean:
|
|
71
|
+
parts = clean.split("x")
|
|
72
|
+
try:
|
|
73
|
+
r, c = int(parts[0]), int(parts[1])
|
|
74
|
+
return cls(custom_rows=r, custom_cols=c)
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
return cls(preset=GridPreset.FOUR_GRID)
|
|
78
|
+
|
|
79
|
+
def to_css_grid(self) -> str:
|
|
80
|
+
"""Return CSS grid-template-columns and grid-template-rows declaration."""
|
|
81
|
+
return f"grid-template-columns: repeat({self.cols}, 1fr); grid-template-rows: repeat({self.rows}, 1fr);"
|