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