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()