from datetime import datetime
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from utils.kml_creator import (
DEFAULT_SITE_ICON,
generate_kml_from_df,
generate_site_kml_from_df,
kml_color_to_rgba,
sector_polygon_coordinates,
)
st.title(":material/map: Site & Sector KML Creator")
REQUIRED_SECTOR_COLUMNS = {
"code",
"name",
"Azimut",
"Longitude",
"Latitude",
"size",
"color",
}
SITE_ICON_OPTIONS = {
"Yellow pin": {
"href": "http://maps.google.com/mapfiles/kml/pushpin/ylw-pushpin.png",
"preview_color": "#FACC15",
"size": 16,
},
"Red pin": {
"href": "http://maps.google.com/mapfiles/kml/pushpin/red-pushpin.png",
"preview_color": "#DC2626",
"size": 16,
},
"Blue pin": {
"href": "http://maps.google.com/mapfiles/kml/pushpin/blue-pushpin.png",
"preview_color": "#2563EB",
"size": 16,
},
"Green pin": {
"href": "http://maps.google.com/mapfiles/kml/pushpin/grn-pushpin.png",
"preview_color": "#16A34A",
"size": 16,
},
"Purple pin": {
"href": "http://maps.google.com/mapfiles/kml/pushpin/purple-pushpin.png",
"preview_color": "#7C3AED",
"size": 16,
},
"Pink pin": {
"href": "http://maps.google.com/mapfiles/kml/pushpin/pink-pushpin.png",
"preview_color": "#DB2777",
"size": 16,
},
"Light blue pin": {
"href": "http://maps.google.com/mapfiles/kml/pushpin/ltblu-pushpin.png",
"preview_color": "#38BDF8",
"size": 16,
},
"Circle": {
"href": DEFAULT_SITE_ICON,
"preview_color": "#E4572E",
"size": 13,
},
"Target": {
"href": "http://maps.google.com/mapfiles/kml/shapes/target.png",
"preview_color": "#2563EB",
"size": 15,
},
"Square": {
"href": "http://maps.google.com/mapfiles/kml/shapes/square.png",
"preview_color": "#16A34A",
"size": 13,
},
"Triangle": {
"href": "http://maps.google.com/mapfiles/kml/shapes/triangle.png",
"preview_color": "#D97706",
"size": 14,
},
"Star": {
"href": "http://maps.google.com/mapfiles/kml/shapes/star.png",
"preview_color": "#CA8A04",
"size": 15,
},
"Info": {
"href": "http://maps.google.com/mapfiles/kml/shapes/info-i.png",
"preview_color": "#7C3AED",
"size": 14,
},
}
def _timestamp() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _read_uploaded_table(uploaded_file) -> pd.DataFrame:
suffix = Path(uploaded_file.name).suffix.lower()
if suffix == ".csv":
return pd.read_csv(uploaded_file, keep_default_na=False)
return pd.read_excel(uploaded_file, keep_default_na=False)
def _default_column(columns: list[str], candidates: list[str]) -> str:
normalized = {str(col).strip().lower(): col for col in columns}
for candidate in candidates:
if candidate.lower() in normalized:
return normalized[candidate.lower()]
for candidate in candidates:
for col in columns:
if candidate.lower() in str(col).strip().lower():
return col
return columns[0]
def _prepare_coordinate_df(df: pd.DataFrame, lat_col: str, lon_col: str) -> pd.DataFrame:
map_df = df.copy()
map_df[lat_col] = pd.to_numeric(map_df[lat_col], errors="coerce")
map_df[lon_col] = pd.to_numeric(map_df[lon_col], errors="coerce")
map_df = map_df.dropna(subset=[lat_col, lon_col])
map_df = map_df[
map_df[lat_col].between(-90, 90) & map_df[lon_col].between(-180, 180)
]
return map_df
def _estimate_zoom(df: pd.DataFrame, lat_col: str, lon_col: str) -> float:
if df.empty:
return 5
lat_span = float(df[lat_col].max() - df[lat_col].min())
lon_span = float(df[lon_col].max() - df[lon_col].min())
span = max(lat_span, lon_span)
if span <= 0.01:
return 13
if span <= 0.05:
return 11
if span <= 0.2:
return 9
if span <= 1:
return 7
if span <= 5:
return 5
return 3
def _rgba_css(rgba: list[int], alpha_override: float | None = None) -> str:
red, green, blue, alpha = rgba
alpha_float = alpha / 255 if alpha_override is None else alpha_override
return f"rgba({red}, {green}, {blue}, {alpha_float:.3f})"
def _hover_text(row: pd.Series) -> str:
return "
".join(
f"{column}: {value}" for column, value in row.items()
)
def _show_site_position_map(
df: pd.DataFrame,
site_col: str,
lat_col: str,
lon_col: str,
title: str,
show_labels: bool = True,
marker_color: str = "#E4572E",
marker_size: int = 13,
) -> None:
map_df = _prepare_coordinate_df(df, lat_col, lon_col)
if map_df.empty:
st.warning("No valid coordinates available for map display.")
return
fig = go.Figure()
mode = "markers+text" if show_labels else "markers"
fig.add_trace(
go.Scattermapbox(
lat=map_df[lat_col],
lon=map_df[lon_col],
mode=mode,
text=map_df[site_col].astype(str),
textposition="top center",
marker={"size": marker_size, "color": marker_color},
hovertext=map_df.apply(_hover_text, axis=1),
hoverinfo="text",
name="Sites",
)
)
fig.update_layout(
title=title,
mapbox_style="open-street-map",
mapbox_center={
"lat": float(map_df[lat_col].mean()),
"lon": float(map_df[lon_col].mean()),
},
mapbox_zoom=_estimate_zoom(map_df, lat_col, lon_col),
height=620,
margin={"r": 0, "t": 45, "l": 0, "b": 0},
)
st.plotly_chart(fig, use_container_width=True)
def _show_sector_map(df: pd.DataFrame, show_labels: bool = True) -> None:
map_df = _prepare_coordinate_df(df, "Latitude", "Longitude")
if map_df.empty:
st.warning("No valid sector coordinates available for map display.")
return
numeric_cols = ["Azimut", "size"]
for col in numeric_cols:
map_df[col] = pd.to_numeric(map_df[col], errors="coerce")
map_df = map_df.dropna(subset=numeric_cols)
if map_df.empty:
st.warning("No valid azimuth/size values available for sector map display.")
return
fig = go.Figure()
df_sorted = map_df.sort_values(by="size", ascending=False)
for _, row in df_sorted.iterrows():
coords = sector_polygon_coordinates(row)
lons = [coord[0] for coord in coords]
lats = [coord[1] for coord in coords]
rgba = kml_color_to_rgba(row["color"])
fig.add_trace(
go.Scattermapbox(
lon=lons,
lat=lats,
mode="lines",
fill="toself",
fillcolor=_rgba_css(rgba),
line={"color": "black", "width": 1},
hovertext=_hover_text(row),
hoverinfo="text",
name=str(row["name"]),
showlegend=False,
)
)
site_df = map_df.drop_duplicates(subset=["code"]).copy()
mode = "markers+text" if show_labels else "markers"
fig.add_trace(
go.Scattermapbox(
lat=site_df["Latitude"],
lon=site_df["Longitude"],
mode=mode,
text=site_df["code"].astype(str),
textposition="top center",
marker={"size": 10, "color": "#111111"},
hovertext=site_df.apply(_hover_text, axis=1),
hoverinfo="text",
name="Sites",
)
)
fig.update_layout(
title="Sector map preview",
mapbox_style="open-street-map",
mapbox_center={
"lat": float(map_df["Latitude"].mean()),
"lon": float(map_df["Longitude"].mean()),
},
mapbox_zoom=_estimate_zoom(map_df, "Latitude", "Longitude"),
height=650,
margin={"r": 0, "t": 45, "l": 0, "b": 0},
)
st.plotly_chart(fig, use_container_width=True)
def _render_sector_help() -> None:
col1, col2 = st.columns(2)
with col1:
st.write("Mandatory columns:")
st.markdown(
"""
| Column Name | Description |
| --- | --- |
| code | Code of the site |
| name | Name of the sector |
| Azimut | Azimuth of the sector |
| Longitude | Longitude of the sector |
| Latitude | Latitude of the sector |
| size | Size of the sector, for example `100` |
| color | KML color code |
"""
)
st.write(
"All other columns added in the file will be displayed in the KML description for each sector."
)
with col2:
st.markdown(
"""
| Color Name | KML Color Code (AABBGGRR) |
| --- | --- |
| Red | 7f0000ff |
| Green | 7f00ff00 |
| Blue | 7fff0000 |
| Yellow | 7f00ffff |
| Cyan | 7fffff00 |
| Magenta | 7fff00ff |
| Orange | 7f007fff |
| Purple | 7f7f00ff |
| Pink | 7fcc99ff |
| Brown | 7f2a2aa5 |
"""
)
def _render_sector_generator() -> None:
_render_sector_help()
sector_kml_sample_file = "samples/Sector_kml.xlsx"
st.download_button(
label="Download Sector KML sample File",
data=open(sector_kml_sample_file, "rb").read(),
file_name="Sector_kml.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
st.write("Upload an Excel file containing sectors data to generate a KML file.")
uploaded_file = st.file_uploader(
"Upload sector XLSX file", type=["xlsx"], key="sector_kml_file"
)
if uploaded_file is None:
return
df = pd.read_excel(uploaded_file, keep_default_na=False)
missing_columns = REQUIRED_SECTOR_COLUMNS.difference(df.columns)
if missing_columns:
st.error(f"Uploaded file must contain columns: {', '.join(sorted(missing_columns))}")
return
kml_data = generate_kml_from_df(df)
st.download_button(
label="Download Sector KML",
data=kml_data,
file_name=f"Sectors_kml_{_timestamp()}.kml",
mime="application/vnd.google-earth.kml+xml",
)
st.success("Sector KML file generated successfully.")
st.dataframe(df.head(100), use_container_width=True)
show_labels = st.checkbox("Show site labels on map", value=True, key="sector_labels")
_show_sector_map(df, show_labels=show_labels)
def _render_site_position_generator() -> None:
st.write(
"Upload an Excel or CSV file containing site positions. Minimum required data: site name/code, latitude, longitude."
)
uploaded_file = st.file_uploader(
"Upload site position file", type=["xlsx", "csv"], key="site_position_file"
)
if uploaded_file is None:
return
df = _read_uploaded_table(uploaded_file)
if df.empty:
st.warning("Uploaded file is empty.")
return
columns = df.columns.tolist()
col1, col2, col3, col4 = st.columns(4)
with col1:
site_col = st.selectbox(
"Site column",
columns,
index=columns.index(_default_column(columns, ["site", "code", "name", "site_code"])),
)
with col2:
lat_col = st.selectbox(
"Latitude column",
columns,
index=columns.index(_default_column(columns, ["Latitude", "lat", "y"])),
)
with col3:
lon_col = st.selectbox(
"Longitude column",
columns,
index=columns.index(_default_column(columns, ["Longitude", "lon", "lng", "x"])),
)
with col4:
icon_name = st.selectbox(
"Site icon",
list(SITE_ICON_OPTIONS.keys()),
index=0,
)
map_df = _prepare_coordinate_df(df, lat_col, lon_col)
if map_df.empty:
st.warning("No valid latitude/longitude rows found after cleaning.")
return
icon_config = SITE_ICON_OPTIONS[icon_name]
kml_data = generate_site_kml_from_df(
map_df,
site_col,
lat_col,
lon_col,
icon_href=icon_config["href"],
)
st.download_button(
label="Download Site Position KML",
data=kml_data,
file_name=f"Site_positions_{_timestamp()}.kml",
mime="application/vnd.google-earth.kml+xml",
)
st.success(f"Site position KML generated successfully for {len(map_df)} valid sites.")
st.dataframe(map_df.head(100), use_container_width=True)
show_labels = st.checkbox("Show site labels on map", value=True, key="site_labels")
_show_site_position_map(
map_df,
site_col=site_col,
lat_col=lat_col,
lon_col=lon_col,
title="Site position map preview",
show_labels=show_labels,
marker_color=icon_config["preview_color"],
marker_size=icon_config["size"],
)
tab_sectors, tab_sites = st.tabs(["Sectors", "Site positions"])
with tab_sectors:
_render_sector_generator()
with tab_sites:
_render_site_position_generator()