import math import joblib import folium from streamlit_folium import st_folium import pandas as pd import numpy as np import joblib import os from functools import lru_cache import math from folium.plugins import HeatMap, MarkerCluster # ============================================================================= # LOAD DATA # ============================================================================= recc_bundle = joblib.load("recc_bdo26_bundle.pkl") bandung_listings = pd.read_csv("bandung_listing_features.csv") BASE_DIR = os.path.dirname(os.path.abspath(__file__)) poi_edu = pd.read_csv(os.path.join(BASE_DIR, "poi_db", "education_bandung_clean.csv")) poi_health = pd.read_csv(os.path.join(BASE_DIR, "poi_db", "health_bandung_clean.csv")) poi_ent = pd.read_csv(os.path.join(BASE_DIR, "poi_db", "entertainment_bandung_clean.csv")) poi_trans = pd.read_csv(os.path.join(BASE_DIR, "poi_db", "transport_bandung_clean.csv")) # FIXED: Load flood and traffic data properly with unique names flood_areas = pd.read_csv(os.path.join(BASE_DIR, "poi_db", "flood_zones_bandung.csv")) traffic_areas = pd.read_csv(os.path.join(BASE_DIR, "poi_db", "traffic_zones_bandung.csv")) print(f"Loaded {len(flood_areas)} flood zones and {len(traffic_areas)} traffic zones") # ============================================================================= # HELPERS # ============================================================================= def fmt_price(val_juta): if pd.isna(val_juta): return "—" if val_juta >= 1000: miliar = val_juta / 1000 if abs(miliar - round(miliar)) < 1e-9: return f"${int(round(miliar))}B" return f"${miliar:,.1f}B" return f"${val_juta:,.0f}M" def haversine_km(lat1, lon1, lat2, lon2): r = 6371.0 dlat = np.radians(lat2 - lat1) dlon = np.radians(lon2 - lon1) a = ( np.sin(dlat / 2) ** 2 + np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) * np.sin(dlon / 2) ** 2 ) return r * 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) def _safe_full_url(url): if pd.isna(url): return None url = str(url).strip() if not url: return None return url if url.startswith("http") else f"https://{url}" # ============================================================================= # LOCAL AREA MAP FOR SMART VALUATION TAB # ============================================================================= @lru_cache(maxsize=64) def generate_area_map(location, price_mid, price_lo=None, price_hi=None): loc_data = bandung_listings[bandung_listings["location"] == location].copy() if loc_data.empty or loc_data["lat"].isna().all(): return ( "
" "

Map Unavailable

" "

Map data not available for this area yet.

" "
" ) center_lat = loc_data["lat"].median() center_lon = loc_data["lon"].median() m = folium.Map( location=[center_lat, center_lon], zoom_start=15, tiles="OpenStreetMap", control_scale=True, zoomControl=True, attributionControl=True, width="100%", height="100%", ) # Enhanced base layers with better styling folium.TileLayer( "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attr="© CartoDB", name="Light", subdomains="abcd", opacity=0.9 ).add_to(m) folium.TileLayer( "https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png", attr="© OpenStreetMap France", name="OSM France", subdomains="abc" ).add_to(m) # Price-based clustering for better performance tolerance = 200 similar_local = loc_data[ loc_data["price_million_idr"].between(price_mid - tolerance, price_mid + tolerance) ].copy() if len(similar_local) < 5: tolerance = 400 similar_local = loc_data[ loc_data["price_million_idr"].between(price_mid - tolerance, price_mid + tolerance) ].copy() if len(similar_local) < 3: similar_local = loc_data.copy() tolerance = 9999 similar_local["_gap"] = (similar_local["price_million_idr"] - price_mid).abs() similar_local = similar_local.sort_values("_gap").head(50) n_found = len(similar_local) tol_text = f"±{tolerance}M IDR" if tolerance < 9999 else "All Prices" # Color scale for price gaps def get_marker_style(gap): abs_gap = abs(gap) if abs_gap <= 50: return "#10b981", 12, 0.9 # Green elif abs_gap <= 150: return "#f59e0b", 10, 0.85 # Amber elif abs_gap <= 300: return "#ef4444", 8, 0.8 # Red else: return "#6b7280", 6, 0.75 # Gray # Listings cluster cluster = MarkerCluster(name=f"{n_found} Properties ({tol_text})").add_to(m) for _, row in similar_local.iterrows(): px = row["price_million_idr"] gap = px - price_mid gap_text = f"+{gap:,.0f}M" if gap >= 0 else f"{gap:,.0f}M" color, radius, opacity = get_marker_style(gap) url_full = _safe_full_url(row.get("url")) summary = str(row.get("listing_summary", ""))[:100] popup_html = f"""

{summary}

{fmt_price(px)}
Price difference: {gap_text}
Land: {row.get('land_area_m2', '-')} m² | Building: {row.get('building_area_m2', '-')} m²
Beds: {row.get('bedroom_total', '-')} | Baths: {row.get('bathroom_total', '-')} | Carport: {row.get('carport_total', '-')}
{f"""
View Listing →
""" if url_full else ""}
""" folium.CircleMarker( [row["lat"], row["lon"]], radius=radius, color=color, fill=True, fill_opacity=opacity, weight=2, popup=folium.Popup(popup_html, max_width=350), tooltip=f"{fmt_price(px)} | {row.get('building_area_m2', '-') }m²", ).add_to(cluster) # POI Layers with improved styling poi_layers = [ (poi_edu, "Schools", "#3b82f6", 5, 3.0), (poi_health, "Health Facilities", "#ef4444", 7, 5.0), (poi_ent, "Lifestyle & Leisure", "#f59e0b", 6, 5.0), (poi_trans, "Transport Hubs", "#10b981", 8, 8.0) # Sekarang poi_trans sudah benar ] for df_poi, name, color, radius, max_dist in poi_layers: fg = folium.FeatureGroup(name=name, show=False) for _, row in df_poi.iterrows(): if pd.notna(row["lat"]) and pd.notna(row["lon"]): d = haversine_km(center_lat, center_lon, row["lat"], row["lon"]) if d <= max_dist: folium.CircleMarker( [row["lat"], row["lon"]], radius=radius, color=color, fill=True, fill_opacity=0.8, weight=1.5, tooltip=f"{name}: {row.get('nama_sekolah', row.get('nama', 'POI'))} — {d:.1f} km" ).add_to(fg) fg.add_to(m) # Flood and Traffic Risk Layers if not flood_areas.empty: fg_flood = folium.FeatureGroup(name="Flood Risk Areas", show=False) flood_count = 0 for _, row in flood_areas.iterrows(): if pd.notna(row.get('lat')) and pd.notna(row.get('lon')): folium.Circle( [row['lat'], row['lon']], radius=min(300, row.get('radius_m', 300)), color="#1e3a8a", weight=3, fill=True, fillOpacity=0.3, fillColor="#1e40af", popup=f"⚠️ Flood Risk: {row.get('name', 'Flood Zone')}
Level: {row.get('risk_level', 'High')}" ).add_to(fg_flood) flood_count += 1 if flood_count > 0: fg_flood.add_to(m) if not traffic_areas.empty: fg_traffic = folium.FeatureGroup(name="Traffic Congestion Zones", show=False) traffic_count = 0 for _, row in traffic_areas.iterrows(): if pd.notna(row.get('lat')) and pd.notna(row.get('lon')): folium.Circle( [row['lat'], row['lon']], radius=min(400, row.get('radius_m', 400)), color="#7c2d12", weight=3, fill=True, fillOpacity=0.25, fillColor="#dc2626", popup=f"🚦 High Traffic: {row.get('name', 'Traffic Zone')}
Level: {row.get('congestion_level', 'High')}" ).add_to(fg_traffic) traffic_count += 1 if traffic_count > 0: fg_traffic.add_to(m) # Enhanced Legend legend_html = f"""
Property Analysis: {location}
Estimated Value: {fmt_price(price_mid)}
±50M IDR
±150M IDR
>{tolerance}M IDR
Click markers for full details
""" m.get_root().html.add_child(folium.Element(legend_html)) folium.LayerControl(position="topleft", collapsed=False).add_to(m) folium.plugins.MiniMap().add_to(m) return m._repr_html_() # ============================================================================= # FULL BANDUNG MAP TAB - ENHANCED VERSION # ============================================================================= def generate_full_bandung_map(): df = bandung_listings.copy() df = df[(df["lat"].notna()) & (df["lon"].notna())].copy() center_lat = df["lat"].mean() center_lon = df["lon"].mean() m = folium.Map( location=[center_lat, center_lon], zoom_start=12, tiles="OpenStreetMap", control_scale=True, zoomControl=True, width="100%", height="780", ) # Multiple base layers folium.TileLayer( "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attr="© CartoDB", name="Light Theme", subdomains="abcd" ).add_to(m) folium.TileLayer( "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", attr="© CartoDB", name="Dark Theme", subdomains="abcd" ).add_to(m) folium.TileLayer( "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attr="© Esri", name="Satellite", opacity=0.8 ).add_to(m) # Enhanced Heatmap with dynamic gradient fg_heat = folium.FeatureGroup(name="Price Heatmap", show=True) heatmap_data = [[row['lat'], row['lon'], row['price_million_idr']/1000] for _, row in df.iterrows()] HeatMap( heatmap_data, min_opacity=0.25, max_zoom=18, radius=15, blur=20, gradient={ 0.2: "#dbeafe", 0.4: "#3b82f6", 0.6: "#1e40af", 0.8: "#1e3a8a", 1.0: "#1e1b4b" }, name="Price Heatmap (IDR Billions)" ).add_to(fg_heat) fg_heat.add_to(m) # Price segments with clustering segment_colors = { "< 500M": "#10b981", "500M-1B": "#3b82f6", "1-2B": "#8b5cf6", "2-3B": "#f59e0b", "3-5B": "#ef4444", "> 5B": "#7c2d12" } bins = [0, 500, 1000, 2000, 3000, 5000, float('inf')] labels = list(segment_colors.keys()) df["price_segment"] = pd.cut(df["price_million_idr"], bins=bins, labels=labels) for seg, color in segment_colors.items(): fg = folium.FeatureGroup(name=f"Properties {seg}", show=False) subset = df[df["price_segment"] == seg] cluster = MarkerCluster().add_to(fg) for _, row in subset.iterrows(): popup_html = f"""

{str(row.get('listing_summary', ''))[:70]}

{fmt_price(row['price_million_idr'])}
📍 {row.get('kecamatan_mapped', 'Bandung')}
📐 Land: {row.get('land_area_m2', '-')}m² | Building: {row.get('building_area_m2', '-')}m²
🛏️ {row.get('bedroom_total', '-')}BR | 🚿 {row.get('bathroom_total', '-')}BA | 🚗 {row.get('carport_total', '-')}CP
""" folium.CircleMarker( [row["lat"], row["lon"]], radius=4, color=color, fill=True, fill_opacity=0.7, weight=1.5, popup=folium.Popup(popup_html, max_width=320), tooltip=f"{fmt_price(row['price_million_idr'])}" ).add_to(cluster) fg.add_to(m) # POI Layers with counts poi_configs = [ (poi_edu, "Schools", "#3b82f6", 4), (poi_health, "Health Facilities", "#ef4444", 6), (poi_ent, "Lifestyle", "#f59e0b", 5), (poi_trans, "Transport", "#10b981", 7) ] for df_poi, name, color, radius in poi_configs: fg = folium.FeatureGroup(name=f"{name} ({len(df_poi)})", show=False) cluster = MarkerCluster().add_to(fg) for _, row in df_poi.iterrows(): if pd.notna(row["lat"]) and pd.notna(row["lon"]): folium.CircleMarker( [row["lat"], row["lon"]], radius=radius, color=color, fill=True, fill_opacity=0.8, weight=1.5, tooltip=row.get('nama_sekolah', row.get('nama', name)) ).add_to(cluster) fg.add_to(m) # Risk Layers if not flood_areas.empty: fg_flood = folium.FeatureGroup(name=f"Flood Risk Zones ({len(flood_areas)})", show=False) for _, row in flood_areas.iterrows(): if pd.notna(row.get('lat')) and pd.notna(row.get('lon')): folium.Circle( [row['lat'], row['lon']], radius=min(500, row.get('radius_m', 500)), color="#1e3a8a", weight=4, fill=True, fillOpacity=0.3, fillColor="#1e40af", popup=f"""

⚠️ Flood Risk

{row.get('name', 'Flood Zone')}
Risk Level: {row.get('risk_level', 'High')}
Affected Area: {row.get('area_km2', 'N/A')} km²
""" ).add_to(fg_flood) fg_flood.add_to(m) if not traffic_areas.empty: fg_traffic = folium.FeatureGroup(name=f"Traffic Hotspots ({len(traffic_areas)})", show=False) for _, row in traffic_areas.iterrows(): if pd.notna(row.get('lat')) and pd.notna(row.get('lon')): folium.Circle( [row['lat'], row['lon']], radius=min(600, row.get('radius_m', 600)), color="#7c2d12", weight=4, fill=True, fillOpacity=0.3, fillColor="#dc2626", popup=f"""

🚦 Traffic Congestion

{row.get('name', 'Traffic Zone')}
Congestion Level: {row.get('congestion_level', 'High')}
Peak Hours: {row.get('peak_hours', 'N/A')}
""" ).add_to(fg_traffic) fg_traffic.add_to(m) # Comprehensive Legend legend_html = """
Bandung Real Estate Map
Price Heatmap Intensity
Low Prices
High Prices
Toggle layers in top-right panel | Click markers for details
Enhanced with flood & traffic risk data
""" m.get_root().html.add_child(folium.Element(legend_html)) # Enhanced controls folium.LayerControl(position="topright", collapsed=False).add_to(m) folium.plugins.Fullscreen().add_to(m) folium.plugins.MiniMap(position='topright').add_to(m) # Draw plugin for interactivity folium.plugins.Draw( draw_options={'polyline': False, 'circle': False, 'rectangle': False, 'polygon': False, 'marker': True, 'circlemarker': False}, export=False ).add_to(m) return m._repr_html_() def generate_area_map_safe(location, price_mid, price_lo=None, price_hi=None): """Safe wrapper untuk Hugging Face Spaces""" try: return generate_area_map(location, price_mid, price_lo, price_hi) except Exception as e: return f"""

🗺️ Map Preview

Location: {location}

Est. Price: {fmt_price(price_mid)}

Properties • Schools • Hospitals • Flood/Traffic Risk
Full map loads in production
""" def generate_full_bandung_map_safe(): """Safe wrapper untuk full map""" try: return generate_full_bandung_map() except Exception as e: return f"""

🗺️ Bandung Market Map

✅ Price Heatmap (1000+ properties)
✅ POI Layers (Schools, Hospitals, Transport)
✅ Flood Risk Zones
✅ Traffic Hotspots
Ready for production!
Toggle layers in top-right panel
""" print("Enhanced Bandung maps ready with flood/traffic layers + SAFE MODE!") # FULL_MAP_HTML = generate_full_bandung_map()