|
|
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.") |
|
|
|
|
|
|
|
|
data = pd.read_csv(cleaned_csv_path, on_bad_lines='skip') |
|
|
|
|
|
|
|
|
data_lower = data.map( |
|
|
lambda x: x.lower().strip() if isinstance(x, str) and x.strip() != "" else x |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
if filter_year != 'all': |
|
|
try: |
|
|
year_val = int(filter_year) |
|
|
df = df[df['tahun_putusan'] == year_val] |
|
|
except: |
|
|
pass |
|
|
|
|
|
|
|
|
if filter_crime != 'all' and 'kata_kunci' in df.columns: |
|
|
df = df[df['kata_kunci'] == filter_crime.lower()] |
|
|
|
|
|
|
|
|
if filter_city != 'all': |
|
|
df = df[df['kota'].str.lower() == filter_city.lower()] |
|
|
|
|
|
if df.empty: |
|
|
return {} |
|
|
|
|
|
|
|
|
case_data = {} |
|
|
|
|
|
|
|
|
grouped = df.groupby('kota') |
|
|
|
|
|
for kota, group in grouped: |
|
|
total_cases = len(group) |
|
|
|
|
|
|
|
|
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. |
|
|
""" |
|
|
|
|
|
real_case_data = load_case_data_from_csv(filter_year=filter_year, filter_crime=filter_crime, filter_city=filter_city) |
|
|
|
|
|
|
|
|
try: |
|
|
with open('app/static/geojson/jatim_kabkota_metric.geojson', encoding='utf-8') as f: |
|
|
geojson_data = json.load(f) |
|
|
except: |
|
|
|
|
|
with open('data/geojson/jatim_kabkota.geojson', encoding='utf-8') as f: |
|
|
geojson_data = json.load(f) |
|
|
|
|
|
|
|
|
for feature in geojson_data['features']: |
|
|
kabupaten_name = feature['properties'].get('name', feature['properties'].get('NAMOBJ', '')) |
|
|
|
|
|
if kabupaten_name in real_case_data: |
|
|
|
|
|
data = real_case_data[kabupaten_name] |
|
|
feature['properties']['metric'] = data['total'] |
|
|
feature['properties']['cases'] = data['cases'] |
|
|
feature['properties']['total_cases'] = data['total'] |
|
|
else: |
|
|
|
|
|
feature['properties']['metric'] = 0 |
|
|
feature['properties']['cases'] = {} |
|
|
feature['properties']['total_cases'] = 0 |
|
|
|
|
|
|
|
|
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]]) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
buffer = 0.1 |
|
|
bounds = [ |
|
|
[min_lat - buffer, min_lon - buffer], |
|
|
[max_lat + buffer, max_lon + buffer] |
|
|
] |
|
|
else: |
|
|
|
|
|
bounds = [[-8.8, 111.0], [-6.0, 114.5]] |
|
|
|
|
|
|
|
|
m = folium.Map( |
|
|
location=[-7.5, 112.5], |
|
|
zoom_start=9, |
|
|
min_zoom=8, |
|
|
max_zoom=13, |
|
|
tiles=None, |
|
|
zoom_control=True, |
|
|
scrollWheelZoom=False, |
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
folium.TileLayer( |
|
|
tiles='CartoDB positron', |
|
|
attr='CartoDB', |
|
|
name='Base Map', |
|
|
overlay=False, |
|
|
control=False, |
|
|
bounds=bounds |
|
|
).add_to(m) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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', {}) |
|
|
|
|
|
|
|
|
lat = props.get('centroid_lat') |
|
|
lon = props.get('centroid_lon') |
|
|
|
|
|
|
|
|
case_list = '<br>'.join([f'<strong>{k}:</strong> {v} case' for k, v in cases.items() if v > 0]) |
|
|
|
|
|
popup_html = f''' |
|
|
<div style="font-family: Arial, sans-serif; width: 280px;"> |
|
|
<h3 style="margin: 0 0 10px 0; |
|
|
color: #2C5F8D; |
|
|
border-bottom: 2px solid #2C5F8D; |
|
|
padding-bottom: 5px;"> |
|
|
{name} |
|
|
</h3> |
|
|
<div style="margin-bottom: 10px;"> |
|
|
<strong style="font-size: 16px; color: #d32f2f;"> |
|
|
Number of Cases: {total} |
|
|
</strong> |
|
|
</div> |
|
|
<div style="margin-top: 10px;"> |
|
|
<strong>Detailed Information:</strong><br> |
|
|
<div style="margin-top: 8px; |
|
|
font-size: 13px; |
|
|
line-height: 1.6; |
|
|
background: #f5f5f5; |
|
|
padding: 10px; |
|
|
border-radius: 5px;"> |
|
|
{case_list if case_list else '<em>No data</em>'} |
|
|
</div> |
|
|
</div> |
|
|
<div style="margin-top: 12px; |
|
|
padding-top: 10px; |
|
|
border-top: 1px solid #ddd; |
|
|
font-size: 11px; |
|
|
color: #666;"> |
|
|
<em>Click for zoom in</em> |
|
|
</div> |
|
|
</div> |
|
|
''' |
|
|
|
|
|
|
|
|
tooltip_html = f''' |
|
|
<div style="font-family: Arial, sans-serif; |
|
|
padding: 8px 12px; |
|
|
background: rgba(44, 95, 141, 0.95); |
|
|
color: white; |
|
|
border-radius: 5px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3); |
|
|
font-size: 13px; |
|
|
white-space: nowrap; |
|
|
border: 2px solid white;"> |
|
|
<strong style="font-size: 14px;">{name}</strong><br> |
|
|
<span style="font-size: 12px;">📊 Total: {total} case</span> |
|
|
</div> |
|
|
''' |
|
|
|
|
|
|
|
|
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, |
|
|
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) |
|
|
|
|
|
|
|
|
if lat and lon: |
|
|
|
|
|
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: |
|
|
|
|
|
marker = folium.Marker( |
|
|
location=[lat, lon], |
|
|
icon=folium.DivIcon(html=''), |
|
|
tooltip=None, |
|
|
popup=None |
|
|
) |
|
|
|
|
|
|
|
|
bounds_str = str(bounds).replace("'", '"') |
|
|
marker_html = f''' |
|
|
<script> |
|
|
var bounds_{name.replace(" ", "_").replace(".", "")} = {bounds_str}; |
|
|
</script> |
|
|
''' |
|
|
m.get_root().html.add_child(folium.Element(marker_html)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
custom_css = ''' |
|
|
<style> |
|
|
.leaflet-container { |
|
|
background-color: #e0e0e0 !important; |
|
|
cursor: pointer !important; |
|
|
} |
|
|
/* Tooltip styling - stays very close to cursor */ |
|
|
.leaflet-tooltip { |
|
|
background-color: transparent !important; |
|
|
border: none !important; |
|
|
box-shadow: none !important; |
|
|
padding: 0 !important; |
|
|
margin: 0 !important; |
|
|
pointer-events: none !important; |
|
|
} |
|
|
.leaflet-tooltip-top { |
|
|
margin-top: -5px !important; |
|
|
} |
|
|
.leaflet-tooltip-left { |
|
|
margin-left: -5px !important; |
|
|
} |
|
|
.leaflet-tooltip-right { |
|
|
margin-left: 5px !important; |
|
|
} |
|
|
.leaflet-tooltip-bottom { |
|
|
margin-top: 5px !important; |
|
|
} |
|
|
/* Hide default tooltip pointer */ |
|
|
.leaflet-tooltip-top:before, |
|
|
.leaflet-tooltip-bottom:before, |
|
|
.leaflet-tooltip-left:before, |
|
|
.leaflet-tooltip-right:before { |
|
|
display: none !important; |
|
|
} |
|
|
/* Hide tiles outside bounds */ |
|
|
.leaflet-tile-container { |
|
|
clip-path: inset(0); |
|
|
} |
|
|
.leaflet-interactive:hover { |
|
|
stroke: #ff5722 !important; |
|
|
stroke-width: 2px !important; |
|
|
stroke-opacity: 1 !important; |
|
|
} |
|
|
.leaflet-popup-content-wrapper { |
|
|
border-radius: 8px !important; |
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important; |
|
|
} |
|
|
.leaflet-popup-tip { |
|
|
display: none !important; |
|
|
} |
|
|
/* Add border around Jawa Timur */ |
|
|
.leaflet-overlay-pane svg { |
|
|
filter: drop-shadow(0 0 3px rgba(0,0,0,0.3)); |
|
|
} |
|
|
</style> |
|
|
''' |
|
|
m.get_root().html.add_child(folium.Element(custom_css)) |
|
|
|
|
|
map_name = m.get_name() |
|
|
|
|
|
|
|
|
restrict_bounds_script = f''' |
|
|
<script> |
|
|
// Restrict map to Jawa Timur bounds only |
|
|
document.addEventListener('DOMContentLoaded', function() {{ |
|
|
setTimeout(function() {{ |
|
|
// Get the Leaflet map instance |
|
|
var mapElement = window.{map_name}; |
|
|
if (mapElement && mapElement._leaflet_id) {{ |
|
|
var map = mapElement; |
|
|
|
|
|
// Set max bounds for Jawa Timur |
|
|
var bounds = L.latLngBounds( |
|
|
L.latLng({bounds[0][0]}, {bounds[0][1]}), // Southwest |
|
|
L.latLng({bounds[1][0]}, {bounds[1][1]}) // Northeast |
|
|
); |
|
|
|
|
|
// Strict bounds - cannot pan outside |
|
|
//map.setMaxBounds(bounds); |
|
|
//map.options.maxBoundsViscosity = 0.6; // Make bounds completely rigid |
|
|
|
|
|
|
|
|
// Set zoom constraints directly on map options |
|
|
map.options.minZoom = 8; |
|
|
map.options.maxZoom = 13; |
|
|
|
|
|
// Remove existing zoom control and add new one with correct limits |
|
|
if (map.zoomControl) {{ |
|
|
map.removeControl(map.zoomControl); |
|
|
}} |
|
|
L.control.zoom({{ position: 'topleft' }}).addTo(map); |
|
|
|
|
|
// Enforce zoom limits on all zoom events |
|
|
map.on('zoom', function() {{ |
|
|
var currentZoom = map.getZoom(); |
|
|
if (currentZoom < 8) {{ |
|
|
map.setZoom(8, {{ animate: false }}); |
|
|
return false; |
|
|
}} else if (currentZoom > 13) {{ |
|
|
map.setZoom(13, {{ animate: false }}); |
|
|
return false; |
|
|
}} |
|
|
}}); |
|
|
|
|
|
// Also on zoomend to catch any missed events |
|
|
map.on('zoomend', function() {{ |
|
|
var currentZoom = map.getZoom(); |
|
|
if (currentZoom < 8) {{ |
|
|
map.setZoom(8, {{ animate: false }}); |
|
|
}} else if (currentZoom > 13) {{ |
|
|
map.setZoom(13, {{ animate: false }}); |
|
|
}} |
|
|
updateZoomControl(); |
|
|
}}); |
|
|
|
|
|
// Update zoom control state |
|
|
function updateZoomControl() {{ |
|
|
var zoom = map.getZoom(); |
|
|
var zoomInButton = document.querySelector('.leaflet-control-zoom-in'); |
|
|
var zoomOutButton = document.querySelector('.leaflet-control-zoom-out'); |
|
|
|
|
|
if (zoomInButton) {{ |
|
|
if (zoom >= 13) {{ |
|
|
zoomInButton.classList.add('leaflet-disabled'); |
|
|
zoomInButton.style.cursor = 'not-allowed'; |
|
|
zoomInButton.style.opacity = '0.4'; |
|
|
zoomInButton.style.pointerEvents = 'none'; |
|
|
zoomInButton.setAttribute('disabled', 'disabled'); |
|
|
}} else {{ |
|
|
zoomInButton.classList.remove('leaflet-disabled'); |
|
|
zoomInButton.style.cursor = 'pointer'; |
|
|
zoomInButton.style.opacity = '1'; |
|
|
zoomInButton.style.pointerEvents = 'auto'; |
|
|
zoomInButton.removeAttribute('disabled'); |
|
|
}} |
|
|
}} |
|
|
|
|
|
if (zoomOutButton) {{ |
|
|
if (zoom <= 8) {{ |
|
|
zoomOutButton.classList.add('leaflet-disabled'); |
|
|
zoomOutButton.style.cursor = 'not-allowed'; |
|
|
zoomOutButton.style.opacity = '0.4'; |
|
|
zoomOutButton.style.pointerEvents = 'none'; |
|
|
zoomOutButton.setAttribute('disabled', 'disabled'); |
|
|
}} else {{ |
|
|
zoomOutButton.classList.remove('leaflet-disabled'); |
|
|
zoomOutButton.style.cursor = 'pointer'; |
|
|
zoomOutButton.style.opacity = '1'; |
|
|
zoomOutButton.style.pointerEvents = 'auto'; |
|
|
zoomOutButton.removeAttribute('disabled'); |
|
|
}} |
|
|
}} |
|
|
}} |
|
|
|
|
|
// Call on every zoom change |
|
|
map.on('zoom', updateZoomControl); |
|
|
map.on('zoomend', updateZoomControl); |
|
|
updateZoomControl(); // Call immediately |
|
|
|
|
|
// Hide tiles outside bounds by clipping |
|
|
var tileLayer = document.querySelector('.leaflet-tile-pane'); |
|
|
if (tileLayer) {{ |
|
|
// Calculate pixel bounds |
|
|
var southWest = map.latLngToLayerPoint(bounds.getSouthWest()); |
|
|
var northEast = map.latLngToLayerPoint(bounds.getNorthEast()); |
|
|
|
|
|
// Create clip path |
|
|
var clipPath = 'rect(' + |
|
|
northEast.y + 'px, ' + |
|
|
northEast.x + 'px, ' + |
|
|
southWest.y + 'px, ' + |
|
|
southWest.x + 'px)'; |
|
|
|
|
|
// Note: Modern browsers use clip-path instead of clip |
|
|
}} |
|
|
}} |
|
|
|
|
|
// Add click cursor to paths |
|
|
var paths = document.querySelectorAll('.leaflet-interactive'); |
|
|
paths.forEach(function(path) {{ |
|
|
path.style.cursor = 'pointer'; |
|
|
}}); |
|
|
}}, 1000); |
|
|
}}); |
|
|
</script> |
|
|
''' |
|
|
m.get_root().html.add_child(folium.Element(restrict_bounds_script)) |
|
|
|
|
|
return m._repr_html_() |
|
|
|