spotmap 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.
spotmap/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """spotmap — Interactive epidemiological spot maps for India."""
2
+
3
+ from .exceptions import ColumnNotFoundError, NoCasePointsError, SpotMapError
4
+ from .map_builder import SpotMap
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["SpotMap", "SpotMapError", "ColumnNotFoundError", "NoCasePointsError"]
spotmap/cli.py ADDED
@@ -0,0 +1,75 @@
1
+ """Command-line interface for spotmap."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+
7
+ def _build_parser() -> argparse.ArgumentParser:
8
+ p = argparse.ArgumentParser(
9
+ prog="spotmap",
10
+ description="Generate an interactive epidemiological spot map for India.",
11
+ )
12
+ p.add_argument("csv", help="Path to the input CSV file with point data.")
13
+ p.add_argument(
14
+ "-o", "--output",
15
+ default="spotmap_output.html",
16
+ help="Output HTML file path (default: spotmap_output.html).",
17
+ )
18
+ p.add_argument("--state-shp", default=None, help="Custom state boundary file.")
19
+ p.add_argument("--district-shp", default=None, help="Custom district boundary file.")
20
+ p.add_argument("--lat-col", default=None, help="Latitude column name.")
21
+ p.add_argument("--lon-col", default=None, help="Longitude column name.")
22
+ p.add_argument("--outcome-col", default=None, help="Outcome column name.")
23
+ p.add_argument("--case-value", default=None, help="Value that represents a case.")
24
+ p.add_argument(
25
+ "--count-cutoff",
26
+ type=int,
27
+ default=2,
28
+ help="District count threshold for mode selection (default: 2).",
29
+ )
30
+ p.add_argument(
31
+ "--cluster-color",
32
+ default="#E85252",
33
+ help="Hex colour for dot-density clusters (default: #E85252).",
34
+ )
35
+ p.add_argument(
36
+ "--case-color",
37
+ default="#D55757",
38
+ help="Hex colour for case pins (default: #D55757).",
39
+ )
40
+ p.add_argument(
41
+ "--control-color",
42
+ default="#7676E7",
43
+ help="Hex colour for control pins (default: #7676E7).",
44
+ )
45
+ return p
46
+
47
+
48
+ def main(argv=None) -> None:
49
+ from .map_builder import SpotMap
50
+
51
+ parser = _build_parser()
52
+ args = parser.parse_args(argv)
53
+
54
+ try:
55
+ SpotMap(
56
+ args.csv,
57
+ state_shp=args.state_shp,
58
+ district_shp=args.district_shp,
59
+ lat_col=args.lat_col,
60
+ lon_col=args.lon_col,
61
+ outcome_col=args.outcome_col,
62
+ case_value=args.case_value,
63
+ count_cutoff=args.count_cutoff,
64
+ cluster_color=args.cluster_color,
65
+ case_color=args.case_color,
66
+ control_color=args.control_color,
67
+ ).build().save(args.output)
68
+ print(f"Map saved to: {args.output}")
69
+ except Exception as exc:
70
+ print(f"Error: {exc}", file=sys.stderr)
71
+ sys.exit(1)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
File without changes
Binary file
Binary file
spotmap/downloader.py ADDED
File without changes
spotmap/exceptions.py ADDED
@@ -0,0 +1,10 @@
1
+ class SpotMapError(Exception):
2
+ pass
3
+
4
+
5
+ class ColumnNotFoundError(SpotMapError):
6
+ pass
7
+
8
+
9
+ class NoCasePointsError(SpotMapError):
10
+ pass
spotmap/layers.py ADDED
@@ -0,0 +1,153 @@
1
+ """Folium layer builders for dot-density clusters and spot-map pins."""
2
+
3
+ import folium
4
+ import geopandas as gpd
5
+ from folium.plugins import MarkerCluster
6
+
7
+
8
+ def _cluster_icon_fn(cluster_color: str, total_cases: int) -> str:
9
+ return f"""
10
+ function(cluster) {{
11
+ var count = cluster.getChildCount();
12
+ var total = {total_cases};
13
+
14
+ var frac = count / total;
15
+ if (!isFinite(frac) || frac < 0) frac = 0;
16
+ if (frac > 1) frac = 1;
17
+
18
+ var baseHex = window.clusterBaseColor || '{cluster_color}';
19
+
20
+ function hexToRgb(hex) {{
21
+ if (!hex) return {{r: 255, g: 0, b: 0}};
22
+ var c = hex.replace('#','');
23
+ if (c.length === 3) c = c[0]+c[0]+c[1]+c[1]+c[2]+c[2];
24
+ var num = parseInt(c, 16);
25
+ return {{r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255}};
26
+ }}
27
+
28
+ function mixWithWhite(rgb, t) {{
29
+ t = Math.max(0, Math.min(1, t));
30
+ return {{
31
+ r: Math.round(255*(1-t) + rgb.r*t),
32
+ g: Math.round(255*(1-t) + rgb.g*t),
33
+ b: Math.round(255*(1-t) + rgb.b*t)
34
+ }};
35
+ }}
36
+
37
+ var baseRgb = hexToRgb(baseHex);
38
+ var t = 0.4 + 0.6 * Math.sqrt(frac);
39
+ if (!isFinite(t) || t < 0.4) t = 0.4;
40
+ if (t > 1) t = 1;
41
+
42
+ var mixed = mixWithWhite(baseRgb, t);
43
+ var color = 'rgba('+mixed.r+','+mixed.g+','+mixed.b+',0.95)';
44
+ var size = 30 + Math.min(25, Math.sqrt(count) * 3);
45
+
46
+ return new L.DivIcon({{
47
+ html: '<div style="background:'+color+
48
+ '; width:'+size+'px; height:'+size+
49
+ 'px; border-radius:50%; display:flex; align-items:center; justify-content:center; '+
50
+ 'box-shadow:0 0 6px rgba(0,0,0,0.4); font-weight:bold;">'+count+'</div>',
51
+ className: 'cluster-icon',
52
+ iconSize: new L.Point(size, size)
53
+ }});
54
+ }}
55
+ """
56
+
57
+
58
+ def add_boundary_layers(
59
+ m: folium.Map,
60
+ india_outline: gpd.GeoDataFrame,
61
+ affected_states: gpd.GeoDataFrame,
62
+ affected_districts: gpd.GeoDataFrame,
63
+ ) -> None:
64
+ folium.GeoJson(
65
+ india_outline,
66
+ name="India Border",
67
+ control=False,
68
+ style_function=lambda x: {
69
+ "fillOpacity": 0.0,
70
+ "color": "#000000",
71
+ "weight": 1,
72
+ "opacity": 0.5,
73
+ },
74
+ ).add_to(m)
75
+
76
+ folium.GeoJson(
77
+ affected_states,
78
+ name="Affected States",
79
+ control=False,
80
+ style_function=lambda x: {
81
+ "fillOpacity": 0.05,
82
+ "color": "#4B0082",
83
+ "weight": 1.5,
84
+ "opacity": 0.7,
85
+ },
86
+ ).add_to(m)
87
+
88
+ folium.GeoJson(
89
+ affected_districts,
90
+ name="Affected Districts",
91
+ control=False,
92
+ style_function=lambda x: {
93
+ "color": "#000000",
94
+ "weight": 1,
95
+ "fillOpacity": 0.01,
96
+ "opacity": 1.0,
97
+ },
98
+ ).add_to(m)
99
+
100
+
101
+ def add_marker_layers(
102
+ m: folium.Map,
103
+ points_cases: gpd.GeoDataFrame,
104
+ points_controls: gpd.GeoDataFrame,
105
+ state_name_col: str,
106
+ district_name_col: str,
107
+ cluster_color: str,
108
+ case_color: str,
109
+ control_color: str,
110
+ ):
111
+ """Add dot-density cluster and spot-map pin layers.
112
+
113
+ Returns:
114
+ (cluster_layer, pins_cases_layer, pins_controls_layer)
115
+ """
116
+ total_cases = len(points_cases) or 1
117
+
118
+ cluster = MarkerCluster(
119
+ name="Dot Density Layer",
120
+ icon_create_function=_cluster_icon_fn(cluster_color, total_cases),
121
+ disableClusteringAtZoom=15,
122
+ spiderfyOnMaxZoom=True,
123
+ showCoverageOnHover=False,
124
+ maxClusterRadius=60,
125
+ singleMarkerMode=True,
126
+ )
127
+ m.add_child(cluster)
128
+
129
+ pins_cases_layer = folium.FeatureGroup(name="Spot Map - Cases")
130
+ pins_controls_layer = folium.FeatureGroup(name="Spot Map - Controls")
131
+ m.add_child(pins_cases_layer)
132
+ m.add_child(pins_controls_layer)
133
+
134
+ for _, row in points_cases.iterrows():
135
+ lat, lon = row.geometry.y, row.geometry.x
136
+ popup = (
137
+ f"<b>Type:</b> Case<br>"
138
+ f"<b>State:</b> {row.get(state_name_col, '')}<br>"
139
+ f"<b>District:</b> {row.get(district_name_col, '')}"
140
+ )
141
+ folium.Marker(location=[lat, lon], popup=popup).add_to(cluster)
142
+ folium.Marker(location=[lat, lon], popup=popup).add_to(pins_cases_layer)
143
+
144
+ for _, row in points_controls.iterrows():
145
+ lat, lon = row.geometry.y, row.geometry.x
146
+ popup = (
147
+ f"<b>Type:</b> Control<br>"
148
+ f"<b>State:</b> {row.get(state_name_col, '')}<br>"
149
+ f"<b>District:</b> {row.get(district_name_col, '')}"
150
+ )
151
+ folium.Marker(location=[lat, lon], popup=popup).add_to(pins_controls_layer)
152
+
153
+ return cluster, pins_cases_layer, pins_controls_layer
spotmap/loader.py ADDED
@@ -0,0 +1,156 @@
1
+ """Smart CSV loading with automatic latitude/longitude and outcome column detection."""
2
+
3
+ import re
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from .exceptions import ColumnNotFoundError
9
+
10
+ _FLOAT_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
11
+ _PAIR_PATTERN = re.compile(
12
+ r"^[\[\(]?\s*(-?\d+(\.\d+)?)\s*,\s*(-?\d+(\.\d+)?)\s*[\]\)]?$"
13
+ )
14
+
15
+ _LAT_NAMES = {"lat", "latitude", "y", "northing"}
16
+ _LON_NAMES = {"lon", "long", "longitude", "lng", "x", "easting"}
17
+ _OUTCOME_CANDIDATES = ["outcome", "case_control", "status", "class", "target", "casecontrol"]
18
+ _CASE_VALUES = {"case", "cases", "1", "yes", "true", "positive", "present"}
19
+
20
+
21
+ def _sample(df: pd.DataFrame, col: str, n: int = 5) -> list:
22
+ return df[col].dropna().astype(str).str.strip().head(n).tolist()
23
+
24
+
25
+ def _is_float_col(df: pd.DataFrame, col: str) -> bool:
26
+ samples = _sample(df, col)
27
+ return bool(samples) and all(_FLOAT_PATTERN.match(x) for x in samples)
28
+
29
+
30
+ def _is_pair_col(df: pd.DataFrame, col: str) -> bool:
31
+ samples = _sample(df, col)
32
+ return bool(samples) and all(_PAIR_PATTERN.match(x) for x in samples)
33
+
34
+
35
+ def _detect_from_combined(df: pd.DataFrame, col: str):
36
+ """Split a 'lat,lon' or 'lon,lat' combined column; returns (lat_series, lon_series)."""
37
+ split = (
38
+ df[col]
39
+ .astype(str)
40
+ .str.replace(r"[\[\]()]", "", regex=True)
41
+ .str.split(",", expand=True)
42
+ )
43
+ v1 = pd.to_numeric(split[0].str.strip(), errors="coerce")
44
+ v2 = pd.to_numeric(split[1].str.strip(), errors="coerce")
45
+
46
+ max1, max2 = v1.abs().max(), v2.abs().max()
47
+
48
+ if max1 > 90 and max2 <= 90:
49
+ # first > 90 → must be longitude
50
+ return v2, v1
51
+ if max2 > 90 and max1 <= 90:
52
+ return v1, v2
53
+ # ambiguous: larger absolute value is longitude
54
+ lon = np.where(v1.abs() > v2.abs(), v1, v2)
55
+ lat = np.where(v1.abs() > v2.abs(), v2, v1)
56
+ return pd.Series(lat, index=df.index), pd.Series(lon, index=df.index)
57
+
58
+
59
+ def detect_lat_lon(df: pd.DataFrame, lat_col: str = None, lon_col: str = None):
60
+ """Return (lat_col_name, lon_col_name) after detection or validation."""
61
+ cols = list(df.columns)
62
+
63
+ # User-supplied — validate they exist
64
+ if lat_col and lon_col:
65
+ missing = [c for c in (lat_col, lon_col) if c not in cols]
66
+ if missing:
67
+ raise ColumnNotFoundError(f"Columns not found in CSV: {missing}")
68
+ return lat_col, lon_col
69
+
70
+ # Combined column?
71
+ for col in cols:
72
+ if _is_pair_col(df, col):
73
+ lat_s, lon_s = _detect_from_combined(df, col)
74
+ df["_auto_lat"] = lat_s
75
+ df["_auto_lon"] = lon_s
76
+ return "_auto_lat", "_auto_lon"
77
+
78
+ # Separate numeric columns
79
+ numeric_cols = [c for c in cols if _is_float_col(df, c)]
80
+ found_lat, found_lon = None, None
81
+
82
+ for c in numeric_cols:
83
+ c_lower = c.lower()
84
+ if any(name in c_lower for name in _LAT_NAMES):
85
+ found_lat = c
86
+ if any(name in c_lower for name in _LON_NAMES):
87
+ found_lon = c
88
+
89
+ if found_lat and found_lon:
90
+ return found_lat, found_lon
91
+
92
+ if len(numeric_cols) == 2:
93
+ return numeric_cols[0], numeric_cols[1]
94
+
95
+ raise ColumnNotFoundError(
96
+ f"Could not auto-detect lat/lon columns. Available columns: {cols}. "
97
+ "Pass lat_col and lon_col explicitly."
98
+ )
99
+
100
+
101
+ def detect_outcome(df: pd.DataFrame, outcome_col: str = None, case_value: str = None):
102
+ """Return (outcome_col, case_value) after detection or validation."""
103
+ cols = list(df.columns)
104
+
105
+ if outcome_col and outcome_col not in cols:
106
+ raise ColumnNotFoundError(
107
+ f"outcome_col '{outcome_col}' not found. Available: {cols}"
108
+ )
109
+
110
+ if outcome_col is None:
111
+ for cand in _OUTCOME_CANDIDATES:
112
+ matches = [c for c in cols if cand in c.lower()]
113
+ if matches:
114
+ outcome_col = matches[0]
115
+ break
116
+
117
+ if outcome_col is None:
118
+ raise ColumnNotFoundError(
119
+ f"Could not find outcome column. Available columns: {cols}. "
120
+ "Pass outcome_col explicitly."
121
+ )
122
+
123
+ norm = df[outcome_col].astype(str).str.strip().str.lower().replace({"nan": np.nan})
124
+ values = norm.dropna().unique()
125
+
126
+ if case_value is None:
127
+ case_value = next((v for v in values if v in _CASE_VALUES), values[0] if len(values) else None)
128
+
129
+ if case_value is None:
130
+ raise ColumnNotFoundError(f"No values found in outcome column '{outcome_col}'.")
131
+
132
+ return outcome_col, str(case_value)
133
+
134
+
135
+ def load_csv(
136
+ path: str,
137
+ lat_col: str = None,
138
+ lon_col: str = None,
139
+ outcome_col: str = None,
140
+ case_value: str = None,
141
+ ) -> tuple:
142
+ """Load and prepare the points CSV.
143
+
144
+ Returns:
145
+ (df, lat_col, lon_col, outcome_col, case_value)
146
+ """
147
+ df = pd.read_csv(path)
148
+ lat_col, lon_col = detect_lat_lon(df, lat_col, lon_col)
149
+
150
+ outcome_col, case_value = detect_outcome(df, outcome_col, case_value)
151
+
152
+ df["_outcome_norm"] = (
153
+ df[outcome_col].astype(str).str.strip().str.lower().replace({"nan": np.nan})
154
+ )
155
+
156
+ return df, lat_col, lon_col, outcome_col, case_value
spotmap/map_builder.py ADDED
@@ -0,0 +1,196 @@
1
+ """Main SpotMap class — orchestrates loading, spatial join, and map building."""
2
+
3
+ import folium
4
+
5
+ from .exceptions import NoCasePointsError
6
+ from .layers import add_boundary_layers, add_marker_layers
7
+ from .loader import load_csv
8
+ from .sidebar import build_sidebar_html
9
+ from .spatial import (
10
+ build_india_outline,
11
+ crop_geodataframe,
12
+ determine_mode,
13
+ load_boundaries,
14
+ spatial_join,
15
+ )
16
+
17
+ _DEFAULT_CLUSTER_COLOR = "#E85252"
18
+ _DEFAULT_CASE_COLOR = "#D55757"
19
+ _DEFAULT_CONTROL_COLOR = "#7676E7"
20
+
21
+
22
+ class SpotMap:
23
+ """Build an interactive epidemiological spot map for India.
24
+
25
+ Parameters
26
+ ----------
27
+ csv_path:
28
+ Path to the CSV file containing point data.
29
+ state_shp:
30
+ Optional path to a custom state boundary file (shapefile / GeoPackage /
31
+ FlatGeobuf). Defaults to the bundled India state boundaries.
32
+ district_shp:
33
+ Optional path to a custom district boundary file. Defaults to the
34
+ bundled India district boundaries.
35
+ lat_col:
36
+ Column name for latitude. Auto-detected when omitted.
37
+ lon_col:
38
+ Column name for longitude. Auto-detected when omitted.
39
+ outcome_col:
40
+ Column name for the case/control outcome. Auto-detected when omitted.
41
+ case_value:
42
+ Value in *outcome_col* that represents a **case**. Auto-detected when
43
+ omitted.
44
+ count_cutoff:
45
+ If the number of affected districts is ≤ this value, the map zooms to
46
+ district level; otherwise to state or national level.
47
+ margin_deg:
48
+ Padding (degrees) added around the data bounding box when cropping
49
+ boundary layers.
50
+ cluster_color:
51
+ Hex colour for the dot-density cluster bubbles.
52
+ case_color:
53
+ Hex colour for case pins in spot-map mode.
54
+ control_color:
55
+ Hex colour for control pins in spot-map mode.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ csv_path: str,
61
+ *,
62
+ state_shp: str = None,
63
+ district_shp: str = None,
64
+ lat_col: str = None,
65
+ lon_col: str = None,
66
+ outcome_col: str = None,
67
+ case_value: str = None,
68
+ count_cutoff: int = 2,
69
+ margin_deg: float = 1.0,
70
+ cluster_color: str = _DEFAULT_CLUSTER_COLOR,
71
+ case_color: str = _DEFAULT_CASE_COLOR,
72
+ control_color: str = _DEFAULT_CONTROL_COLOR,
73
+ ):
74
+ self.csv_path = csv_path
75
+ self.state_shp = state_shp
76
+ self.district_shp = district_shp
77
+ self.lat_col = lat_col
78
+ self.lon_col = lon_col
79
+ self.outcome_col = outcome_col
80
+ self.case_value = case_value
81
+ self.count_cutoff = count_cutoff
82
+ self.margin_deg = margin_deg
83
+ self.cluster_color = cluster_color
84
+ self.case_color = case_color
85
+ self.control_color = control_color
86
+
87
+ self._map: folium.Map = None
88
+
89
+ # ------------------------------------------------------------------
90
+ # Public API
91
+ # ------------------------------------------------------------------
92
+
93
+ def build(self) -> "SpotMap":
94
+ """Run the full pipeline and store the Folium map internally.
95
+
96
+ Returns *self* so calls can be chained: ``SpotMap(...).build().save(...)``.
97
+ """
98
+ # 1. Load CSV
99
+ df, lat_col, lon_col, outcome_col, case_value = load_csv(
100
+ self.csv_path,
101
+ lat_col=self.lat_col,
102
+ lon_col=self.lon_col,
103
+ outcome_col=self.outcome_col,
104
+ case_value=self.case_value,
105
+ )
106
+
107
+ # 2. Load boundaries
108
+ states, districts, state_name_col, district_name_col = load_boundaries(
109
+ state_shp=self.state_shp,
110
+ district_shp=self.district_shp,
111
+ )
112
+
113
+ # 3. Spatial join
114
+ points_joined = spatial_join(
115
+ df, lat_col, lon_col, states, districts, state_name_col, district_name_col
116
+ )
117
+
118
+ # 4. Split cases / controls
119
+ mask = points_joined["_outcome_norm"] == case_value
120
+ points_cases = points_joined[mask].copy()
121
+ points_controls = points_joined[~mask].copy()
122
+
123
+ if points_cases.empty:
124
+ raise NoCasePointsError(
125
+ f"No case points found with outcome value '{case_value}'."
126
+ )
127
+
128
+ # 5. Determine mode + crop boundaries
129
+ mode, affected_dist_names, unique_state_names, bounds = determine_mode(
130
+ points_cases, district_name_col, state_name_col, self.count_cutoff
131
+ )
132
+
133
+ india_outline = build_india_outline(states)
134
+ affected_states = states[states[state_name_col].isin(unique_state_names)].copy()
135
+ affected_districts = districts[
136
+ districts[district_name_col].isin(affected_dist_names)
137
+ ].copy()
138
+
139
+ india_sub = crop_geodataframe(india_outline, bounds, self.margin_deg)
140
+ states_sub = crop_geodataframe(affected_states, bounds, self.margin_deg)
141
+ districts_sub = crop_geodataframe(affected_districts, bounds, self.margin_deg)
142
+
143
+ # 6. Init map
144
+ zoom = {"india": 4, "states": 5, "districts": 7}[mode]
145
+ m = folium.Map(
146
+ location=[points_cases.geometry.y.mean(), points_cases.geometry.x.mean()],
147
+ zoom_start=zoom,
148
+ tiles="CartoDB positron",
149
+ )
150
+
151
+ # 7. Boundary layers
152
+ add_boundary_layers(m, india_sub, states_sub, districts_sub)
153
+
154
+ # 8. Marker layers
155
+ cluster, pins_cases, pins_controls = add_marker_layers(
156
+ m,
157
+ points_cases,
158
+ points_controls,
159
+ state_name_col,
160
+ district_name_col,
161
+ cluster_color=self.cluster_color,
162
+ case_color=self.case_color,
163
+ control_color=self.control_color,
164
+ )
165
+
166
+ # 9. Sidebar
167
+ sidebar_html = build_sidebar_html(
168
+ map_id=m.get_name(),
169
+ dots_name=cluster.get_name(),
170
+ pins_cases_name=pins_cases.get_name(),
171
+ pins_controls_name=pins_controls.get_name(),
172
+ mode=mode,
173
+ n_cases=len(points_cases),
174
+ n_controls=len(points_controls),
175
+ cluster_color=self.cluster_color,
176
+ case_color=self.case_color,
177
+ control_color=self.control_color,
178
+ )
179
+ m.get_root().html.add_child(folium.Element(sidebar_html))
180
+
181
+ self._map = m
182
+ return self
183
+
184
+ def save(self, output_path: str) -> "SpotMap":
185
+ """Save the built map to an HTML file."""
186
+ if self._map is None:
187
+ raise RuntimeError("Call .build() before .save().")
188
+ self._map.save(output_path)
189
+ return self
190
+
191
+ @property
192
+ def map(self) -> folium.Map:
193
+ """The underlying Folium map object (after build)."""
194
+ if self._map is None:
195
+ raise RuntimeError("Call .build() first.")
196
+ return self._map
spotmap/sidebar.py ADDED
@@ -0,0 +1,360 @@
1
+ """Sidebar HTML/JS/CSS generation."""
2
+
3
+
4
+ def build_sidebar_html(
5
+ map_id: str,
6
+ dots_name: str,
7
+ pins_cases_name: str,
8
+ pins_controls_name: str,
9
+ mode: str,
10
+ n_cases: int,
11
+ n_controls: int,
12
+ cluster_color: str,
13
+ case_color: str,
14
+ control_color: str,
15
+ ) -> str:
16
+ return f"""
17
+ <style>
18
+ #sidebar-toggle-btn {{
19
+ position: fixed;
20
+ top: 10px;
21
+ right: 10px;
22
+ z-index: 10000;
23
+ width: 38px;
24
+ height: 38px;
25
+ background: white;
26
+ border-radius: 4px;
27
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ cursor: pointer;
32
+ font-size: 20px;
33
+ user-select: none;
34
+ }}
35
+ #sidebar-toggle-btn span {{
36
+ display: block;
37
+ width: 20px;
38
+ height: 2px;
39
+ background: #333;
40
+ margin: 3px 0;
41
+ }}
42
+ #sidebar-toggle-btn:hover {{ background: #f4f4f4; }}
43
+
44
+ #map-sidebar {{
45
+ position: fixed;
46
+ top: 10px;
47
+ right: 10px;
48
+ bottom: 10px;
49
+ width: 260px;
50
+ z-index: 9999;
51
+ background: white;
52
+ padding: 10px 12px;
53
+ border-radius: 6px;
54
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
55
+ font-size: 13px;
56
+ overflow-y: auto;
57
+ transform: translateX(110%);
58
+ transition: transform 0.25s ease-out;
59
+ font-family: sans-serif;
60
+ }}
61
+ #map-sidebar.open {{ transform: translateX(0); }}
62
+ #map-sidebar h4 {{
63
+ margin: 8px 0 6px 0;
64
+ font-size: 14px;
65
+ border-bottom: 1px solid #ddd;
66
+ padding-bottom: 3px;
67
+ color: #333;
68
+ }}
69
+ #map-sidebar label {{ display: block; margin: 4px 0; cursor: pointer; }}
70
+ #map-sidebar .sidebar-section {{ margin-bottom: 12px; }}
71
+ #map-sidebar .sidebar-footer {{
72
+ margin-top: 12px;
73
+ font-size: 11px;
74
+ color: #555;
75
+ border-top: 1px solid #eee;
76
+ padding-top: 8px;
77
+ }}
78
+ #map-sidebar a.download-link {{
79
+ display: block;
80
+ margin: 4px 0;
81
+ color: #0066cc;
82
+ text-decoration: underline;
83
+ cursor: pointer;
84
+ font-weight: 600;
85
+ }}
86
+ #map-sidebar a.download-link:hover {{ color: #004a99; }}
87
+
88
+ #map-legend {{
89
+ position: absolute;
90
+ top: 20px;
91
+ left: 60px;
92
+ z-index: 1000;
93
+ background: rgba(255,255,255,0.9);
94
+ padding: 8px 10px;
95
+ border-radius: 4px;
96
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
97
+ font-size: 12px;
98
+ font-family: sans-serif;
99
+ min-width: 100px;
100
+ border: 1px solid #ccc;
101
+ display: none;
102
+ }}
103
+ #map-legend h4 {{
104
+ margin: 0 0 6px 0;
105
+ font-size: 13px;
106
+ border-bottom: 1px solid #ccc;
107
+ padding-bottom: 2px;
108
+ text-align: center;
109
+ }}
110
+ .legend-item {{ display: flex; align-items: center; margin-bottom: 4px; }}
111
+ .legend-icon {{
112
+ width: 12px;
113
+ height: 12px;
114
+ border-radius: 50%;
115
+ margin-right: 8px;
116
+ border: 1px solid rgba(0,0,0,0.2);
117
+ display: inline-block;
118
+ }}
119
+
120
+ @media print {{
121
+ #map-sidebar, #sidebar-toggle-btn {{ display: none !important; }}
122
+ #map-legend {{ display: block !important; position: absolute; top: 10px; left: 10px; }}
123
+ .leaflet-control-zoom {{ display: none !important; }}
124
+ }}
125
+ </style>
126
+
127
+ <div id="sidebar-toggle-btn" title="Map Options">
128
+ <div><span></span><span></span><span></span></div>
129
+ </div>
130
+
131
+ <div id="map-legend">
132
+ <h4>Legend</h4>
133
+ <div class="legend-item" id="legend-case-item">
134
+ <span class="legend-icon" id="legend-icon-case" style="background-color:{case_color};"></span>
135
+ <span>Case</span>
136
+ </div>
137
+ <div class="legend-item" id="legend-control-item" style="display:none;">
138
+ <span class="legend-icon" id="legend-icon-control" style="background-color:{control_color};"></span>
139
+ <span>Control</span>
140
+ </div>
141
+ </div>
142
+
143
+ <div id="map-sidebar">
144
+ <div class="sidebar-section">
145
+ <h4>Map Mode</h4>
146
+ <label><input type="radio" name="markerMode" value="dots" checked> Dot Density (Cases)</label>
147
+ <label><input type="radio" name="markerMode" value="pins"> Spot Map (Pins)</label>
148
+ </div>
149
+
150
+ <div class="sidebar-section">
151
+ <h4>Spot Map Filter</h4>
152
+ <label><input type="radio" name="spotFilterMode" value="cases" checked> Cases Only</label>
153
+ <label><input type="radio" name="spotFilterMode" value="both"> Cases &amp; Controls</label>
154
+ </div>
155
+
156
+ <div class="sidebar-section">
157
+ <h4>Cluster Color (Cases)</h4>
158
+ <label><input type="radio" name="colorMode" value="red"> Red</label>
159
+ <label><input type="radio" name="colorMode" value="blue"> Blue</label>
160
+ <label><input type="radio" name="colorMode" value="green"> Green</label>
161
+ <label>
162
+ <input type="radio" name="colorMode" value="custom" checked> Custom
163
+ <input type="color" id="clusterCustomColor" value="{cluster_color}"
164
+ style="margin-left:6px;vertical-align:middle;width:40px;height:20px;border:none;padding:0;">
165
+ </label>
166
+ </div>
167
+
168
+ <div class="sidebar-section">
169
+ <h4>Spot Map Colors</h4>
170
+ <div style="margin-bottom:6px;">
171
+ <b>Cases:</b><br>
172
+ <input type="color" id="caseColorPicker" value="{case_color}"
173
+ style="vertical-align:middle;width:40px;height:20px;border:none;padding:0;">
174
+ <button type="button" id="caseApply" style="font-size:11px;margin-left:4px;">Apply</button>
175
+ </div>
176
+ <div>
177
+ <b>Controls:</b><br>
178
+ <input type="color" id="controlColorPicker" value="{control_color}"
179
+ style="vertical-align:middle;width:40px;height:20px;border:none;padding:0;">
180
+ <button type="button" id="controlApply" style="font-size:11px;margin-left:4px;">Apply</button>
181
+ </div>
182
+ </div>
183
+
184
+ <div class="sidebar-section">
185
+ <h4>Pin Size</h4>
186
+ <input type="range" id="pinSizeSlider" min="0.5" max="2.0" step="0.25" value="1.0" style="width:100%;">
187
+ </div>
188
+
189
+ <div class="sidebar-section">
190
+ <h4>Export</h4>
191
+ <a id="downloadPrintLink" class="download-link">Print / Save PDF</a>
192
+ <a id="downloadPngLink" class="download-link">Download PNG</a>
193
+ </div>
194
+
195
+ <div class="sidebar-footer">
196
+ <div><b>Mode:</b> {mode}</div>
197
+ <div><b>Cases:</b> {n_cases}</div>
198
+ <div><b>Controls:</b> {n_controls}</div>
199
+ </div>
200
+ </div>
201
+
202
+ <script src="https://unpkg.com/leaflet-simple-map-screenshoter"></script>
203
+ <script>
204
+ window.addEventListener('load', function() {{
205
+ var mapObj = {map_id};
206
+ var dotsLayer = {dots_name};
207
+ var pinsCasesLayer = {pins_cases_name};
208
+ var pinsControlsLayer = {pins_controls_name};
209
+
210
+ // Move legend inside map container so screenshoter captures it
211
+ var legendDiv = document.getElementById('map-legend');
212
+ mapObj.getContainer().appendChild(legendDiv);
213
+
214
+ var simpleMapScreenshoter = L.simpleMapScreenshoter({{
215
+ hidden: true,
216
+ mimeType: 'image/png'
217
+ }}).addTo(mapObj);
218
+
219
+ var sidebar = document.getElementById('map-sidebar');
220
+ var toggleBtn = document.getElementById('sidebar-toggle-btn');
221
+ var sidebarOpen = false;
222
+
223
+ toggleBtn.addEventListener('click', function() {{
224
+ sidebarOpen = !sidebarOpen;
225
+ sidebar.classList.toggle('open', sidebarOpen);
226
+ }});
227
+
228
+ window.caseColor = '{case_color}';
229
+ window.controlColor = '{control_color}';
230
+ window.clusterBaseColor = '{cluster_color}';
231
+ window.pinScale = 1.0;
232
+
233
+ function updateLegend() {{
234
+ var legendBox = document.getElementById('map-legend');
235
+ var isPins = document.querySelector('input[name="markerMode"]:checked').value === 'pins';
236
+ legendBox.style.display = isPins ? 'block' : 'none';
237
+ if (!isPins) return;
238
+ document.getElementById('legend-icon-case').style.backgroundColor = window.caseColor;
239
+ document.getElementById('legend-icon-control').style.backgroundColor = window.controlColor;
240
+ var isBoth = document.querySelector('input[name="spotFilterMode"]:checked').value === 'both';
241
+ document.getElementById('legend-control-item').style.display = isBoth ? 'flex' : 'none';
242
+ }}
243
+
244
+ function makePinIcon(colorHex) {{
245
+ var scale = window.pinScale || 1.0;
246
+ var baseW = 18, baseH = 24;
247
+ var html =
248
+ '<div style="position:relative;width:'+baseW+'px;height:'+baseH+'px;transform:scale('+scale+');transform-origin:50% 100%;">' +
249
+ '<div style="position:absolute;left:3px;top:6px;width:12px;height:12px;border-radius:50% 50% 50% 0;background:'+colorHex+';transform:rotate(-45deg);box-shadow:0 0 2px rgba(0,0,0,0.5);"></div>' +
250
+ '<div style="position:absolute;left:6.5px;top:9.5px;width:5px;height:5px;border-radius:50%;background:white;opacity:0.9;"></div>' +
251
+ '</div>';
252
+ return new L.DivIcon({{ html: html, className: '', iconSize: [baseW, baseH], iconAnchor: [baseW/2, baseH] }});
253
+ }}
254
+
255
+ function redrawPins() {{
256
+ if (pinsCasesLayer) {{
257
+ pinsCasesLayer.eachLayer(function(marker) {{
258
+ if (marker.setIcon) marker.setIcon(makePinIcon(window.caseColor));
259
+ }});
260
+ }}
261
+ if (pinsControlsLayer) {{
262
+ pinsControlsLayer.eachLayer(function(marker) {{
263
+ if (marker.setIcon) marker.setIcon(makePinIcon(window.controlColor));
264
+ }});
265
+ }}
266
+ updateLegend();
267
+ }}
268
+
269
+ function refreshClusters() {{
270
+ if (mapObj.hasLayer(dotsLayer)) {{
271
+ mapObj.removeLayer(dotsLayer);
272
+ mapObj.addLayer(dotsLayer);
273
+ }}
274
+ }}
275
+
276
+ function applyLayerLogic() {{
277
+ var mode = document.querySelector('input[name="markerMode"]:checked').value;
278
+ var filter = document.querySelector('input[name="spotFilterMode"]:checked').value;
279
+
280
+ if (mode === 'dots') {{
281
+ if (!mapObj.hasLayer(dotsLayer)) mapObj.addLayer(dotsLayer);
282
+ if (mapObj.hasLayer(pinsCasesLayer)) mapObj.removeLayer(pinsCasesLayer);
283
+ if (mapObj.hasLayer(pinsControlsLayer)) mapObj.removeLayer(pinsControlsLayer);
284
+ }} else {{
285
+ if (mapObj.hasLayer(dotsLayer)) mapObj.removeLayer(dotsLayer);
286
+ if (!mapObj.hasLayer(pinsCasesLayer)) mapObj.addLayer(pinsCasesLayer);
287
+ if (filter === 'both') {{
288
+ if (!mapObj.hasLayer(pinsControlsLayer)) mapObj.addLayer(pinsControlsLayer);
289
+ }} else {{
290
+ if (mapObj.hasLayer(pinsControlsLayer)) mapObj.removeLayer(pinsControlsLayer);
291
+ }}
292
+ }}
293
+ updateLegend();
294
+ }}
295
+
296
+ document.querySelectorAll('input[type=radio]').forEach(function(r) {{
297
+ r.addEventListener('change', applyLayerLogic);
298
+ }});
299
+
300
+ // Cluster color
301
+ var colorRadios = document.getElementsByName('colorMode');
302
+ var custClust = document.getElementById('clusterCustomColor');
303
+
304
+ function updateClusterColor() {{
305
+ var val = document.querySelector('input[name="colorMode"]:checked').value;
306
+ if (val === 'red') window.clusterBaseColor = '#FF0000';
307
+ else if (val === 'blue') window.clusterBaseColor = '#0000FF';
308
+ else if (val === 'green') window.clusterBaseColor = '#00AA00';
309
+ else window.clusterBaseColor = custClust.value;
310
+ refreshClusters();
311
+ }}
312
+
313
+ for (var i = 0; i < colorRadios.length; i++) colorRadios[i].addEventListener('change', updateClusterColor);
314
+ if (custClust) custClust.addEventListener('input', function() {{
315
+ document.querySelector('input[name="colorMode"][value="custom"]').checked = true;
316
+ updateClusterColor();
317
+ }});
318
+
319
+ // Spot map colors
320
+ document.getElementById('caseApply').addEventListener('click', function() {{
321
+ window.caseColor = document.getElementById('caseColorPicker').value;
322
+ redrawPins();
323
+ }});
324
+ document.getElementById('controlApply').addEventListener('click', function() {{
325
+ window.controlColor = document.getElementById('controlColorPicker').value;
326
+ redrawPins();
327
+ }});
328
+
329
+ // Pin size
330
+ document.getElementById('pinSizeSlider').addEventListener('input', function(e) {{
331
+ window.pinScale = parseFloat(e.target.value);
332
+ redrawPins();
333
+ }});
334
+
335
+ // Export
336
+ document.getElementById('downloadPrintLink').addEventListener('click', function() {{ window.print(); }});
337
+ document.getElementById('downloadPngLink').addEventListener('click', function() {{
338
+ sidebar.classList.remove('open');
339
+ sidebarOpen = false;
340
+ setTimeout(function() {{
341
+ simpleMapScreenshoter.takeScreen('blob', {{ caption: function() {{ return ''; }} }})
342
+ .then(function(blob) {{
343
+ var link = document.createElement('a');
344
+ link.download = 'map.png';
345
+ link.href = URL.createObjectURL(blob);
346
+ link.click();
347
+ }})
348
+ .catch(function(e) {{ alert(e); }})
349
+ .finally(function() {{
350
+ sidebar.classList.add('open');
351
+ sidebarOpen = true;
352
+ }});
353
+ }}, 500);
354
+ }});
355
+
356
+ applyLayerLogic();
357
+ redrawPins();
358
+ }});
359
+ </script>
360
+ """
spotmap/spatial.py ADDED
@@ -0,0 +1,151 @@
1
+ """Boundary loading and spatial-join utilities."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import geopandas as gpd
7
+ import numpy as np
8
+ from shapely.geometry import Point
9
+
10
+ os.environ.setdefault("SHAPE_RESTORE_SHX", "YES")
11
+
12
+ _DATA_DIR = Path(__file__).parent / "data"
13
+ _STATE_FGB = _DATA_DIR / "state_boundary_lite.fgb"
14
+ _DISTRICT_FGB = _DATA_DIR / "district_boundary_lite.fgb"
15
+
16
+ _STATE_CANDIDATES = ["STATE", "STATE_UT", "ST_NM", "STATE_NAME", "STNAME", "NAME"]
17
+ _DISTRICT_CANDIDATES = [
18
+ "DISTRICT", "DIST_NEW", "DISTRICT_N",
19
+ "DT_NAME", "DIST_ROMAN", "dtname", "NAME",
20
+ ]
21
+
22
+
23
+ def _find_name_col(gdf: gpd.GeoDataFrame, candidates: list, label: str) -> str:
24
+ col = next((c for c in candidates if c in gdf.columns), None)
25
+ if col is None:
26
+ raise ValueError(
27
+ f"No {label} name column found. "
28
+ f"Columns present: {list(gdf.columns)}"
29
+ )
30
+ return col
31
+
32
+
33
+ def _ensure_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
34
+ if gdf.crs is None:
35
+ gdf = gdf.set_crs(epsg=4326)
36
+ elif gdf.crs.to_epsg() != 4326:
37
+ gdf = gdf.to_crs(epsg=4326)
38
+ gdf["geometry"] = gdf["geometry"].buffer(0)
39
+ return gdf
40
+
41
+
42
+ def load_boundaries(state_shp: str = None, district_shp: str = None):
43
+ """Load state and district boundary GeoDataFrames.
44
+
45
+ Uses bundled FlatGeobuf files by default. Pass custom shapefile/GeoPackage
46
+ paths to override.
47
+
48
+ Returns:
49
+ (states, districts, state_name_col, district_name_col)
50
+ """
51
+ state_path = state_shp or str(_STATE_FGB)
52
+ district_path = district_shp or str(_DISTRICT_FGB)
53
+
54
+ states = _ensure_wgs84(gpd.read_file(state_path))
55
+ districts = _ensure_wgs84(gpd.read_file(district_path))
56
+
57
+ state_name_col = _find_name_col(states, _STATE_CANDIDATES, "state")
58
+ district_name_col = _find_name_col(districts, _DISTRICT_CANDIDATES, "district")
59
+
60
+ return states, districts, state_name_col, district_name_col
61
+
62
+
63
+ def build_india_outline(states: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
64
+ geom = states.unary_union
65
+ return gpd.GeoDataFrame({"geometry": [geom]}, crs=states.crs)
66
+
67
+
68
+ def _get_col_safe(joined: gpd.GeoDataFrame, col: str):
69
+ shp_col = f"{col}_shp"
70
+ if shp_col in joined.columns:
71
+ return joined[shp_col].values
72
+ if col in joined.columns:
73
+ return joined[col].values
74
+ return [None] * len(joined)
75
+
76
+
77
+ def spatial_join(
78
+ df,
79
+ lat_col: str,
80
+ lon_col: str,
81
+ states: gpd.GeoDataFrame,
82
+ districts: gpd.GeoDataFrame,
83
+ state_name_col: str,
84
+ district_name_col: str,
85
+ ) -> gpd.GeoDataFrame:
86
+ """Attach state and district names to each point via spatial join."""
87
+ geometry = [Point(xy) for xy in zip(df[lon_col], df[lat_col])]
88
+ points = gpd.GeoDataFrame(df.copy(), geometry=geometry, crs="EPSG:4326")
89
+
90
+ joined_district = gpd.sjoin(
91
+ points,
92
+ districts[[district_name_col, "geometry"]],
93
+ how="left",
94
+ predicate="within",
95
+ lsuffix="_csv",
96
+ rsuffix="_shp",
97
+ ).drop(columns=["index_right"], errors="ignore")
98
+
99
+ joined_state = gpd.sjoin(
100
+ points,
101
+ states[[state_name_col, "geometry"]],
102
+ how="left",
103
+ predicate="within",
104
+ lsuffix="_csv",
105
+ rsuffix="_shp",
106
+ ).drop(columns=["index_right"], errors="ignore")
107
+
108
+ result = points.copy()
109
+ result[district_name_col] = _get_col_safe(joined_district, district_name_col)
110
+ result[state_name_col] = _get_col_safe(joined_state, state_name_col)
111
+ return result
112
+
113
+
114
+ def determine_mode(
115
+ points_cases: gpd.GeoDataFrame,
116
+ district_name_col: str,
117
+ state_name_col: str,
118
+ count_cutoff: int = 2,
119
+ ):
120
+ """Determine map mode: 'districts', 'states', or 'india'.
121
+
122
+ Returns:
123
+ (mode, affected_districts, unique_states, bounds_array)
124
+ """
125
+ affected_districts = points_cases[district_name_col].dropna().unique()
126
+ unique_states = points_cases[state_name_col].dropna().unique()
127
+
128
+ num_districts = len(affected_districts)
129
+ num_states = len(unique_states)
130
+
131
+ if 0 < num_districts <= count_cutoff:
132
+ mode = "districts"
133
+ elif num_states > count_cutoff:
134
+ mode = "india"
135
+ else:
136
+ mode = "states"
137
+
138
+ bounds = np.array(points_cases.total_bounds, dtype=float)
139
+ return mode, affected_districts, unique_states, bounds
140
+
141
+
142
+ def crop_geodataframe(
143
+ gdf: gpd.GeoDataFrame, bounds: np.ndarray, margin: float = 1.0
144
+ ) -> gpd.GeoDataFrame:
145
+ if gdf is None or gdf.empty:
146
+ return gdf
147
+ if not np.isfinite(bounds).all():
148
+ return gdf
149
+ minx, miny, maxx, maxy = bounds
150
+ sub = gdf.cx[minx - margin : maxx + margin, miny - margin : maxy + margin]
151
+ return sub if not sub.empty else gdf
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: spotmap
3
+ Version: 0.1.0
4
+ Summary: Interactive epidemiological spot maps for India
5
+ Project-URL: Homepage, https://github.com/TharunMallesan/spotmap
6
+ Project-URL: Repository, https://github.com/TharunMallesan/spotmap
7
+ Project-URL: Bug Tracker, https://github.com/TharunMallesan/spotmap/issues
8
+ Author-email: Tharun Mallesan <tharunmallesan@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2025 Tharun Mallesan
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: case-control,epidemiology,folium,geospatial,india,spot map
32
+ Classifier: Intended Audience :: Science/Research
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Scientific/Engineering :: GIS
37
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
38
+ Requires-Python: >=3.9
39
+ Requires-Dist: folium>=0.14
40
+ Requires-Dist: geopandas>=0.13
41
+ Requires-Dist: numpy>=1.24
42
+ Requires-Dist: pandas>=2.0
43
+ Requires-Dist: shapely>=2.0
44
+ Description-Content-Type: text/markdown
45
+
46
+ # SpotMap
47
+
48
+ **Interactive epidemiological spot maps for India**
49
+
50
+ SpotMap turns a CSV of case/control coordinates into a publication-ready interactive HTML map with:
51
+
52
+ - **Dot density** clustering (cases only)
53
+ - **Spot map** pins (cases and/or controls) with custom colours
54
+ - Automatic state/district boundary overlays (bundled data — no shapefile setup needed)
55
+ - Sidebar with mode toggle, colour pickers, pin size slider, and PNG/PDF export
56
+ - Smart auto-detection of latitude, longitude, and outcome columns
57
+
58
+ ---
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install spotmap
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Quick start
69
+
70
+ ### Python API
71
+
72
+ ```python
73
+ from spotmap import SpotMap
74
+
75
+ SpotMap("my_data.csv").build().save("map.html")
76
+ ```
77
+
78
+ ### Command line
79
+
80
+ ```bash
81
+ spotmap my_data.csv -o map.html
82
+ ```
83
+
84
+ ---
85
+
86
+ ## CSV format
87
+
88
+ SpotMap auto-detects columns — no strict naming required.
89
+
90
+ | Requirement | Details |
91
+ |---|---|
92
+ | **Coordinates** | Separate `lat` / `lon` columns **or** a combined `"lat,lon"` column |
93
+ | **Outcome** | A column named `outcome`, `status`, `case_control`, etc. with values like `case` / `control` |
94
+
95
+ Example:
96
+
97
+ ```csv
98
+ latitude,longitude,outcome
99
+ 28.6,77.2,case
100
+ 19.0,72.8,control
101
+ 13.0,80.2,case
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Python API reference
107
+
108
+ ```python
109
+ SpotMap(
110
+ csv_path, # required
111
+ state_shp=None, # custom state boundary (shapefile/GeoPackage)
112
+ district_shp=None, # custom district boundary
113
+ lat_col=None, # override auto-detection
114
+ lon_col=None,
115
+ outcome_col=None,
116
+ case_value=None, # value that means "case" in outcome_col
117
+ count_cutoff=2, # districts ≤ cutoff → district zoom
118
+ margin_deg=1.0, # boundary crop padding
119
+ cluster_color="#E85252", # dot-density bubble colour
120
+ case_color="#D55757", # case pin colour
121
+ control_color="#7676E7", # control pin colour
122
+ )
123
+ ```
124
+
125
+ Chain calls:
126
+
127
+ ```python
128
+ sm = SpotMap("data.csv", case_color="#FF0000").build()
129
+ sm.save("map.html")
130
+
131
+ # Access the raw Folium map for further customisation
132
+ folium_map = sm.map
133
+ ```
134
+
135
+ ---
136
+
137
+ ## CLI reference
138
+
139
+ ```
140
+ usage: spotmap [-h] [-o OUTPUT] [--state-shp STATE_SHP]
141
+ [--district-shp DISTRICT_SHP] [--lat-col LAT_COL]
142
+ [--lon-col LON_COL] [--outcome-col OUTCOME_COL]
143
+ [--case-value CASE_VALUE] [--count-cutoff COUNT_CUTOFF]
144
+ [--cluster-color CLUSTER_COLOR] [--case-color CASE_COLOR]
145
+ [--control-color CONTROL_COLOR]
146
+ csv
147
+ ```
148
+
149
+ ---
150
+
151
+ ## License
152
+
153
+ MIT © Tharun Mallesan
@@ -0,0 +1,17 @@
1
+ spotmap/__init__.py,sha256=BlQMFe7VlEKl87-xreRDeHGX0jFsdUqzG22cOKjfQsE,283
2
+ spotmap/cli.py,sha256=_Ah-XNV-ogMhZkURmmEcSx5FB12Yq7bF09FLRGgfEWM,2417
3
+ spotmap/downloader.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ spotmap/exceptions.py,sha256=TEfnlO-ku_Tx-g1v2UK5v-bwfeabbsQD_IyKmCEkWqo,142
5
+ spotmap/layers.py,sha256=UqsBEhLfxlCg2wS7UNzYU1MLgJxrP8jkcvXgy9NT2CQ,4640
6
+ spotmap/loader.py,sha256=Q3ZaUv8Fm7wLttKhPc0gzKZ2hFQejsQBc_m0bjR6hbM,5076
7
+ spotmap/map_builder.py,sha256=rDQrt2kuu5cZvObWX6LeUfqlnaH1HnLV1zfFRn-M95Q,6715
8
+ spotmap/sidebar.py,sha256=tHp_HXZCzDUghxxWMC0uUAzkPLNjoWJk_JV85bvEcMw,12094
9
+ spotmap/spatial.py,sha256=2oS1TJD9PMIf-VMvbjuQmg72qWN1JU9Kw4WpULrmpbE,4613
10
+ spotmap/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ spotmap/data/district_boundary_lite.fgb,sha256=oorTqdpM0bsWvctho_q_BlL_GZOC4HidV5nKJqw6R9k,3436432
12
+ spotmap/data/state_boundary_lite.fgb,sha256=ATPx4_pgJornLF55TyCuNaGpnaFW4xE529w3CdzrsoQ,398000
13
+ spotmap-0.1.0.dist-info/METADATA,sha256=4K79gT7FQKfutiQoE5JfAKuEI-l3VRKRGbaGDcLke3w,4782
14
+ spotmap-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ spotmap-0.1.0.dist-info/entry_points.txt,sha256=EA3N1pqdmutcqtjoGKg4gCtzzLtb8zB3-3SJEsVSBiY,45
16
+ spotmap-0.1.0.dist-info/licenses/LICENSE,sha256=s3GroUjUSdtFQyA4mGVDAva2dUkdSa_7FhlKCFULGhk,1072
17
+ spotmap-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,2 @@
1
+ [console_scripts]
2
+ spotmap = spotmap.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tharun Mallesan
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.