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 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('"', '&quot;')
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('"', '&quot;')
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);"