import folium from folium import plugins import json import os import pandas as pd from collections import Counter import folium from folium import plugins import json import os import pandas as pd from collections import Counter def load_case_data_from_csv(filter_year='all', filter_crime='all', filter_city='all'): """Load case data from a single cleaned CSV file.""" cleaned_csv_path = "/app/cleaned_data.csv" if not os.path.exists(cleaned_csv_path): raise FileNotFoundError(f"{cleaned_csv_path} not found.") # load 1 file saja data = pd.read_csv(cleaned_csv_path, on_bad_lines='skip') # normalisasi ke lowercase data_lower = data.map( lambda x: x.lower().strip() if isinstance(x, str) and x.strip() != "" else x ) # normalisasi nama pengadilan → ambil kota-nya saja if 'lembaga_peradilan' in data_lower.columns: data_lower['kota'] = ( data_lower['lembaga_peradilan'] .str.replace(r'^pn\s+', '', regex=True) .str.strip() .str.title() ) else: data_lower['kota'] = None # parsing tanggal if 'tanggal_musyawarah' in data_lower.columns: data_lower['tanggal'] = pd.to_datetime( data_lower['tanggal_musyawarah'], errors='coerce' ) data_lower['tahun_putusan'] = data_lower['tanggal'].dt.year.astype('Int64') df = data_lower.copy() # filter year if filter_year != 'all': try: year_val = int(filter_year) df = df[df['tahun_putusan'] == year_val] except: pass # filter crime if filter_crime != 'all' and 'kata_kunci' in df.columns: df = df[df['kata_kunci'] == filter_crime.lower()] # filter city if filter_city != 'all': df = df[df['kota'].str.lower() == filter_city.lower()] if df.empty: return {} # agregasi case_data = {} # group by kota grouped = df.groupby('kota') for kota, group in grouped: total_cases = len(group) # ambil top 10 kejahatan if 'kata_kunci' in group.columns: crime_counts = group['kata_kunci'].value_counts().head(10) else: crime_counts = {} cases = {crime.title(): int(count) for crime, count in crime_counts.items()} case_data[kota] = { 'total': total_cases, 'cases': cases, } return case_data def create_heatmap_interactive(filter_year='all', filter_crime='all', filter_city='all'): """Create an interactive Folium choropleth heatmap with click-to-zoom and case information. Args: filter_year: Filter by specific year or 'all' for all years filter_crime: Filter by specific crime type or 'all' for all crimes filter_city: Filter by specific city/kabupaten or 'all' for all cities Returns: str: HTML string for embedding the Folium map. """ # Load real case data from CSV with filters real_case_data = load_case_data_from_csv(filter_year=filter_year, filter_crime=filter_crime, filter_city=filter_city) # Load GeoJSON data with metric try: with open('app/static/geojson/jatim_kabkota_metric.geojson', encoding='utf-8') as f: geojson_data = json.load(f) except: # Fallback to original if metric version doesn't exist with open('data/geojson/jatim_kabkota.geojson', encoding='utf-8') as f: geojson_data = json.load(f) # Update features with real case data for feature in geojson_data['features']: kabupaten_name = feature['properties'].get('name', feature['properties'].get('NAMOBJ', '')) if kabupaten_name in real_case_data: # Use real data data = real_case_data[kabupaten_name] feature['properties']['metric'] = data['total'] feature['properties']['cases'] = data['cases'] feature['properties']['total_cases'] = data['total'] else: # Fallback jika tidak ada data feature['properties']['metric'] = 0 feature['properties']['cases'] = {} feature['properties']['total_cases'] = 0 # Calculate bounds from all features for Jawa Timur only all_bounds = [] for feature in geojson_data['features']: geom = feature['geometry'] if geom['type'] == 'Polygon': for coord in geom['coordinates'][0]: all_bounds.append([coord[1], coord[0]]) elif geom['type'] == 'MultiPolygon': for poly in geom['coordinates']: for coord in poly[0]: all_bounds.append([coord[1], coord[0]]) # Get min/max bounds for Jawa Timur if all_bounds: lats = [b[0] for b in all_bounds] lons = [b[1] for b in all_bounds] min_lat, max_lat = min(lats), max(lats) min_lon, max_lon = min(lons), max(lons) # Add small buffer (0.1 degrees) buffer = 0.1 bounds = [ [min_lat - buffer, min_lon - buffer], # Southwest [max_lat + buffer, max_lon + buffer] # Northeast ] else: # Fallback bounds for Jawa Timur bounds = [[-8.8, 111.0], [-6.0, 114.5]] # Create Folium map with restricted bounds m = folium.Map( location=[-7.5, 112.5], zoom_start=9, # Start zoom level 9 - nyaman lihat seluruh Jatim min_zoom=8, # Min zoom 8 - bisa lihat peta lebih luas sedikit max_zoom=13, # Max zoom 13 - cukup untuk detail tiles=None, # No tiles initially zoom_control=True, # Tampilkan tombol zoom scrollWheelZoom=False, # Matikan scroll wheel zoom - hanya pakai tombol prefer_canvas=True, max_bounds=True, min_lat=bounds[0][0], max_lat=bounds[1][0], min_lon=bounds[0][1], max_lon=bounds[1][1] ) # Add tiles only for Jawa Timur area using TileLayer with bounds folium.TileLayer( tiles='CartoDB positron', attr='CartoDB', name='Base Map', overlay=False, control=False, bounds=bounds ).add_to(m) # Don't use fit_bounds here - it will override zoom_start # Instead, we set zoom_start=9 above and let JavaScript handle bounds # Create choropleth layer choropleth = folium.Choropleth( geo_data=geojson_data, name='Legal Case Heatmap', data={f['properties']['name']: f['properties']['metric'] for f in geojson_data['features']}, columns=['name', 'metric'], key_on='feature.properties.name', fill_color='OrRd', fill_opacity=0.8, line_opacity=0.5, line_weight=1.5, legend_name='Number of Cases', highlight=True, ).add_to(m) # Add interactive tooltips and popups with click-to-zoom for feature in geojson_data['features']: props = feature['properties'] name = props.get('name', 'Unknown') total = props.get('total_cases', props.get('metric', 0)) cases = props.get('cases', {}) # Get centroid for marker lat = props.get('centroid_lat') lon = props.get('centroid_lon') # Create detailed popup content case_list = '
'.join([f'{k}: {v} case' for k, v in cases.items() if v > 0]) popup_html = f'''

{name}

Number of Cases: {total}
Detailed Information:
{case_list if case_list else 'No data'}
Click for zoom in
''' # Compact tooltip for hover - stays close to cursor tooltip_html = f'''
{name}
📊 Total: {total} case
''' # Add GeoJson layer with popup for each feature geo_json = folium.GeoJson( feature, name=name, style_function=lambda x: { 'fillColor': 'transparent', 'color': 'transparent', 'weight': 0, 'fillOpacity': 0 }, highlight_function=lambda x: { 'fillColor': '#ffeb3b', 'color': '#ff5722', 'weight': 3, 'fillOpacity': 0.7 }, tooltip=folium.Tooltip( tooltip_html, sticky=True, # Tooltip follows cursor closely style=""" background-color: transparent; border: none; box-shadow: none; padding: 0; margin: 0; """ ), popup=folium.Popup(popup_html, max_width=300) ) geo_json.add_to(m) # Add click event to zoom to feature bounds if lat and lon: # Calculate bounds from geometry geom = feature['geometry'] bounds = [] if geom['type'] == 'Polygon': for coord in geom['coordinates'][0]: bounds.append([coord[1], coord[0]]) elif geom['type'] == 'MultiPolygon': for poly in geom['coordinates']: for coord in poly[0]: bounds.append([coord[1], coord[0]]) if bounds: # Add invisible marker for click-to-zoom functionality marker = folium.Marker( location=[lat, lon], icon=folium.DivIcon(html=''), tooltip=None, popup=None ) # Add JavaScript for zoom on click bounds_str = str(bounds).replace("'", '"') marker_html = f''' ''' m.get_root().html.add_child(folium.Element(marker_html)) # # Add legend # legend_html = ''' #
#
# ''' # m.get_root().html.add_child(folium.Element(legend_html)) # Add custom CSS for better interactivity and sticky tooltip custom_css = ''' ''' m.get_root().html.add_child(folium.Element(custom_css)) map_name = m.get_name() # Add JavaScript to restrict panning to Jawa Timur bounds restrict_bounds_script = f''' ''' m.get_root().html.add_child(folium.Element(restrict_bounds_script)) return m._repr_html_()