Upload 40 files
Browse files- .gitattributes +3 -0
- app/__init__.py +5 -0
- app/__pycache__/__init__.cpython-310.pyc +0 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/forecast.cpython-311.pyc +0 -0
- app/__pycache__/map_py.cpython-310.pyc +0 -0
- app/__pycache__/map_py.cpython-311.pyc +0 -0
- app/__pycache__/map_py_heatmap.cpython-310.pyc +0 -0
- app/__pycache__/map_py_heatmap.cpython-311.pyc +0 -0
- app/__pycache__/routes.cpython-310.pyc +0 -0
- app/__pycache__/routes.cpython-311.pyc +0 -0
- app/__pycache__/table_summary.cpython-311.pyc +0 -0
- app/eksplorasi.ipynb +0 -0
- app/forecast.py +48 -0
- app/map_py.py +245 -0
- app/map_py_backup.py +225 -0
- app/map_py_heatmap.py +544 -0
- app/map_py_temp.py +225 -0
- app/routes.py +272 -0
- app/static/css/style.css +178 -0
- app/static/geojson/jatim.geojson +0 -0
- app/static/geojson/jatim_kabkota.geojson +0 -0
- app/static/geojson/jatim_kabkota_metric.geojson +0 -0
- app/static/img/heatmap_jatim.png +3 -0
- app/static/js/main.js +51 -0
- app/table_summary.py +231 -0
- app/templates/heatmap.html +344 -0
- app/templates/index.html +978 -0
- app/templates/map.html +40 -0
- cleaned_data.csv +3 -0
- data/geojson/Jawa Timur Map Chart.svg +0 -0
- data/geojson/Kab_Kota SHP.7z.001 +3 -0
- data/geojson/TASWIL5000020230907KABKOTA.xml +344 -0
- data/geojson/jatim.geojson +45 -0
- data/geojson/jatim_kabkota.geojson +0 -0
- requirements.txt +12 -0
- run.py +4 -0
- scripts/generate_heatmap_geopandas.py +152 -0
- scripts/generate_map.py +30 -0
- test_api.py +23 -0
- test_app.py +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
app/static/img/heatmap_jatim.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
cleaned_data.csv filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
data/geojson/Kab_Kota[[:space:]]SHP.7z.001 filter=lfs diff=lfs merge=lfs -text
|
app/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask
|
| 2 |
+
|
| 3 |
+
app = Flask(__name__)
|
| 4 |
+
|
| 5 |
+
from app import routes
|
app/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (237 Bytes). View file
|
|
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (308 Bytes). View file
|
|
|
app/__pycache__/forecast.cpython-311.pyc
ADDED
|
Binary file (2.87 kB). View file
|
|
|
app/__pycache__/map_py.cpython-310.pyc
ADDED
|
Binary file (6.37 kB). View file
|
|
|
app/__pycache__/map_py.cpython-311.pyc
ADDED
|
Binary file (8.65 kB). View file
|
|
|
app/__pycache__/map_py_heatmap.cpython-310.pyc
ADDED
|
Binary file (18.6 kB). View file
|
|
|
app/__pycache__/map_py_heatmap.cpython-311.pyc
ADDED
|
Binary file (22.9 kB). View file
|
|
|
app/__pycache__/routes.cpython-310.pyc
ADDED
|
Binary file (7.51 kB). View file
|
|
|
app/__pycache__/routes.cpython-311.pyc
ADDED
|
Binary file (11.7 kB). View file
|
|
|
app/__pycache__/table_summary.cpython-311.pyc
ADDED
|
Binary file (9.11 kB). View file
|
|
|
app/eksplorasi.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/forecast.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
def forecast(forecast_data):
|
| 5 |
+
future_months = 12
|
| 6 |
+
|
| 7 |
+
df = pd.DataFrame(forecast_data)
|
| 8 |
+
|
| 9 |
+
df['date'] = pd.to_datetime(df['tahun'].astype(str) + '-' + df['bulan'].astype(str) + '-01')
|
| 10 |
+
df = df[['date', 'count']].sort_values('date')
|
| 11 |
+
|
| 12 |
+
max_date = df['date'].max()
|
| 13 |
+
cutoff = max_date - pd.DateOffset(years=5)
|
| 14 |
+
df = df[df['date'] >= cutoff]
|
| 15 |
+
|
| 16 |
+
df.set_index('date', inplace=True)
|
| 17 |
+
ts = df['count'].asfreq('MS').fillna(0)
|
| 18 |
+
|
| 19 |
+
# MODEL HOLT-WINTERS
|
| 20 |
+
model = ExponentialSmoothing(
|
| 21 |
+
ts,
|
| 22 |
+
trend="add", # bisa juga "mul"
|
| 23 |
+
seasonal="add",
|
| 24 |
+
seasonal_periods=12
|
| 25 |
+
).fit()
|
| 26 |
+
|
| 27 |
+
prediction = model.forecast(future_months)
|
| 28 |
+
|
| 29 |
+
future_index = pd.date_range(
|
| 30 |
+
ts.index[-1] + pd.DateOffset(months=1),
|
| 31 |
+
periods=future_months,
|
| 32 |
+
freq='MS'
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
forecast_json = [
|
| 36 |
+
{"date": str(date.date()), "forecast": float(pred)}
|
| 37 |
+
for date, pred in zip(future_index, prediction)
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
history_json = [
|
| 41 |
+
{"date": str(idx.date()), "count": int(val)}
|
| 42 |
+
for idx, val in ts.items()
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"history": history_json,
|
| 47 |
+
"forecast": forecast_json
|
| 48 |
+
}
|
app/map_py.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import folium
|
| 2 |
+
from folium import plugins
|
| 3 |
+
import json
|
| 4 |
+
import random
|
| 5 |
+
import colorsys
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def generate_vibrant_colors(n):
|
| 9 |
+
"""Generate n vibrant distinct colors for polygon map (like the reference image)."""
|
| 10 |
+
# Predefined vibrant colors matching the reference image style
|
| 11 |
+
vibrant_palette = [
|
| 12 |
+
'#DC143C', # Crimson red
|
| 13 |
+
'#FF1493', # Deep pink
|
| 14 |
+
'#8B008B', # Dark magenta
|
| 15 |
+
'#9400D3', # Dark violet
|
| 16 |
+
'#4B0082', # Indigo
|
| 17 |
+
'#0000CD', # Medium blue
|
| 18 |
+
'#1E90FF', # Dodger blue
|
| 19 |
+
'#00BFFF', # Deep sky blue
|
| 20 |
+
'#00CED1', # Dark turquoise
|
| 21 |
+
'#20B2AA', # Light sea green
|
| 22 |
+
'#008B8B', # Dark cyan
|
| 23 |
+
'#006400', # Dark green
|
| 24 |
+
'#228B22', # Forest green
|
| 25 |
+
'#32CD32', # Lime green
|
| 26 |
+
'#7FFF00', # Chartreuse
|
| 27 |
+
'#ADFF2F', # Green yellow
|
| 28 |
+
'#FFD700', # Gold
|
| 29 |
+
'#FFA500', # Orange
|
| 30 |
+
'#FF8C00', # Dark orange
|
| 31 |
+
'#FF6347', # Tomato
|
| 32 |
+
'#FF4500', # Orange red
|
| 33 |
+
'#B22222', # Fire brick
|
| 34 |
+
'#8B4513', # Saddle brown
|
| 35 |
+
'#D2691E', # Chocolate
|
| 36 |
+
'#CD853F', # Peru
|
| 37 |
+
'#DEB887', # Burlywood
|
| 38 |
+
'#F0E68C', # Khaki
|
| 39 |
+
'#9370DB', # Medium purple
|
| 40 |
+
'#BA55D3', # Medium orchid
|
| 41 |
+
'#DA70D6', # Orchid
|
| 42 |
+
'#EE82EE', # Violet
|
| 43 |
+
'#FF69B4', # Hot pink
|
| 44 |
+
'#C71585', # Medium violet red
|
| 45 |
+
'#DB7093', # Pale violet red
|
| 46 |
+
'#BC8F8F', # Rosy brown
|
| 47 |
+
'#CD5C5C', # Indian red
|
| 48 |
+
'#F08080', # Light coral
|
| 49 |
+
'#FA8072', # Salmon
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
# If we need more colors, generate additional ones using HSV
|
| 53 |
+
if n > len(vibrant_palette):
|
| 54 |
+
for i in range(n - len(vibrant_palette)):
|
| 55 |
+
hue = (i * 137.5) % 360 # Golden angle for good distribution
|
| 56 |
+
saturation = 0.7 + (random.random() * 0.3) # 70-100%
|
| 57 |
+
value = 0.6 + (random.random() * 0.4) # 60-100%
|
| 58 |
+
|
| 59 |
+
rgb = colorsys.hsv_to_rgb(hue/360.0, saturation, value)
|
| 60 |
+
hex_color = '#{:02x}{:02x}{:02x}'.format(
|
| 61 |
+
int(rgb[0] * 255),
|
| 62 |
+
int(rgb[1] * 255),
|
| 63 |
+
int(rgb[2] * 255)
|
| 64 |
+
)
|
| 65 |
+
vibrant_palette.append(hex_color)
|
| 66 |
+
|
| 67 |
+
# Shuffle and return exactly n colors
|
| 68 |
+
colors = vibrant_palette[:n]
|
| 69 |
+
random.shuffle(colors)
|
| 70 |
+
return colors
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def create_map():
|
| 74 |
+
"""Create a Folium polygon map with vibrant colors - like the reference image.
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
str: HTML string for embedding the Folium map (safe to render).
|
| 78 |
+
"""
|
| 79 |
+
# Load GeoJSON data with 38 kabupaten/kota
|
| 80 |
+
with open('data/geojson/jatim_kabkota.geojson', encoding='utf-8') as f:
|
| 81 |
+
geojson_data = json.load(f)
|
| 82 |
+
|
| 83 |
+
# Generate vibrant distinct colors for each district (polygon style)
|
| 84 |
+
num_districts = len(geojson_data['features'])
|
| 85 |
+
vibrant_colors = generate_vibrant_colors(num_districts)
|
| 86 |
+
|
| 87 |
+
# Create color mapping for each district
|
| 88 |
+
color_map = {}
|
| 89 |
+
for i, feature in enumerate(geojson_data['features']):
|
| 90 |
+
district_name = feature['properties']['name']
|
| 91 |
+
color_map[district_name] = vibrant_colors[i]
|
| 92 |
+
|
| 93 |
+
# Create Folium map with white background (like reference image)
|
| 94 |
+
m = folium.Map(
|
| 95 |
+
location=[-7.5, 112.5],
|
| 96 |
+
zoom_start=8,
|
| 97 |
+
tiles='CartoDB positron',
|
| 98 |
+
prefer_canvas=True,
|
| 99 |
+
zoom_control=True,
|
| 100 |
+
scrollWheelZoom=True
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Style function: Vibrant distinct color for each district (polygon style)
|
| 104 |
+
def style_function(feature):
|
| 105 |
+
district_name = feature['properties'].get('name', 'Unknown')
|
| 106 |
+
return {
|
| 107 |
+
'fillColor': color_map.get(district_name, '#4A90E2'),
|
| 108 |
+
'color': '#333333', # Dark gray border between polygons
|
| 109 |
+
'weight': 1.5, # Thinner border for cleaner look
|
| 110 |
+
'fillOpacity': 0.9, # Solid colors like reference image
|
| 111 |
+
'opacity': 1
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# Highlight function for hover effect
|
| 115 |
+
def highlight_function(feature):
|
| 116 |
+
district_name = feature['properties'].get('name', 'Unknown')
|
| 117 |
+
return {
|
| 118 |
+
'fillColor': color_map.get(district_name, '#4A90E2'),
|
| 119 |
+
'color': '#000000', # Black border on hover
|
| 120 |
+
'weight': 3,
|
| 121 |
+
'fillOpacity': 1.0, # Full opacity on hover
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# Add GeoJSON layer with choropleth colors
|
| 125 |
+
folium.GeoJson(
|
| 126 |
+
geojson_data,
|
| 127 |
+
name='Kabupaten/Kota Jawa Timur',
|
| 128 |
+
style_function=style_function,
|
| 129 |
+
highlight_function=highlight_function,
|
| 130 |
+
tooltip=folium.GeoJsonTooltip(
|
| 131 |
+
fields=['name'],
|
| 132 |
+
aliases=['Wilayah:'],
|
| 133 |
+
localize=True,
|
| 134 |
+
sticky=False,
|
| 135 |
+
labels=True,
|
| 136 |
+
style="""
|
| 137 |
+
background-color: white;
|
| 138 |
+
border: 2px solid #2C3E50;
|
| 139 |
+
border-radius: 5px;
|
| 140 |
+
font-family: Arial, sans-serif;
|
| 141 |
+
font-size: 12px;
|
| 142 |
+
padding: 8px;
|
| 143 |
+
box-shadow: 3px 3px 6px rgba(0,0,0,0.3);
|
| 144 |
+
""",
|
| 145 |
+
),
|
| 146 |
+
popup=folium.GeoJsonPopup(
|
| 147 |
+
fields=['name', 'province'],
|
| 148 |
+
aliases=['Nama:', 'Provinsi:'],
|
| 149 |
+
localize=True,
|
| 150 |
+
)
|
| 151 |
+
).add_to(m)
|
| 152 |
+
|
| 153 |
+
# Add text labels at centroids
|
| 154 |
+
for feature in geojson_data['features']:
|
| 155 |
+
props = feature['properties']
|
| 156 |
+
name = props.get('name', 'Unknown')
|
| 157 |
+
lat = props.get('centroid_lat')
|
| 158 |
+
lon = props.get('centroid_lon')
|
| 159 |
+
|
| 160 |
+
if lat and lon:
|
| 161 |
+
# Shorten names for display
|
| 162 |
+
display_name = name.replace('Kab. ', '').replace('Kota ', '')
|
| 163 |
+
|
| 164 |
+
folium.Marker(
|
| 165 |
+
location=[lat, lon],
|
| 166 |
+
icon=folium.DivIcon(html=f'''
|
| 167 |
+
<div style="
|
| 168 |
+
font-family: Arial, sans-serif;
|
| 169 |
+
font-size: 9px;
|
| 170 |
+
color: #FFFFFF;
|
| 171 |
+
font-weight: bold;
|
| 172 |
+
text-shadow:
|
| 173 |
+
1px 1px 2px rgba(0,0,0,0.9),
|
| 174 |
+
-1px -1px 2px rgba(0,0,0,0.9),
|
| 175 |
+
1px -1px 2px rgba(0,0,0,0.9),
|
| 176 |
+
-1px 1px 2px rgba(0,0,0,0.9);
|
| 177 |
+
text-align: center;
|
| 178 |
+
white-space: nowrap;
|
| 179 |
+
">{display_name}</div>
|
| 180 |
+
''')
|
| 181 |
+
).add_to(m)
|
| 182 |
+
|
| 183 |
+
# Fit map to bounds
|
| 184 |
+
bounds = []
|
| 185 |
+
for feature in geojson_data['features']:
|
| 186 |
+
geom = feature['geometry']
|
| 187 |
+
if geom['type'] == 'Polygon':
|
| 188 |
+
for pt in geom['coordinates'][0]:
|
| 189 |
+
bounds.append((pt[1], pt[0]))
|
| 190 |
+
elif geom['type'] == 'MultiPolygon':
|
| 191 |
+
for poly in geom['coordinates']:
|
| 192 |
+
for pt in poly[0]:
|
| 193 |
+
bounds.append((pt[1], pt[0]))
|
| 194 |
+
|
| 195 |
+
if bounds:
|
| 196 |
+
m.fit_bounds(bounds)
|
| 197 |
+
|
| 198 |
+
# # Add legend showing vibrant polygon colors
|
| 199 |
+
# legend_html = '''
|
| 200 |
+
# <div style="position: fixed;
|
| 201 |
+
# top: 10px; right: 10px; width: 240px;
|
| 202 |
+
# background-color: white; z-index:9999;
|
| 203 |
+
# border:2px solid #333; border-radius: 8px;
|
| 204 |
+
# padding: 15px;
|
| 205 |
+
# box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
| 206 |
+
# font-family: Arial, sans-serif;
|
| 207 |
+
# ">
|
| 208 |
+
# <div style="margin-top: 10px; max-height: 300px; overflow-y: auto;">
|
| 209 |
+
# '''
|
| 210 |
+
|
| 211 |
+
# Show all districts with their unique colors
|
| 212 |
+
for district, color in sorted(color_map.items()):
|
| 213 |
+
display_name = district.replace('Kab. ', '').replace('Kota ', '')
|
| 214 |
+
legend_html += f'''
|
| 215 |
+
<div style="margin: 3px 0; display: flex; align-items: center;">
|
| 216 |
+
<div style="width: 22px; height: 16px; background-color: {color};
|
| 217 |
+
border: 1px solid #666; margin-right: 8px; border-radius: 2px;
|
| 218 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.2);"></div>
|
| 219 |
+
<span style="font-size: 9px; color: #444;">{display_name}</span>
|
| 220 |
+
</div>
|
| 221 |
+
'''
|
| 222 |
+
|
| 223 |
+
legend_html += '''
|
| 224 |
+
</div>
|
| 225 |
+
<div style="margin-top: 12px; padding-top: 10px; border-top: 1px solid #ddd;
|
| 226 |
+
font-size: 9px; color: #888; text-align: center;">
|
| 227 |
+
Klik wilayah untuk detail
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
'''
|
| 231 |
+
|
| 232 |
+
m.get_root().html.add_child(folium.Element(legend_html))
|
| 233 |
+
|
| 234 |
+
# Add background color styling (white like reference image)
|
| 235 |
+
custom_css = '''
|
| 236 |
+
<style>
|
| 237 |
+
.leaflet-container {
|
| 238 |
+
background-color: #FFFFFF !important;
|
| 239 |
+
}
|
| 240 |
+
</style>
|
| 241 |
+
'''
|
| 242 |
+
m.get_root().html.add_child(folium.Element(custom_css))
|
| 243 |
+
|
| 244 |
+
# Return HTML representation (embedding) so the route can render it inside a template
|
| 245 |
+
return m._repr_html_()
|
app/map_py_backup.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import folium
|
| 2 |
+
import json
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
import folium
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
def generate_distinct_colors(n):
|
| 12 |
+
"""Generate n distinct bright colors for each district"""
|
| 13 |
+
# Predefined palette of 38 distinct, vibrant colors
|
| 14 |
+
colors = [
|
| 15 |
+
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
|
| 16 |
+
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B739', '#52B788',
|
| 17 |
+
'#E63946', '#06FFA5', '#FFD93D', '#6BCF7F', '#C77DFF',
|
| 18 |
+
'#FF9F1C', '#2EC4B6', '#E71D36', '#FF499E', '#00B4D8',
|
| 19 |
+
'#90E0EF', '#F72585', '#7209B7', '#3A0CA3', '#F4A261',
|
| 20 |
+
'#2A9D8F', '#E76F51', '#264653', '#E9C46A', '#F77F00',
|
| 21 |
+
'#D62828', '#023047', '#8338EC', '#3DDC84', '#FF006E',
|
| 22 |
+
'#FFBE0B', '#FB5607', '#8AC926'
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
# Shuffle to ensure adjacent regions get different colors
|
| 26 |
+
random.shuffle(colors)
|
| 27 |
+
|
| 28 |
+
return colors[:n]
|
| 29 |
+
|
| 30 |
+
def create_map():
|
| 31 |
+
"""Create a Folium map with distinct colors for each kabupaten/kota in Jawa Timur.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
str: HTML string for embedding the Folium map (safe to render).
|
| 35 |
+
"""
|
| 36 |
+
# Load GeoJSON data - using complete kabupaten/kota file
|
| 37 |
+
geojson_path = os.path.join(os.path.dirname(__file__), '..', 'data', 'geojson', 'jatim_kabkota.geojson')
|
| 38 |
+
|
| 39 |
+
with open(geojson_path, encoding='utf-8') as f:
|
| 40 |
+
geojson_data = json.load(f)
|
| 41 |
+
|
| 42 |
+
# Create a Folium map centered on Jawa Timur
|
| 43 |
+
m = folium.Map(
|
| 44 |
+
location=[-7.5, 112.5],
|
| 45 |
+
zoom_start=8,
|
| 46 |
+
tiles='CartoDB positron',
|
| 47 |
+
prefer_canvas=True,
|
| 48 |
+
zoom_control=True,
|
| 49 |
+
attributionControl=False
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Generate distinct colors for each kabupaten/kota (38 total)
|
| 53 |
+
num_features = len(geojson_data.get('features', []))
|
| 54 |
+
colors = generate_distinct_colors(num_features)
|
| 55 |
+
|
| 56 |
+
# Create color mapping with city names
|
| 57 |
+
color_map = {}
|
| 58 |
+
city_list = []
|
| 59 |
+
for idx, feature in enumerate(geojson_data.get('features', [])):
|
| 60 |
+
feature_name = feature.get('properties', {}).get('name', f'Feature_{idx}')
|
| 61 |
+
feature_type = feature.get('properties', {}).get('type', 'Kabupaten')
|
| 62 |
+
color_map[feature_name] = colors[idx]
|
| 63 |
+
city_list.append({
|
| 64 |
+
'name': feature_name,
|
| 65 |
+
'type': feature_type,
|
| 66 |
+
'color': colors[idx]
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
# Style function - 3D effect with vibrant colors like the reference image
|
| 70 |
+
def style_function(feature):
|
| 71 |
+
feature_name = feature['properties'].get('name', 'Unknown')
|
| 72 |
+
return {
|
| 73 |
+
'fillColor': color_map.get(feature_name, '#CCCCCC'),
|
| 74 |
+
'color': '#333333', # Dark gray border for 3D effect
|
| 75 |
+
'weight': 2, # Medium border
|
| 76 |
+
'fillOpacity': 1.0, # Completely solid for vibrant colors
|
| 77 |
+
'opacity': 1, # Solid border
|
| 78 |
+
'dashArray': None, # Solid line
|
| 79 |
+
'lineJoin': 'miter', # Sharp corners for clear boundaries
|
| 80 |
+
'lineCap': 'butt' # Clean edges
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Highlight function for hover
|
| 84 |
+
def highlight_function(feature):
|
| 85 |
+
return {
|
| 86 |
+
'fillColor': '#FFFF00', # Yellow on hover
|
| 87 |
+
'color': '#FF0000', # Red border
|
| 88 |
+
'weight': 3,
|
| 89 |
+
'fillOpacity': 0.9,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Add GeoJSON layer with colored regions
|
| 93 |
+
folium.GeoJson(
|
| 94 |
+
geojson_data,
|
| 95 |
+
name='Jawa Timur',
|
| 96 |
+
style_function=style_function,
|
| 97 |
+
highlight_function=highlight_function,
|
| 98 |
+
tooltip=folium.GeoJsonTooltip(
|
| 99 |
+
fields=['name'],
|
| 100 |
+
aliases=['Wilayah:'],
|
| 101 |
+
localize=True,
|
| 102 |
+
sticky=False,
|
| 103 |
+
labels=True,
|
| 104 |
+
style="""
|
| 105 |
+
background-color: white;
|
| 106 |
+
border: 2px solid black;
|
| 107 |
+
border-radius: 5px;
|
| 108 |
+
font-family: Arial, sans-serif;
|
| 109 |
+
font-size: 12px;
|
| 110 |
+
padding: 8px;
|
| 111 |
+
box-shadow: 3px 3px 5px rgba(0,0,0,0.5);
|
| 112 |
+
""",
|
| 113 |
+
)
|
| 114 |
+
).add_to(m)
|
| 115 |
+
|
| 116 |
+
# Add text labels on each kabupaten/kota
|
| 117 |
+
for feature in geojson_data.get('features', []):
|
| 118 |
+
props = feature.get('properties', {})
|
| 119 |
+
name = props.get('name', 'Unknown')
|
| 120 |
+
center_lat = props.get('centroid_lat')
|
| 121 |
+
center_lon = props.get('centroid_lon')
|
| 122 |
+
|
| 123 |
+
if center_lat and center_lon:
|
| 124 |
+
# Add label marker with white text and shadow for contrast
|
| 125 |
+
folium.Marker(
|
| 126 |
+
location=[center_lat, center_lon],
|
| 127 |
+
icon=folium.DivIcon(html=f'''
|
| 128 |
+
<div style="
|
| 129 |
+
font-family: Arial, sans-serif;
|
| 130 |
+
font-size: 10px;
|
| 131 |
+
color: #FFFFFF;
|
| 132 |
+
font-weight: bold;
|
| 133 |
+
text-shadow: 1px 1px 3px rgba(0,0,0,0.9),
|
| 134 |
+
-1px -1px 3px rgba(0,0,0,0.9),
|
| 135 |
+
1px -1px 3px rgba(0,0,0,0.9),
|
| 136 |
+
-1px 1px 3px rgba(0,0,0,0.9);
|
| 137 |
+
text-align: center;
|
| 138 |
+
white-space: nowrap;
|
| 139 |
+
">{name}</div>
|
| 140 |
+
''')
|
| 141 |
+
).add_to(m)
|
| 142 |
+
|
| 143 |
+
# Add custom CSS for STRONG 3D shadow effect like the reference image
|
| 144 |
+
custom_css = '''
|
| 145 |
+
<style>
|
| 146 |
+
.leaflet-container {
|
| 147 |
+
background-color: #F5F5F5 !important; /* Light gray background */
|
| 148 |
+
}
|
| 149 |
+
/* STRONG 3D shadow effect - multiple layers for depth */
|
| 150 |
+
.leaflet-interactive {
|
| 151 |
+
filter:
|
| 152 |
+
drop-shadow(3px 3px 2px rgba(0,0,0,0.3))
|
| 153 |
+
drop-shadow(6px 6px 4px rgba(0,0,0,0.25))
|
| 154 |
+
drop-shadow(9px 9px 8px rgba(0,0,0,0.2))
|
| 155 |
+
drop-shadow(12px 12px 12px rgba(0,0,0,0.15));
|
| 156 |
+
}
|
| 157 |
+
/* Clean polygon edges */
|
| 158 |
+
path.leaflet-interactive {
|
| 159 |
+
stroke-linejoin: miter !important;
|
| 160 |
+
stroke-linecap: butt !important;
|
| 161 |
+
}
|
| 162 |
+
</style>
|
| 163 |
+
'''
|
| 164 |
+
m.get_root().html.add_child(folium.Element(custom_css))
|
| 165 |
+
|
| 166 |
+
# Add legend showing all 38 kabupaten/kota with their unique colors
|
| 167 |
+
legend_html = '''
|
| 168 |
+
<div style="position: fixed;
|
| 169 |
+
bottom: 20px; left: 20px; width: 280px; max-height: 520px;
|
| 170 |
+
background-color: white; z-index:9999; font-size:11px;
|
| 171 |
+
border:2px solid #333; border-radius: 8px;
|
| 172 |
+
overflow-y: auto;
|
| 173 |
+
box-shadow: 5px 5px 15px rgba(0,0,0,0.5);
|
| 174 |
+
font-family: Arial, sans-serif;
|
| 175 |
+
">
|
| 176 |
+
<div style="background-color: #1976D2; color: white; padding: 12px;
|
| 177 |
+
font-weight: bold; text-align: center; font-size: 13px;
|
| 178 |
+
border-radius: 6px 6px 0 0;">
|
| 179 |
+
📍 38 Kabupaten/Kota Jawa Timur
|
| 180 |
+
</div>
|
| 181 |
+
<div style="padding: 10px; max-height: 450px; overflow-y: auto;">
|
| 182 |
+
'''
|
| 183 |
+
|
| 184 |
+
# Sort cities alphabetically and add to legend
|
| 185 |
+
sorted_cities = sorted(city_list, key=lambda x: x['name'])
|
| 186 |
+
for idx, city in enumerate(sorted_cities, 1):
|
| 187 |
+
legend_html += f'''
|
| 188 |
+
<div style="margin: 3px 0; display: flex; align-items: center;">
|
| 189 |
+
<div style="width: 20px; height: 20px; background-color: {city['color']};
|
| 190 |
+
border: 1.5px solid #333; margin-right: 8px; flex-shrink: 0;
|
| 191 |
+
border-radius: 3px;
|
| 192 |
+
box-shadow: 2px 2px 4px rgba(0,0,0,0.3);"></div>
|
| 193 |
+
<span style="font-size: 10px; color: #333;">
|
| 194 |
+
<b>{idx}.</b> {city['name']} ({city['type']})
|
| 195 |
+
</span>
|
| 196 |
+
</div>
|
| 197 |
+
'''
|
| 198 |
+
|
| 199 |
+
legend_html += '''
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
'''
|
| 203 |
+
|
| 204 |
+
m.get_root().html.add_child(folium.Element(legend_html))
|
| 205 |
+
|
| 206 |
+
# Add title
|
| 207 |
+
title_html = '''
|
| 208 |
+
<div style="position: fixed;
|
| 209 |
+
top: 10px; left: 50%; transform: translateX(-50%);
|
| 210 |
+
background-color: white; z-index:9999;
|
| 211 |
+
padding: 10px 30px;
|
| 212 |
+
border: 2px solid black;
|
| 213 |
+
border-radius: 5px;
|
| 214 |
+
font-family: Arial, sans-serif;
|
| 215 |
+
font-size: 16px;
|
| 216 |
+
font-weight: bold;
|
| 217 |
+
box-shadow: 3px 3px 10px rgba(0,0,0,0.5);
|
| 218 |
+
">
|
| 219 |
+
Peta Jawa Timur - Kabupaten/Kota
|
| 220 |
+
</div>
|
| 221 |
+
'''
|
| 222 |
+
m.get_root().html.add_child(folium.Element(title_html))
|
| 223 |
+
|
| 224 |
+
# Return HTML representation
|
| 225 |
+
return m._repr_html_()
|
app/map_py_heatmap.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import folium
|
| 2 |
+
from folium import plugins
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from collections import Counter
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
import folium
|
| 10 |
+
from folium import plugins
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from collections import Counter
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_case_data_from_csv(filter_year='all', filter_crime='all', filter_city='all'):
|
| 18 |
+
"""Load case data from a single cleaned CSV file."""
|
| 19 |
+
|
| 20 |
+
cleaned_csv_path = "../cleaned_data.csv"
|
| 21 |
+
if not os.path.exists(cleaned_csv_path):
|
| 22 |
+
raise FileNotFoundError(f"{cleaned_csv_path} not found.")
|
| 23 |
+
|
| 24 |
+
# load 1 file saja
|
| 25 |
+
data = pd.read_csv(cleaned_csv_path, on_bad_lines='skip')
|
| 26 |
+
|
| 27 |
+
# normalisasi ke lowercase
|
| 28 |
+
data_lower = data.map(
|
| 29 |
+
lambda x: x.lower().strip() if isinstance(x, str) and x.strip() != "" else x
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# normalisasi nama pengadilan → ambil kota-nya saja
|
| 33 |
+
if 'lembaga_peradilan' in data_lower.columns:
|
| 34 |
+
data_lower['kota'] = (
|
| 35 |
+
data_lower['lembaga_peradilan']
|
| 36 |
+
.str.replace(r'^pn\s+', '', regex=True)
|
| 37 |
+
.str.strip()
|
| 38 |
+
.str.title()
|
| 39 |
+
)
|
| 40 |
+
else:
|
| 41 |
+
data_lower['kota'] = None
|
| 42 |
+
|
| 43 |
+
# parsing tanggal
|
| 44 |
+
if 'tanggal_musyawarah' in data_lower.columns:
|
| 45 |
+
data_lower['tanggal'] = pd.to_datetime(
|
| 46 |
+
data_lower['tanggal_musyawarah'], errors='coerce'
|
| 47 |
+
)
|
| 48 |
+
data_lower['tahun_putusan'] = data_lower['tanggal'].dt.year.astype('Int64')
|
| 49 |
+
|
| 50 |
+
df = data_lower.copy()
|
| 51 |
+
|
| 52 |
+
# filter year
|
| 53 |
+
if filter_year != 'all':
|
| 54 |
+
try:
|
| 55 |
+
year_val = int(filter_year)
|
| 56 |
+
df = df[df['tahun_putusan'] == year_val]
|
| 57 |
+
except:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
# filter crime
|
| 61 |
+
if filter_crime != 'all' and 'kata_kunci' in df.columns:
|
| 62 |
+
df = df[df['kata_kunci'] == filter_crime.lower()]
|
| 63 |
+
|
| 64 |
+
# filter city
|
| 65 |
+
if filter_city != 'all':
|
| 66 |
+
df = df[df['kota'].str.lower() == filter_city.lower()]
|
| 67 |
+
|
| 68 |
+
if df.empty:
|
| 69 |
+
return {}
|
| 70 |
+
|
| 71 |
+
# agregasi
|
| 72 |
+
case_data = {}
|
| 73 |
+
|
| 74 |
+
# group by kota
|
| 75 |
+
grouped = df.groupby('kota')
|
| 76 |
+
|
| 77 |
+
for kota, group in grouped:
|
| 78 |
+
total_cases = len(group)
|
| 79 |
+
|
| 80 |
+
# ambil top 10 kejahatan
|
| 81 |
+
if 'kata_kunci' in group.columns:
|
| 82 |
+
crime_counts = group['kata_kunci'].value_counts().head(10)
|
| 83 |
+
else:
|
| 84 |
+
crime_counts = {}
|
| 85 |
+
|
| 86 |
+
cases = {crime.title(): int(count) for crime, count in crime_counts.items()}
|
| 87 |
+
|
| 88 |
+
case_data[kota] = {
|
| 89 |
+
'total': total_cases,
|
| 90 |
+
'cases': cases,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return case_data
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def create_heatmap_interactive(filter_year='all', filter_crime='all', filter_city='all'):
|
| 98 |
+
"""Create an interactive Folium choropleth heatmap with click-to-zoom and case information.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
filter_year: Filter by specific year or 'all' for all years
|
| 102 |
+
filter_crime: Filter by specific crime type or 'all' for all crimes
|
| 103 |
+
filter_city: Filter by specific city/kabupaten or 'all' for all cities
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
str: HTML string for embedding the Folium map.
|
| 107 |
+
"""
|
| 108 |
+
# Load real case data from CSV with filters
|
| 109 |
+
real_case_data = load_case_data_from_csv(filter_year=filter_year, filter_crime=filter_crime, filter_city=filter_city)
|
| 110 |
+
|
| 111 |
+
# Load GeoJSON data with metric
|
| 112 |
+
try:
|
| 113 |
+
with open('app/static/geojson/jatim_kabkota_metric.geojson', encoding='utf-8') as f:
|
| 114 |
+
geojson_data = json.load(f)
|
| 115 |
+
except:
|
| 116 |
+
# Fallback to original if metric version doesn't exist
|
| 117 |
+
with open('data/geojson/jatim_kabkota.geojson', encoding='utf-8') as f:
|
| 118 |
+
geojson_data = json.load(f)
|
| 119 |
+
|
| 120 |
+
# Update features with real case data
|
| 121 |
+
for feature in geojson_data['features']:
|
| 122 |
+
kabupaten_name = feature['properties'].get('name', feature['properties'].get('NAMOBJ', ''))
|
| 123 |
+
|
| 124 |
+
if kabupaten_name in real_case_data:
|
| 125 |
+
# Use real data
|
| 126 |
+
data = real_case_data[kabupaten_name]
|
| 127 |
+
feature['properties']['metric'] = data['total']
|
| 128 |
+
feature['properties']['cases'] = data['cases']
|
| 129 |
+
feature['properties']['total_cases'] = data['total']
|
| 130 |
+
else:
|
| 131 |
+
# Fallback jika tidak ada data
|
| 132 |
+
feature['properties']['metric'] = 0
|
| 133 |
+
feature['properties']['cases'] = {}
|
| 134 |
+
feature['properties']['total_cases'] = 0
|
| 135 |
+
|
| 136 |
+
# Calculate bounds from all features for Jawa Timur only
|
| 137 |
+
all_bounds = []
|
| 138 |
+
for feature in geojson_data['features']:
|
| 139 |
+
geom = feature['geometry']
|
| 140 |
+
if geom['type'] == 'Polygon':
|
| 141 |
+
for coord in geom['coordinates'][0]:
|
| 142 |
+
all_bounds.append([coord[1], coord[0]])
|
| 143 |
+
elif geom['type'] == 'MultiPolygon':
|
| 144 |
+
for poly in geom['coordinates']:
|
| 145 |
+
for coord in poly[0]:
|
| 146 |
+
all_bounds.append([coord[1], coord[0]])
|
| 147 |
+
|
| 148 |
+
# Get min/max bounds for Jawa Timur
|
| 149 |
+
if all_bounds:
|
| 150 |
+
lats = [b[0] for b in all_bounds]
|
| 151 |
+
lons = [b[1] for b in all_bounds]
|
| 152 |
+
min_lat, max_lat = min(lats), max(lats)
|
| 153 |
+
min_lon, max_lon = min(lons), max(lons)
|
| 154 |
+
|
| 155 |
+
# Add small buffer (0.1 degrees)
|
| 156 |
+
buffer = 0.1
|
| 157 |
+
bounds = [
|
| 158 |
+
[min_lat - buffer, min_lon - buffer], # Southwest
|
| 159 |
+
[max_lat + buffer, max_lon + buffer] # Northeast
|
| 160 |
+
]
|
| 161 |
+
else:
|
| 162 |
+
# Fallback bounds for Jawa Timur
|
| 163 |
+
bounds = [[-8.8, 111.0], [-6.0, 114.5]]
|
| 164 |
+
|
| 165 |
+
# Create Folium map with restricted bounds
|
| 166 |
+
m = folium.Map(
|
| 167 |
+
location=[-7.5, 112.5],
|
| 168 |
+
zoom_start=9, # Start zoom level 9 - nyaman lihat seluruh Jatim
|
| 169 |
+
min_zoom=8, # Min zoom 8 - bisa lihat peta lebih luas sedikit
|
| 170 |
+
max_zoom=13, # Max zoom 13 - cukup untuk detail
|
| 171 |
+
tiles=None, # No tiles initially
|
| 172 |
+
zoom_control=True, # Tampilkan tombol zoom
|
| 173 |
+
scrollWheelZoom=False, # Matikan scroll wheel zoom - hanya pakai tombol
|
| 174 |
+
prefer_canvas=True,
|
| 175 |
+
max_bounds=True,
|
| 176 |
+
min_lat=bounds[0][0],
|
| 177 |
+
max_lat=bounds[1][0],
|
| 178 |
+
min_lon=bounds[0][1],
|
| 179 |
+
max_lon=bounds[1][1]
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Add tiles only for Jawa Timur area using TileLayer with bounds
|
| 183 |
+
folium.TileLayer(
|
| 184 |
+
tiles='CartoDB positron',
|
| 185 |
+
attr='CartoDB',
|
| 186 |
+
name='Base Map',
|
| 187 |
+
overlay=False,
|
| 188 |
+
control=False,
|
| 189 |
+
bounds=bounds
|
| 190 |
+
).add_to(m)
|
| 191 |
+
|
| 192 |
+
# Don't use fit_bounds here - it will override zoom_start
|
| 193 |
+
# Instead, we set zoom_start=9 above and let JavaScript handle bounds
|
| 194 |
+
|
| 195 |
+
# Create choropleth layer
|
| 196 |
+
choropleth = folium.Choropleth(
|
| 197 |
+
geo_data=geojson_data,
|
| 198 |
+
name='Legal Case Heatmap',
|
| 199 |
+
data={f['properties']['name']: f['properties']['metric'] for f in geojson_data['features']},
|
| 200 |
+
columns=['name', 'metric'],
|
| 201 |
+
key_on='feature.properties.name',
|
| 202 |
+
fill_color='OrRd',
|
| 203 |
+
fill_opacity=0.8,
|
| 204 |
+
line_opacity=0.5,
|
| 205 |
+
line_weight=1.5,
|
| 206 |
+
legend_name='Number of Cases',
|
| 207 |
+
highlight=True,
|
| 208 |
+
).add_to(m)
|
| 209 |
+
|
| 210 |
+
# Add interactive tooltips and popups with click-to-zoom
|
| 211 |
+
for feature in geojson_data['features']:
|
| 212 |
+
props = feature['properties']
|
| 213 |
+
name = props.get('name', 'Unknown')
|
| 214 |
+
total = props.get('total_cases', props.get('metric', 0))
|
| 215 |
+
cases = props.get('cases', {})
|
| 216 |
+
|
| 217 |
+
# Get centroid for marker
|
| 218 |
+
lat = props.get('centroid_lat')
|
| 219 |
+
lon = props.get('centroid_lon')
|
| 220 |
+
|
| 221 |
+
# Create detailed popup content
|
| 222 |
+
case_list = '<br>'.join([f'<strong>{k}:</strong> {v} case' for k, v in cases.items() if v > 0])
|
| 223 |
+
|
| 224 |
+
popup_html = f'''
|
| 225 |
+
<div style="font-family: Arial, sans-serif; width: 280px;">
|
| 226 |
+
<h3 style="margin: 0 0 10px 0;
|
| 227 |
+
color: #2C5F8D;
|
| 228 |
+
border-bottom: 2px solid #2C5F8D;
|
| 229 |
+
padding-bottom: 5px;">
|
| 230 |
+
{name}
|
| 231 |
+
</h3>
|
| 232 |
+
<div style="margin-bottom: 10px;">
|
| 233 |
+
<strong style="font-size: 16px; color: #d32f2f;">
|
| 234 |
+
Number of Cases: {total}
|
| 235 |
+
</strong>
|
| 236 |
+
</div>
|
| 237 |
+
<div style="margin-top: 10px;">
|
| 238 |
+
<strong>Detailed Information:</strong><br>
|
| 239 |
+
<div style="margin-top: 8px;
|
| 240 |
+
font-size: 13px;
|
| 241 |
+
line-height: 1.6;
|
| 242 |
+
background: #f5f5f5;
|
| 243 |
+
padding: 10px;
|
| 244 |
+
border-radius: 5px;">
|
| 245 |
+
{case_list if case_list else '<em>No data</em>'}
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
<div style="margin-top: 12px;
|
| 249 |
+
padding-top: 10px;
|
| 250 |
+
border-top: 1px solid #ddd;
|
| 251 |
+
font-size: 11px;
|
| 252 |
+
color: #666;">
|
| 253 |
+
<em>Click for zoom in</em>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
'''
|
| 257 |
+
|
| 258 |
+
# Compact tooltip for hover - stays close to cursor
|
| 259 |
+
tooltip_html = f'''
|
| 260 |
+
<div style="font-family: Arial, sans-serif;
|
| 261 |
+
padding: 8px 12px;
|
| 262 |
+
background: rgba(44, 95, 141, 0.95);
|
| 263 |
+
color: white;
|
| 264 |
+
border-radius: 5px;
|
| 265 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
| 266 |
+
font-size: 13px;
|
| 267 |
+
white-space: nowrap;
|
| 268 |
+
border: 2px solid white;">
|
| 269 |
+
<strong style="font-size: 14px;">{name}</strong><br>
|
| 270 |
+
<span style="font-size: 12px;">📊 Total: {total} case</span>
|
| 271 |
+
</div>
|
| 272 |
+
'''
|
| 273 |
+
|
| 274 |
+
# Add GeoJson layer with popup for each feature
|
| 275 |
+
geo_json = folium.GeoJson(
|
| 276 |
+
feature,
|
| 277 |
+
name=name,
|
| 278 |
+
style_function=lambda x: {
|
| 279 |
+
'fillColor': 'transparent',
|
| 280 |
+
'color': 'transparent',
|
| 281 |
+
'weight': 0,
|
| 282 |
+
'fillOpacity': 0
|
| 283 |
+
},
|
| 284 |
+
highlight_function=lambda x: {
|
| 285 |
+
'fillColor': '#ffeb3b',
|
| 286 |
+
'color': '#ff5722',
|
| 287 |
+
'weight': 3,
|
| 288 |
+
'fillOpacity': 0.7
|
| 289 |
+
},
|
| 290 |
+
tooltip=folium.Tooltip(
|
| 291 |
+
tooltip_html,
|
| 292 |
+
sticky=True, # Tooltip follows cursor closely
|
| 293 |
+
style="""
|
| 294 |
+
background-color: transparent;
|
| 295 |
+
border: none;
|
| 296 |
+
box-shadow: none;
|
| 297 |
+
padding: 0;
|
| 298 |
+
margin: 0;
|
| 299 |
+
"""
|
| 300 |
+
),
|
| 301 |
+
popup=folium.Popup(popup_html, max_width=300)
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
geo_json.add_to(m)
|
| 305 |
+
|
| 306 |
+
# Add click event to zoom to feature bounds
|
| 307 |
+
if lat and lon:
|
| 308 |
+
# Calculate bounds from geometry
|
| 309 |
+
geom = feature['geometry']
|
| 310 |
+
bounds = []
|
| 311 |
+
|
| 312 |
+
if geom['type'] == 'Polygon':
|
| 313 |
+
for coord in geom['coordinates'][0]:
|
| 314 |
+
bounds.append([coord[1], coord[0]])
|
| 315 |
+
elif geom['type'] == 'MultiPolygon':
|
| 316 |
+
for poly in geom['coordinates']:
|
| 317 |
+
for coord in poly[0]:
|
| 318 |
+
bounds.append([coord[1], coord[0]])
|
| 319 |
+
|
| 320 |
+
if bounds:
|
| 321 |
+
# Add invisible marker for click-to-zoom functionality
|
| 322 |
+
marker = folium.Marker(
|
| 323 |
+
location=[lat, lon],
|
| 324 |
+
icon=folium.DivIcon(html=''),
|
| 325 |
+
tooltip=None,
|
| 326 |
+
popup=None
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
# Add JavaScript for zoom on click
|
| 330 |
+
bounds_str = str(bounds).replace("'", '"')
|
| 331 |
+
marker_html = f'''
|
| 332 |
+
<script>
|
| 333 |
+
var bounds_{name.replace(" ", "_").replace(".", "")} = {bounds_str};
|
| 334 |
+
</script>
|
| 335 |
+
'''
|
| 336 |
+
m.get_root().html.add_child(folium.Element(marker_html))
|
| 337 |
+
|
| 338 |
+
# # Add legend
|
| 339 |
+
# legend_html = '''
|
| 340 |
+
# <div style="position: fixed;
|
| 341 |
+
# bottom: 50px; left: 50px; width: 300px;
|
| 342 |
+
# background-color: white; z-index:9999;
|
| 343 |
+
# border:2px solid #2C5F8D; border-radius: 8px;
|
| 344 |
+
# padding: 15px;
|
| 345 |
+
# box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
| 346 |
+
# font-family: Arial, sans-serif;">
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# </div>
|
| 351 |
+
# '''
|
| 352 |
+
|
| 353 |
+
# m.get_root().html.add_child(folium.Element(legend_html))
|
| 354 |
+
|
| 355 |
+
# Add custom CSS for better interactivity and sticky tooltip
|
| 356 |
+
custom_css = '''
|
| 357 |
+
<style>
|
| 358 |
+
.leaflet-container {
|
| 359 |
+
background-color: #e0e0e0 !important;
|
| 360 |
+
cursor: pointer !important;
|
| 361 |
+
}
|
| 362 |
+
/* Tooltip styling - stays very close to cursor */
|
| 363 |
+
.leaflet-tooltip {
|
| 364 |
+
background-color: transparent !important;
|
| 365 |
+
border: none !important;
|
| 366 |
+
box-shadow: none !important;
|
| 367 |
+
padding: 0 !important;
|
| 368 |
+
margin: 0 !important;
|
| 369 |
+
pointer-events: none !important;
|
| 370 |
+
}
|
| 371 |
+
.leaflet-tooltip-top {
|
| 372 |
+
margin-top: -5px !important;
|
| 373 |
+
}
|
| 374 |
+
.leaflet-tooltip-left {
|
| 375 |
+
margin-left: -5px !important;
|
| 376 |
+
}
|
| 377 |
+
.leaflet-tooltip-right {
|
| 378 |
+
margin-left: 5px !important;
|
| 379 |
+
}
|
| 380 |
+
.leaflet-tooltip-bottom {
|
| 381 |
+
margin-top: 5px !important;
|
| 382 |
+
}
|
| 383 |
+
/* Hide default tooltip pointer */
|
| 384 |
+
.leaflet-tooltip-top:before,
|
| 385 |
+
.leaflet-tooltip-bottom:before,
|
| 386 |
+
.leaflet-tooltip-left:before,
|
| 387 |
+
.leaflet-tooltip-right:before {
|
| 388 |
+
display: none !important;
|
| 389 |
+
}
|
| 390 |
+
/* Hide tiles outside bounds */
|
| 391 |
+
.leaflet-tile-container {
|
| 392 |
+
clip-path: inset(0);
|
| 393 |
+
}
|
| 394 |
+
.leaflet-interactive:hover {
|
| 395 |
+
stroke: #ff5722 !important;
|
| 396 |
+
stroke-width: 2px !important;
|
| 397 |
+
stroke-opacity: 1 !important;
|
| 398 |
+
}
|
| 399 |
+
.leaflet-popup-content-wrapper {
|
| 400 |
+
border-radius: 8px !important;
|
| 401 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important;
|
| 402 |
+
}
|
| 403 |
+
.leaflet-popup-tip {
|
| 404 |
+
display: none !important;
|
| 405 |
+
}
|
| 406 |
+
/* Add border around Jawa Timur */
|
| 407 |
+
.leaflet-overlay-pane svg {
|
| 408 |
+
filter: drop-shadow(0 0 3px rgba(0,0,0,0.3));
|
| 409 |
+
}
|
| 410 |
+
</style>
|
| 411 |
+
'''
|
| 412 |
+
m.get_root().html.add_child(folium.Element(custom_css))
|
| 413 |
+
|
| 414 |
+
map_name = m.get_name()
|
| 415 |
+
|
| 416 |
+
# Add JavaScript to restrict panning to Jawa Timur bounds
|
| 417 |
+
restrict_bounds_script = f'''
|
| 418 |
+
<script>
|
| 419 |
+
// Restrict map to Jawa Timur bounds only
|
| 420 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 421 |
+
setTimeout(function() {{
|
| 422 |
+
// Get the Leaflet map instance
|
| 423 |
+
var mapElement = window.{map_name};
|
| 424 |
+
if (mapElement && mapElement._leaflet_id) {{
|
| 425 |
+
var map = mapElement;
|
| 426 |
+
|
| 427 |
+
// Set max bounds for Jawa Timur
|
| 428 |
+
var bounds = L.latLngBounds(
|
| 429 |
+
L.latLng({bounds[0][0]}, {bounds[0][1]}), // Southwest
|
| 430 |
+
L.latLng({bounds[1][0]}, {bounds[1][1]}) // Northeast
|
| 431 |
+
);
|
| 432 |
+
|
| 433 |
+
// Strict bounds - cannot pan outside
|
| 434 |
+
//map.setMaxBounds(bounds);
|
| 435 |
+
//map.options.maxBoundsViscosity = 0.6; // Make bounds completely rigid
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
// Set zoom constraints directly on map options
|
| 439 |
+
map.options.minZoom = 8;
|
| 440 |
+
map.options.maxZoom = 13;
|
| 441 |
+
|
| 442 |
+
// Remove existing zoom control and add new one with correct limits
|
| 443 |
+
if (map.zoomControl) {{
|
| 444 |
+
map.removeControl(map.zoomControl);
|
| 445 |
+
}}
|
| 446 |
+
L.control.zoom({{ position: 'topleft' }}).addTo(map);
|
| 447 |
+
|
| 448 |
+
// Enforce zoom limits on all zoom events
|
| 449 |
+
map.on('zoom', function() {{
|
| 450 |
+
var currentZoom = map.getZoom();
|
| 451 |
+
if (currentZoom < 8) {{
|
| 452 |
+
map.setZoom(8, {{ animate: false }});
|
| 453 |
+
return false;
|
| 454 |
+
}} else if (currentZoom > 13) {{
|
| 455 |
+
map.setZoom(13, {{ animate: false }});
|
| 456 |
+
return false;
|
| 457 |
+
}}
|
| 458 |
+
}});
|
| 459 |
+
|
| 460 |
+
// Also on zoomend to catch any missed events
|
| 461 |
+
map.on('zoomend', function() {{
|
| 462 |
+
var currentZoom = map.getZoom();
|
| 463 |
+
if (currentZoom < 8) {{
|
| 464 |
+
map.setZoom(8, {{ animate: false }});
|
| 465 |
+
}} else if (currentZoom > 13) {{
|
| 466 |
+
map.setZoom(13, {{ animate: false }});
|
| 467 |
+
}}
|
| 468 |
+
updateZoomControl();
|
| 469 |
+
}});
|
| 470 |
+
|
| 471 |
+
// Update zoom control state
|
| 472 |
+
function updateZoomControl() {{
|
| 473 |
+
var zoom = map.getZoom();
|
| 474 |
+
var zoomInButton = document.querySelector('.leaflet-control-zoom-in');
|
| 475 |
+
var zoomOutButton = document.querySelector('.leaflet-control-zoom-out');
|
| 476 |
+
|
| 477 |
+
if (zoomInButton) {{
|
| 478 |
+
if (zoom >= 13) {{
|
| 479 |
+
zoomInButton.classList.add('leaflet-disabled');
|
| 480 |
+
zoomInButton.style.cursor = 'not-allowed';
|
| 481 |
+
zoomInButton.style.opacity = '0.4';
|
| 482 |
+
zoomInButton.style.pointerEvents = 'none';
|
| 483 |
+
zoomInButton.setAttribute('disabled', 'disabled');
|
| 484 |
+
}} else {{
|
| 485 |
+
zoomInButton.classList.remove('leaflet-disabled');
|
| 486 |
+
zoomInButton.style.cursor = 'pointer';
|
| 487 |
+
zoomInButton.style.opacity = '1';
|
| 488 |
+
zoomInButton.style.pointerEvents = 'auto';
|
| 489 |
+
zoomInButton.removeAttribute('disabled');
|
| 490 |
+
}}
|
| 491 |
+
}}
|
| 492 |
+
|
| 493 |
+
if (zoomOutButton) {{
|
| 494 |
+
if (zoom <= 8) {{
|
| 495 |
+
zoomOutButton.classList.add('leaflet-disabled');
|
| 496 |
+
zoomOutButton.style.cursor = 'not-allowed';
|
| 497 |
+
zoomOutButton.style.opacity = '0.4';
|
| 498 |
+
zoomOutButton.style.pointerEvents = 'none';
|
| 499 |
+
zoomOutButton.setAttribute('disabled', 'disabled');
|
| 500 |
+
}} else {{
|
| 501 |
+
zoomOutButton.classList.remove('leaflet-disabled');
|
| 502 |
+
zoomOutButton.style.cursor = 'pointer';
|
| 503 |
+
zoomOutButton.style.opacity = '1';
|
| 504 |
+
zoomOutButton.style.pointerEvents = 'auto';
|
| 505 |
+
zoomOutButton.removeAttribute('disabled');
|
| 506 |
+
}}
|
| 507 |
+
}}
|
| 508 |
+
}}
|
| 509 |
+
|
| 510 |
+
// Call on every zoom change
|
| 511 |
+
map.on('zoom', updateZoomControl);
|
| 512 |
+
map.on('zoomend', updateZoomControl);
|
| 513 |
+
updateZoomControl(); // Call immediately
|
| 514 |
+
|
| 515 |
+
// Hide tiles outside bounds by clipping
|
| 516 |
+
var tileLayer = document.querySelector('.leaflet-tile-pane');
|
| 517 |
+
if (tileLayer) {{
|
| 518 |
+
// Calculate pixel bounds
|
| 519 |
+
var southWest = map.latLngToLayerPoint(bounds.getSouthWest());
|
| 520 |
+
var northEast = map.latLngToLayerPoint(bounds.getNorthEast());
|
| 521 |
+
|
| 522 |
+
// Create clip path
|
| 523 |
+
var clipPath = 'rect(' +
|
| 524 |
+
northEast.y + 'px, ' +
|
| 525 |
+
northEast.x + 'px, ' +
|
| 526 |
+
southWest.y + 'px, ' +
|
| 527 |
+
southWest.x + 'px)';
|
| 528 |
+
|
| 529 |
+
// Note: Modern browsers use clip-path instead of clip
|
| 530 |
+
}}
|
| 531 |
+
}}
|
| 532 |
+
|
| 533 |
+
// Add click cursor to paths
|
| 534 |
+
var paths = document.querySelectorAll('.leaflet-interactive');
|
| 535 |
+
paths.forEach(function(path) {{
|
| 536 |
+
path.style.cursor = 'pointer';
|
| 537 |
+
}});
|
| 538 |
+
}}, 1000);
|
| 539 |
+
}});
|
| 540 |
+
</script>
|
| 541 |
+
'''
|
| 542 |
+
m.get_root().html.add_child(folium.Element(restrict_bounds_script))
|
| 543 |
+
|
| 544 |
+
return m._repr_html_()
|
app/map_py_temp.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import folium
|
| 2 |
+
import json
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
import folium
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
def generate_distinct_colors(n):
|
| 12 |
+
"""Generate n distinct bright colors for each district"""
|
| 13 |
+
# Predefined palette of 38 distinct, vibrant colors
|
| 14 |
+
colors = [
|
| 15 |
+
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
|
| 16 |
+
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B739', '#52B788',
|
| 17 |
+
'#E63946', '#06FFA5', '#FFD93D', '#6BCF7F', '#C77DFF',
|
| 18 |
+
'#FF9F1C', '#2EC4B6', '#E71D36', '#FF499E', '#00B4D8',
|
| 19 |
+
'#90E0EF', '#F72585', '#7209B7', '#3A0CA3', '#F4A261',
|
| 20 |
+
'#2A9D8F', '#E76F51', '#264653', '#E9C46A', '#F77F00',
|
| 21 |
+
'#D62828', '#023047', '#8338EC', '#3DDC84', '#FF006E',
|
| 22 |
+
'#FFBE0B', '#FB5607', '#8AC926'
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
# Shuffle to ensure adjacent regions get different colors
|
| 26 |
+
random.shuffle(colors)
|
| 27 |
+
|
| 28 |
+
return colors[:n]
|
| 29 |
+
|
| 30 |
+
def create_map():
|
| 31 |
+
"""Create a Folium map with distinct colors for each kabupaten/kota in Jawa Timur.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
str: HTML string for embedding the Folium map (safe to render).
|
| 35 |
+
"""
|
| 36 |
+
# Load GeoJSON data - using complete kabupaten/kota file
|
| 37 |
+
geojson_path = os.path.join(os.path.dirname(__file__), '..', 'data', 'geojson', 'jatim_kabkota.geojson')
|
| 38 |
+
|
| 39 |
+
with open(geojson_path, encoding='utf-8') as f:
|
| 40 |
+
geojson_data = json.load(f)
|
| 41 |
+
|
| 42 |
+
# Create a Folium map centered on Jawa Timur
|
| 43 |
+
m = folium.Map(
|
| 44 |
+
location=[-7.5, 112.5],
|
| 45 |
+
zoom_start=8,
|
| 46 |
+
tiles='CartoDB positron',
|
| 47 |
+
prefer_canvas=True,
|
| 48 |
+
zoom_control=True,
|
| 49 |
+
attributionControl=False
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Generate distinct colors for each kabupaten/kota (38 total)
|
| 53 |
+
num_features = len(geojson_data.get('features', []))
|
| 54 |
+
colors = generate_distinct_colors(num_features)
|
| 55 |
+
|
| 56 |
+
# Create color mapping with city names
|
| 57 |
+
color_map = {}
|
| 58 |
+
city_list = []
|
| 59 |
+
for idx, feature in enumerate(geojson_data.get('features', [])):
|
| 60 |
+
feature_name = feature.get('properties', {}).get('name', f'Feature_{idx}')
|
| 61 |
+
feature_type = feature.get('properties', {}).get('type', 'Kabupaten')
|
| 62 |
+
color_map[feature_name] = colors[idx]
|
| 63 |
+
city_list.append({
|
| 64 |
+
'name': feature_name,
|
| 65 |
+
'type': feature_type,
|
| 66 |
+
'color': colors[idx]
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
# Style function - 3D effect with vibrant colors like the reference image
|
| 70 |
+
def style_function(feature):
|
| 71 |
+
feature_name = feature['properties'].get('name', 'Unknown')
|
| 72 |
+
return {
|
| 73 |
+
'fillColor': color_map.get(feature_name, '#CCCCCC'),
|
| 74 |
+
'color': '#333333', # Dark gray border for 3D effect
|
| 75 |
+
'weight': 2, # Medium border
|
| 76 |
+
'fillOpacity': 1.0, # Completely solid for vibrant colors
|
| 77 |
+
'opacity': 1, # Solid border
|
| 78 |
+
'dashArray': None, # Solid line
|
| 79 |
+
'lineJoin': 'miter', # Sharp corners for clear boundaries
|
| 80 |
+
'lineCap': 'butt' # Clean edges
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Highlight function for hover
|
| 84 |
+
def highlight_function(feature):
|
| 85 |
+
return {
|
| 86 |
+
'fillColor': '#FFFF00', # Yellow on hover
|
| 87 |
+
'color': '#FF0000', # Red border
|
| 88 |
+
'weight': 3,
|
| 89 |
+
'fillOpacity': 0.9,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Add GeoJSON layer with colored regions
|
| 93 |
+
folium.GeoJson(
|
| 94 |
+
geojson_data,
|
| 95 |
+
name='Jawa Timur',
|
| 96 |
+
style_function=style_function,
|
| 97 |
+
highlight_function=highlight_function,
|
| 98 |
+
tooltip=folium.GeoJsonTooltip(
|
| 99 |
+
fields=['name'],
|
| 100 |
+
aliases=['Wilayah:'],
|
| 101 |
+
localize=True,
|
| 102 |
+
sticky=False,
|
| 103 |
+
labels=True,
|
| 104 |
+
style="""
|
| 105 |
+
background-color: white;
|
| 106 |
+
border: 2px solid black;
|
| 107 |
+
border-radius: 5px;
|
| 108 |
+
font-family: Arial, sans-serif;
|
| 109 |
+
font-size: 12px;
|
| 110 |
+
padding: 8px;
|
| 111 |
+
box-shadow: 3px 3px 5px rgba(0,0,0,0.5);
|
| 112 |
+
""",
|
| 113 |
+
)
|
| 114 |
+
).add_to(m)
|
| 115 |
+
|
| 116 |
+
# Add text labels on each kabupaten/kota
|
| 117 |
+
for feature in geojson_data.get('features', []):
|
| 118 |
+
props = feature.get('properties', {})
|
| 119 |
+
name = props.get('name', 'Unknown')
|
| 120 |
+
center_lat = props.get('centroid_lat')
|
| 121 |
+
center_lon = props.get('centroid_lon')
|
| 122 |
+
|
| 123 |
+
if center_lat and center_lon:
|
| 124 |
+
# Add label marker with white text and shadow for contrast
|
| 125 |
+
folium.Marker(
|
| 126 |
+
location=[center_lat, center_lon],
|
| 127 |
+
icon=folium.DivIcon(html=f'''
|
| 128 |
+
<div style="
|
| 129 |
+
font-family: Arial, sans-serif;
|
| 130 |
+
font-size: 10px;
|
| 131 |
+
color: #FFFFFF;
|
| 132 |
+
font-weight: bold;
|
| 133 |
+
text-shadow: 1px 1px 3px rgba(0,0,0,0.9),
|
| 134 |
+
-1px -1px 3px rgba(0,0,0,0.9),
|
| 135 |
+
1px -1px 3px rgba(0,0,0,0.9),
|
| 136 |
+
-1px 1px 3px rgba(0,0,0,0.9);
|
| 137 |
+
text-align: center;
|
| 138 |
+
white-space: nowrap;
|
| 139 |
+
">{name}</div>
|
| 140 |
+
''')
|
| 141 |
+
).add_to(m)
|
| 142 |
+
|
| 143 |
+
# Add custom CSS for STRONG 3D shadow effect like the reference image
|
| 144 |
+
custom_css = '''
|
| 145 |
+
<style>
|
| 146 |
+
.leaflet-container {
|
| 147 |
+
background-color: #F5F5F5 !important; /* Light gray background */
|
| 148 |
+
}
|
| 149 |
+
/* STRONG 3D shadow effect - multiple layers for depth */
|
| 150 |
+
.leaflet-interactive {
|
| 151 |
+
filter:
|
| 152 |
+
drop-shadow(3px 3px 2px rgba(0,0,0,0.3))
|
| 153 |
+
drop-shadow(6px 6px 4px rgba(0,0,0,0.25))
|
| 154 |
+
drop-shadow(9px 9px 8px rgba(0,0,0,0.2))
|
| 155 |
+
drop-shadow(12px 12px 12px rgba(0,0,0,0.15));
|
| 156 |
+
}
|
| 157 |
+
/* Clean polygon edges */
|
| 158 |
+
path.leaflet-interactive {
|
| 159 |
+
stroke-linejoin: miter !important;
|
| 160 |
+
stroke-linecap: butt !important;
|
| 161 |
+
}
|
| 162 |
+
</style>
|
| 163 |
+
'''
|
| 164 |
+
m.get_root().html.add_child(folium.Element(custom_css))
|
| 165 |
+
|
| 166 |
+
# Add legend showing all 38 kabupaten/kota with their unique colors
|
| 167 |
+
legend_html = '''
|
| 168 |
+
<div style="position: fixed;
|
| 169 |
+
bottom: 20px; left: 20px; width: 280px; max-height: 520px;
|
| 170 |
+
background-color: white; z-index:9999; font-size:11px;
|
| 171 |
+
border:2px solid #333; border-radius: 8px;
|
| 172 |
+
overflow-y: auto;
|
| 173 |
+
box-shadow: 5px 5px 15px rgba(0,0,0,0.5);
|
| 174 |
+
font-family: Arial, sans-serif;
|
| 175 |
+
">
|
| 176 |
+
<div style="background-color: #1976D2; color: white; padding: 12px;
|
| 177 |
+
font-weight: bold; text-align: center; font-size: 13px;
|
| 178 |
+
border-radius: 6px 6px 0 0;">
|
| 179 |
+
📍 38 Kabupaten/Kota Jawa Timur
|
| 180 |
+
</div>
|
| 181 |
+
<div style="padding: 10px; max-height: 450px; overflow-y: auto;">
|
| 182 |
+
'''
|
| 183 |
+
|
| 184 |
+
# Sort cities alphabetically and add to legend
|
| 185 |
+
sorted_cities = sorted(city_list, key=lambda x: x['name'])
|
| 186 |
+
for idx, city in enumerate(sorted_cities, 1):
|
| 187 |
+
legend_html += f'''
|
| 188 |
+
<div style="margin: 3px 0; display: flex; align-items: center;">
|
| 189 |
+
<div style="width: 20px; height: 20px; background-color: {city['color']};
|
| 190 |
+
border: 1.5px solid #333; margin-right: 8px; flex-shrink: 0;
|
| 191 |
+
border-radius: 3px;
|
| 192 |
+
box-shadow: 2px 2px 4px rgba(0,0,0,0.3);"></div>
|
| 193 |
+
<span style="font-size: 10px; color: #333;">
|
| 194 |
+
<b>{idx}.</b> {city['name']} ({city['type']})
|
| 195 |
+
</span>
|
| 196 |
+
</div>
|
| 197 |
+
'''
|
| 198 |
+
|
| 199 |
+
legend_html += '''
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
'''
|
| 203 |
+
|
| 204 |
+
m.get_root().html.add_child(folium.Element(legend_html))
|
| 205 |
+
|
| 206 |
+
# Add title
|
| 207 |
+
title_html = '''
|
| 208 |
+
<div style="position: fixed;
|
| 209 |
+
top: 10px; left: 50%; transform: translateX(-50%);
|
| 210 |
+
background-color: white; z-index:9999;
|
| 211 |
+
padding: 10px 30px;
|
| 212 |
+
border: 2px solid black;
|
| 213 |
+
border-radius: 5px;
|
| 214 |
+
font-family: Arial, sans-serif;
|
| 215 |
+
font-size: 16px;
|
| 216 |
+
font-weight: bold;
|
| 217 |
+
box-shadow: 3px 3px 10px rgba(0,0,0,0.5);
|
| 218 |
+
">
|
| 219 |
+
Peta Jawa Timur - Kabupaten/Kota
|
| 220 |
+
</div>
|
| 221 |
+
'''
|
| 222 |
+
m.get_root().html.add_child(folium.Element(title_html))
|
| 223 |
+
|
| 224 |
+
# Return HTML representation
|
| 225 |
+
return m._repr_html_()
|
app/routes.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import render_template, jsonify, request
|
| 2 |
+
from app.map_py import create_map
|
| 3 |
+
from app.map_py_heatmap import create_heatmap_interactive
|
| 4 |
+
from app.table_summary import table_summary
|
| 5 |
+
from app.forecast import forecast
|
| 6 |
+
from app import app
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from collections import Counter
|
| 12 |
+
import warnings
|
| 13 |
+
warnings.filterwarnings("ignore")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@app.route('/')
|
| 17 |
+
def home():
|
| 18 |
+
return render_template('index.html')
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@app.route('/map')
|
| 22 |
+
def map_view():
|
| 23 |
+
"""Display interactive heatmap with choropleth (click to see case details)"""
|
| 24 |
+
# Get filter parameters
|
| 25 |
+
filter_year = request.args.get('year', 'all')
|
| 26 |
+
filter_crime = request.args.get('crime', 'all')
|
| 27 |
+
filter_city = request.args.get('city', 'all')
|
| 28 |
+
|
| 29 |
+
map_html = create_heatmap_interactive(filter_year=filter_year, filter_crime=filter_crime, filter_city=filter_city)
|
| 30 |
+
return render_template('map.html', map_html=map_html)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@app.route('/map-folium')
|
| 34 |
+
def map_folium_view():
|
| 35 |
+
"""Old Folium polygon map (backup)"""
|
| 36 |
+
map_html = create_map()
|
| 37 |
+
return render_template('map.html', map_html=map_html)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.route('/heatmap')
|
| 41 |
+
def heatmap_view():
|
| 42 |
+
"""Display GeoPandas-generated heatmap"""
|
| 43 |
+
return render_template('heatmap.html')
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@app.route('/generate-heatmap', methods=['POST'])
|
| 47 |
+
def generate_heatmap():
|
| 48 |
+
"""API endpoint to regenerate heatmap"""
|
| 49 |
+
try:
|
| 50 |
+
# Get the project root directory
|
| 51 |
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 52 |
+
script_path = os.path.join(project_root, 'scripts', 'generate_heatmap_geopandas.py')
|
| 53 |
+
|
| 54 |
+
# Run the script
|
| 55 |
+
result = subprocess.run(
|
| 56 |
+
[sys.executable, script_path],
|
| 57 |
+
cwd=project_root,
|
| 58 |
+
capture_output=True,
|
| 59 |
+
text=True,
|
| 60 |
+
timeout=60
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
if result.returncode == 0:
|
| 64 |
+
return jsonify({
|
| 65 |
+
'success': True,
|
| 66 |
+
'message': 'Heatmap generated successfully',
|
| 67 |
+
'image_url': '/static/img/heatmap_jatim.png'
|
| 68 |
+
})
|
| 69 |
+
else:
|
| 70 |
+
return jsonify({
|
| 71 |
+
'success': False,
|
| 72 |
+
'error': result.stderr or 'Unknown error'
|
| 73 |
+
}), 500
|
| 74 |
+
|
| 75 |
+
except subprocess.TimeoutExpired:
|
| 76 |
+
return jsonify({
|
| 77 |
+
'success': False,
|
| 78 |
+
'error': 'Script timeout (lebih dari 60 detik)'
|
| 79 |
+
}), 500
|
| 80 |
+
except Exception as e:
|
| 81 |
+
return jsonify({
|
| 82 |
+
'success': False,
|
| 83 |
+
'error': str(e)
|
| 84 |
+
}), 500
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@app.route('/api/statistics')
|
| 88 |
+
def get_statistics():
|
| 89 |
+
"""API endpoint untuk mendapatkan data statistik dari CSV"""
|
| 90 |
+
# Get filter parameters from query string
|
| 91 |
+
filter_year = request.args.get('year', 'all')
|
| 92 |
+
filter_crime = request.args.get('crime', 'all')
|
| 93 |
+
filter_city = request.args.get('city', 'all')
|
| 94 |
+
|
| 95 |
+
# Load cleaned_data.csv (preferred) or fallback to meta folder
|
| 96 |
+
cleaned_csv_path = 'cleaned_data.csv'
|
| 97 |
+
print(os.getcwd())
|
| 98 |
+
|
| 99 |
+
if os.path.exists(cleaned_csv_path):
|
| 100 |
+
# Use cleaned_data.csv (single file, faster)
|
| 101 |
+
print(f"Loading data from {cleaned_csv_path}")
|
| 102 |
+
data = pd.read_csv(cleaned_csv_path, on_bad_lines='skip')
|
| 103 |
+
data_lower = data.map(lambda x: x.lower().strip() if isinstance(x, str) and x.strip() != "" else x)
|
| 104 |
+
|
| 105 |
+
# Parse year from tahun column
|
| 106 |
+
if 'tahun' in data_lower.columns:
|
| 107 |
+
data_lower['tahun_putusan'] = data_lower['tahun'].astype('Int64')
|
| 108 |
+
|
| 109 |
+
# Count unique PNs
|
| 110 |
+
exist_pn = data_lower['lembaga_peradilan'].nunique()
|
| 111 |
+
|
| 112 |
+
# # Apply filters
|
| 113 |
+
filtered_data = data_lower.copy()
|
| 114 |
+
filtered = False
|
| 115 |
+
|
| 116 |
+
# Filter by year
|
| 117 |
+
if filter_year != 'all':
|
| 118 |
+
try:
|
| 119 |
+
year_val = int(filter_year)
|
| 120 |
+
filtered_data = filtered_data[filtered_data['tahun_putusan'] == year_val]
|
| 121 |
+
filtered = True
|
| 122 |
+
except:
|
| 123 |
+
pass
|
| 124 |
+
|
| 125 |
+
# Filter by crime type
|
| 126 |
+
if filter_crime != 'all':
|
| 127 |
+
filtered_data = filtered_data[filtered_data['kata_kunci'] == filter_crime.lower()]
|
| 128 |
+
filtered = True
|
| 129 |
+
|
| 130 |
+
# Filter by city/kabupaten
|
| 131 |
+
if filter_city != 'all':
|
| 132 |
+
filtered_data = filtered_data[
|
| 133 |
+
filtered_data['lembaga_peradilan'].str.contains(filter_city.lower(), case=False, na=False)
|
| 134 |
+
]
|
| 135 |
+
filtered = True
|
| 136 |
+
|
| 137 |
+
city_names = (
|
| 138 |
+
data_lower['lembaga_peradilan']
|
| 139 |
+
.str.replace(r'^pn\s+', '', regex=True)
|
| 140 |
+
.str.strip()
|
| 141 |
+
.str.title()
|
| 142 |
+
.unique() # ambil unik
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# sort hasil unik
|
| 146 |
+
city_names = sorted(city_names)
|
| 147 |
+
|
| 148 |
+
city_options = [
|
| 149 |
+
{'value': city.lower(), 'label': city}
|
| 150 |
+
for city in city_names
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
# Pastikan kolom tanggal ada
|
| 154 |
+
if 'tanggal' in data_lower.columns:
|
| 155 |
+
try:
|
| 156 |
+
data_lower['tanggal'] = pd.to_datetime(
|
| 157 |
+
data_lower['tanggal'], errors='coerce'
|
| 158 |
+
)
|
| 159 |
+
except:
|
| 160 |
+
pass
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# Extract month
|
| 164 |
+
data_lower['bulan_putusan'] = data_lower['tanggal'].dt.month
|
| 165 |
+
data_lower['tahun_putusan'] = data_lower['tanggal'].dt.year
|
| 166 |
+
|
| 167 |
+
# hitung jumlah per (tahun, bulan)
|
| 168 |
+
grouped = (
|
| 169 |
+
data_lower
|
| 170 |
+
.groupby(['tahun_putusan', 'bulan_putusan'])
|
| 171 |
+
.size()
|
| 172 |
+
.reset_index(name='count')
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# generate struktur lengkap: setiap tahun punya 12 bulan
|
| 176 |
+
tahun_list = sorted(grouped['tahun_putusan'].unique())
|
| 177 |
+
|
| 178 |
+
forecast_data = []
|
| 179 |
+
|
| 180 |
+
for tahun in tahun_list:
|
| 181 |
+
for bulan in range(1, 12+1):
|
| 182 |
+
row = grouped[
|
| 183 |
+
(grouped['tahun_putusan'] == tahun) &
|
| 184 |
+
(grouped['bulan_putusan'] == bulan)
|
| 185 |
+
]
|
| 186 |
+
|
| 187 |
+
jumlah = int(row['count'].iloc[0]) if not row.empty else 0
|
| 188 |
+
|
| 189 |
+
forecast_data.append({
|
| 190 |
+
'tahun': tahun,
|
| 191 |
+
'bulan': bulan,
|
| 192 |
+
'count': jumlah
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
forecast_result = forecast(forecast_data)
|
| 196 |
+
|
| 197 |
+
if (filtered):
|
| 198 |
+
data_lower = filtered_data
|
| 199 |
+
|
| 200 |
+
#mengambil untuk option dropdown
|
| 201 |
+
# Crime types + count (sudah OK)
|
| 202 |
+
all_crimes = data_lower['kata_kunci'].value_counts()
|
| 203 |
+
crime_types = [
|
| 204 |
+
{'value': crime, 'label': crime.title(), 'count': int(count)}
|
| 205 |
+
for crime, count in all_crimes.items()
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
# Years + count
|
| 209 |
+
all_years = data_lower['tahun_putusan'].dropna().astype(int).value_counts().sort_index(ascending=False)
|
| 210 |
+
year_options = [
|
| 211 |
+
{'value': str(year), 'label': str(year), 'count': int(count)}
|
| 212 |
+
for year, count in all_years.items()
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
# ============================================
|
| 216 |
+
# MONTHLY SEASONALITY DATA (1–12)
|
| 217 |
+
# ============================================
|
| 218 |
+
# Pastikan kolom tanggal ada
|
| 219 |
+
if 'tanggal' in data_lower.columns:
|
| 220 |
+
try:
|
| 221 |
+
data_lower['tanggal'] = pd.to_datetime(
|
| 222 |
+
data_lower['tanggal'], errors='coerce'
|
| 223 |
+
)
|
| 224 |
+
except:
|
| 225 |
+
pass
|
| 226 |
+
|
| 227 |
+
# Extract month
|
| 228 |
+
data_lower['bulan_putusan'] = data_lower['tanggal'].dt.month
|
| 229 |
+
|
| 230 |
+
# Monthly count (filtered data)
|
| 231 |
+
monthly_counts_raw = (
|
| 232 |
+
data_lower['bulan_putusan']
|
| 233 |
+
.dropna()
|
| 234 |
+
.astype(int)
|
| 235 |
+
.value_counts()
|
| 236 |
+
.to_dict()
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Buat full 1–12 (meskipun 0)
|
| 240 |
+
monthly_data = [
|
| 241 |
+
{'month': m, 'count': int(monthly_counts_raw.get(m, 0))}
|
| 242 |
+
for m in range(1, 13)
|
| 243 |
+
]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
tabel = table_summary(data_lower)
|
| 247 |
+
|
| 248 |
+
# Ganti NaN dengan 0 atau null
|
| 249 |
+
tabel = tabel.fillna(0)
|
| 250 |
+
|
| 251 |
+
# Atau kalau mau null:
|
| 252 |
+
# tabel = tabel.where(pd.notnull(tabel), None)
|
| 253 |
+
|
| 254 |
+
# Pastikan semua keys lowercase tanpa spasi ganda
|
| 255 |
+
tabel.columns = (
|
| 256 |
+
tabel.columns
|
| 257 |
+
.str.strip()
|
| 258 |
+
.str.replace(" ", "_")
|
| 259 |
+
.str.lower()
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
return jsonify({
|
| 263 |
+
'total_cases': len(filtered_data),
|
| 264 |
+
'total_pn': exist_pn,
|
| 265 |
+
'seasonal_data': monthly_data,
|
| 266 |
+
'kasus_percentage': tabel.to_dict(orient="records"), # <-- ini
|
| 267 |
+
'crime_types': crime_types,
|
| 268 |
+
'year_options': year_options,
|
| 269 |
+
'city_options': city_options,
|
| 270 |
+
'forecast_result': forecast_result,
|
| 271 |
+
'filter_active': filter_year != 'all' or filter_crime != 'all' or filter_city != 'all'
|
| 272 |
+
})
|
app/static/css/style.css
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Custom CSS styles for the Pemetaan Hukum Jawa Timur application */
|
| 2 |
+
|
| 3 |
+
/* General styles */
|
| 4 |
+
body {
|
| 5 |
+
font-family: Arial, sans-serif;
|
| 6 |
+
margin: 0;
|
| 7 |
+
padding: 0;
|
| 8 |
+
background-color: #f4f4f4;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/* Header styles */
|
| 12 |
+
.header {
|
| 13 |
+
background-color: #007bff;
|
| 14 |
+
color: white;
|
| 15 |
+
padding: 20px 0;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.header .logo {
|
| 19 |
+
font-size: 24px;
|
| 20 |
+
text-align: center;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.nav {
|
| 24 |
+
text-align: center;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.nav ul {
|
| 28 |
+
list-style: none;
|
| 29 |
+
padding: 0;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.nav li {
|
| 33 |
+
display: inline;
|
| 34 |
+
margin: 0 15px;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.nav a {
|
| 38 |
+
color: white;
|
| 39 |
+
text-decoration: none;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* Hero section styles */
|
| 43 |
+
.hero {
|
| 44 |
+
background: url('../images/hero-bg.jpg') no-repeat center center/cover;
|
| 45 |
+
color: white;
|
| 46 |
+
padding: 60px 0;
|
| 47 |
+
text-align: center;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* Map section styles */
|
| 51 |
+
.map-section {
|
| 52 |
+
padding: 40px 0;
|
| 53 |
+
background-color: white;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.map-container {
|
| 57 |
+
height: 500px;
|
| 58 |
+
margin-top: 20px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* Control panel styles */
|
| 62 |
+
.control-panel {
|
| 63 |
+
margin-bottom: 20px;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* Legend styles */
|
| 67 |
+
.legend {
|
| 68 |
+
margin-top: 20px;
|
| 69 |
+
padding: 10px;
|
| 70 |
+
background-color: #f9f9f9;
|
| 71 |
+
border: 1px solid #ddd;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Statistics section styles */
|
| 75 |
+
.statistics-section {
|
| 76 |
+
padding: 40px 0;
|
| 77 |
+
background-color: #f4f4f4;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.stat-card {
|
| 81 |
+
background: white;
|
| 82 |
+
border-radius: 5px;
|
| 83 |
+
padding: 20px;
|
| 84 |
+
margin: 10px;
|
| 85 |
+
text-align: center;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* Footer styles */
|
| 89 |
+
.footer {
|
| 90 |
+
background-color: #007bff;
|
| 91 |
+
color: white;
|
| 92 |
+
padding: 20px 0;
|
| 93 |
+
text-align: center;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.footer-section {
|
| 97 |
+
margin-bottom: 20px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.social-links {
|
| 101 |
+
margin-top: 10px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.social-link {
|
| 105 |
+
color: white;
|
| 106 |
+
margin: 0 10px;
|
| 107 |
+
text-decoration: none;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/* Info panel styles */
|
| 111 |
+
.info-panel {
|
| 112 |
+
display: none; /* Hidden by default */
|
| 113 |
+
position: fixed;
|
| 114 |
+
top: 50%;
|
| 115 |
+
left: 50%;
|
| 116 |
+
transform: translate(-50%, -50%);
|
| 117 |
+
background-color: white;
|
| 118 |
+
border: 1px solid #ddd;
|
| 119 |
+
z-index: 1000;
|
| 120 |
+
width: 300px;
|
| 121 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.info-panel-header {
|
| 125 |
+
background-color: #007bff;
|
| 126 |
+
color: white;
|
| 127 |
+
padding: 10px;
|
| 128 |
+
text-align: center;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.close-btn {
|
| 132 |
+
background: none;
|
| 133 |
+
border: none;
|
| 134 |
+
color: white;
|
| 135 |
+
font-size: 20px;
|
| 136 |
+
cursor: pointer;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
#kasusTable {
|
| 140 |
+
border-collapse: collapse;
|
| 141 |
+
width: 100%;
|
| 142 |
+
font-size: 14px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
#kasusTable th, #kasusTable td {
|
| 146 |
+
border-bottom: 1px solid #ddd;
|
| 147 |
+
padding: 6px 8px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
#kasusTable tr:nth-child(even) {
|
| 151 |
+
background: #f9f9f9;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
#kasusTable th {
|
| 155 |
+
background: #efefef;
|
| 156 |
+
font-weight: bold;
|
| 157 |
+
border-bottom: 2px solid #ccc;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.summary-card {
|
| 161 |
+
background: white;
|
| 162 |
+
border-radius: 8px;
|
| 163 |
+
/*border-left: 5px solid #1f77b4;*/
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.summary-title {
|
| 167 |
+
font-size: 14px;
|
| 168 |
+
color: #555;
|
| 169 |
+
font-weight: 600;
|
| 170 |
+
margin-bottom: 5px;
|
| 171 |
+
text-transform: uppercase;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.summary-value {
|
| 175 |
+
font-size: 26px;
|
| 176 |
+
font-weight: 700;
|
| 177 |
+
color: #1f77b4;
|
| 178 |
+
}
|
app/static/geojson/jatim.geojson
ADDED
|
File without changes
|
app/static/geojson/jatim_kabkota.geojson
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/static/geojson/jatim_kabkota_metric.geojson
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/static/img/heatmap_jatim.png
ADDED
|
Git LFS Details
|
app/static/js/main.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file contains custom JavaScript for the application, including any interactivity for the map and handling user inputs.
|
| 2 |
+
|
| 3 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 4 |
+
const categoryFilter = document.getElementById('category-filter');
|
| 5 |
+
const yearFilter = document.getElementById('year-filter');
|
| 6 |
+
const crimeFilter = document.getElementById('crime-filter');
|
| 7 |
+
const resetButton = document.getElementById('reset-filter');
|
| 8 |
+
const mapContainer = document.getElementById('mapContainer');
|
| 9 |
+
const infoPanel = document.getElementById('infoPanel');
|
| 10 |
+
const closeInfoPanel = document.getElementById('closeInfoPanel');
|
| 11 |
+
const infoPanelBody = document.getElementById('infoPanelBody');
|
| 12 |
+
|
| 13 |
+
// Initialize the map
|
| 14 |
+
let map = L.map(mapContainer).setView([-7.275, 112.641], 8); // Center on Jawa Timur
|
| 15 |
+
|
| 16 |
+
// Load and display GeoJSON data
|
| 17 |
+
fetch('/data/geojson/jatim.geojson')
|
| 18 |
+
.then(response => response.json())
|
| 19 |
+
.then(data => {
|
| 20 |
+
L.geoJSON(data, {
|
| 21 |
+
onEachFeature: function(feature, layer) {
|
| 22 |
+
layer.on('click', function() {
|
| 23 |
+
infoPanelBody.innerHTML = `<h4>${feature.properties.name}</h4><p>${feature.properties.info}</p>`;
|
| 24 |
+
infoPanel.style.display = 'block';
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
}).addTo(map);
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// Close info panel
|
| 31 |
+
closeInfoPanel.addEventListener('click', function() {
|
| 32 |
+
infoPanel.style.display = 'none';
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
// Filter functionality
|
| 36 |
+
categoryFilter.addEventListener('change', updateMap);
|
| 37 |
+
yearFilter.addEventListener('change', updateMap);
|
| 38 |
+
crimeFilter.addEventListener('change', updateMap);
|
| 39 |
+
|
| 40 |
+
resetButton.addEventListener('click', function() {
|
| 41 |
+
categoryFilter.value = 'all';
|
| 42 |
+
yearFilter.value = 'all';
|
| 43 |
+
crimeFilter.value = 'all';
|
| 44 |
+
updateMap();
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
function updateMap() {
|
| 48 |
+
// Logic to update the map based on selected filters
|
| 49 |
+
// This function should filter the GeoJSON data and redraw the map
|
| 50 |
+
}
|
| 51 |
+
});
|
app/table_summary.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import matplotlib.pyplot as plt
|
| 3 |
+
import seaborn as sns
|
| 4 |
+
import os
|
| 5 |
+
import csv
|
| 6 |
+
import os.path
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import re
|
| 9 |
+
import numpy as np
|
| 10 |
+
import math
|
| 11 |
+
|
| 12 |
+
def kategorikan(amar):
|
| 13 |
+
if amar is None:
|
| 14 |
+
return None
|
| 15 |
+
a = amar.lower().strip()
|
| 16 |
+
|
| 17 |
+
if ("seumur hidup" in a):
|
| 18 |
+
return "penjara seumur hidup"
|
| 19 |
+
|
| 20 |
+
# 1. Pidana Penjara
|
| 21 |
+
if ("pidana penjara" in a) or ("kurungan" in a) or ("subsider penjara" in a):
|
| 22 |
+
return "pidana penjara"
|
| 23 |
+
|
| 24 |
+
# 2. Pidana Denda
|
| 25 |
+
if ("pidana denda" in a) or ("subsider denda" in a):
|
| 26 |
+
return "pidana denda"
|
| 27 |
+
|
| 28 |
+
# 3. Hukuman Mati
|
| 29 |
+
if "pidana mati" in a:
|
| 30 |
+
return "pidana mati"
|
| 31 |
+
|
| 32 |
+
# 4. Bebas Dakwaan
|
| 33 |
+
if ("bebas dari dakwaan" in a) or ("lepas dari tuntutan" in a) \
|
| 34 |
+
or ("membebaskan" in a and "dakwaan" in a):
|
| 35 |
+
return "bebas dakwaan"
|
| 36 |
+
|
| 37 |
+
# 5. Bebas Bersyarat
|
| 38 |
+
if ("pidana bersyarat" in a) or ("restorative justice" in a) \
|
| 39 |
+
or ("dikembalikan kepada orang tua" in a) \
|
| 40 |
+
or ("pidana tambahan" in a) \
|
| 41 |
+
or ("lain-lain" in a) or ("lain lain" in a) or ("lain-lain" in a) \
|
| 42 |
+
or ("penghentian pemeriksaan perkara" in a):
|
| 43 |
+
return "bebas bersyarat"
|
| 44 |
+
|
| 45 |
+
# 6. Terdakwa meninggal → drop (return None)
|
| 46 |
+
if "meninggal" in a:
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
# 7. Tidak dikenali → drop
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
# Fungsi ekstraksi lama penjara (bulan)
|
| 53 |
+
def extract_penjara(text):
|
| 54 |
+
if pd.isna(text):
|
| 55 |
+
return None
|
| 56 |
+
# cari tahun
|
| 57 |
+
tahun = re.search(r'(\d+)\s*\(.*?\)\s*tahun', text, re.IGNORECASE)
|
| 58 |
+
tahun = int(tahun.group(1)) if tahun else 0
|
| 59 |
+
# cari bulan
|
| 60 |
+
bulan = re.search(r'(\d+)\s*\(.*?\)\s*bulan', text, re.IGNORECASE)
|
| 61 |
+
bulan = int(bulan.group(1)) if bulan else 0
|
| 62 |
+
return tahun * 12 + bulan # total bulan
|
| 63 |
+
|
| 64 |
+
# Fungsi ekstraksi nilai denda
|
| 65 |
+
def extract_denda(text):
|
| 66 |
+
if pd.isna(text):
|
| 67 |
+
return None
|
| 68 |
+
# cari angka setelah "pidana denda sebesar rp"
|
| 69 |
+
match = re.search(r'pidana denda\s*(sebesar|sejumlah|retribusi+\s+sebesar|restribusi+\s+sebesar)?\s*(?:rp\.?\s*){1,2}([\d.]+)', text, re.IGNORECASE)
|
| 70 |
+
#print(match)
|
| 71 |
+
if match:
|
| 72 |
+
# hapus titik sebagai pemisah ribuan, ubah jadi integer
|
| 73 |
+
return int(match.group(2).replace('.', ''))
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
# Fungsi utama untuk apply ke DataFrame
|
| 77 |
+
def proses_amar(row):
|
| 78 |
+
cat = row["kategori_bersih"] # kolom kategori utama: pidana_penjara / pidana_denda / dll
|
| 79 |
+
text = row["catatan_amar"]
|
| 80 |
+
|
| 81 |
+
if cat == "pidana penjara":
|
| 82 |
+
return extract_penjara(text), None
|
| 83 |
+
elif cat == "pidana denda":
|
| 84 |
+
return None, extract_denda(text)
|
| 85 |
+
else:
|
| 86 |
+
# kategori lain → kosong
|
| 87 |
+
return None, None
|
| 88 |
+
|
| 89 |
+
def ringkasan(df):
|
| 90 |
+
# hitung jumlah kasus per tindak pidana
|
| 91 |
+
total_kasus = len(df)
|
| 92 |
+
|
| 93 |
+
# fungsi bantu untuk menghitung persentase kategori hukuman
|
| 94 |
+
def pct_cat(subdf, cat):
|
| 95 |
+
return round(100 * (subdf['kategori_bersih'] == cat).sum() / len(subdf), 3)
|
| 96 |
+
|
| 97 |
+
# agregasi
|
| 98 |
+
summary = []
|
| 99 |
+
|
| 100 |
+
for tp, group in df.groupby('kata_kunci'):
|
| 101 |
+
rata_penjara = round(group.loc[group['kategori_bersih']=='pidana penjara', 'lama_penjara'].mean(), 1)
|
| 102 |
+
rata_denda = round(group.loc[group['kategori_bersih']=='pidana denda', 'banyak_denda'].mean(), 0)
|
| 103 |
+
|
| 104 |
+
pct_penjara = pct_cat(group, 'pidana penjara')
|
| 105 |
+
pct_seumur = pct_cat(group, 'penjara seumur hidup')
|
| 106 |
+
pct_denda = pct_cat(group, 'pidana denda')
|
| 107 |
+
pct_bebas_bersyarat = pct_cat(group, 'bebas bersyarat')
|
| 108 |
+
pct_bebas_dakwaan = pct_cat(group, 'bebas dakwaan')
|
| 109 |
+
pct_mati = pct_cat(group, 'pidana mati')
|
| 110 |
+
|
| 111 |
+
pct_kasus = round(100 * len(group) / total_kasus, 3)
|
| 112 |
+
|
| 113 |
+
summary.append({
|
| 114 |
+
'tindak pidana': tp,
|
| 115 |
+
'rata-rata penjara': rata_penjara,
|
| 116 |
+
'rata-rata denda': rata_denda,
|
| 117 |
+
'penjara': f"{pct_penjara}",
|
| 118 |
+
'penjara seumur hidup': f"{pct_seumur}",
|
| 119 |
+
'denda': f"{pct_denda}",
|
| 120 |
+
'bebas bersyarat': f"{pct_bebas_bersyarat}",
|
| 121 |
+
'bebas dakwaan': f"{pct_bebas_dakwaan}",
|
| 122 |
+
'hukuman mati': f"{pct_mati}",
|
| 123 |
+
'kontribusi kasus': f"{pct_kasus}"
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
# buat DataFrame
|
| 127 |
+
tabel_ringkasan = pd.DataFrame(summary)
|
| 128 |
+
|
| 129 |
+
WEIGHTS = {
|
| 130 |
+
'hukuman mati': 10.0,
|
| 131 |
+
'penjara seumur hidup': 8.0,
|
| 132 |
+
'penjara': 5.0,
|
| 133 |
+
'denda': 1.5,
|
| 134 |
+
'bebas bersyarat': -1.0,
|
| 135 |
+
'bebas dakwaan': -2.0
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
def to_float(x):
|
| 139 |
+
if x is None:
|
| 140 |
+
return 0.0
|
| 141 |
+
try:
|
| 142 |
+
v = float(str(x).replace('%','').replace(',',''))
|
| 143 |
+
if math.isnan(v):
|
| 144 |
+
return 0.0
|
| 145 |
+
return v
|
| 146 |
+
except:
|
| 147 |
+
return 0.0
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def hitung_score(row):
|
| 151 |
+
|
| 152 |
+
hm = to_float(row['hukuman mati'])
|
| 153 |
+
sh = to_float(row['penjara seumur hidup'])
|
| 154 |
+
pj = to_float(row['penjara'])
|
| 155 |
+
dn = to_float(row['denda'])
|
| 156 |
+
bb = to_float(row['bebas bersyarat'])
|
| 157 |
+
bd = to_float(row['bebas dakwaan'])
|
| 158 |
+
|
| 159 |
+
base_score = (
|
| 160 |
+
hm * WEIGHTS['hukuman mati'] * 1.2 +
|
| 161 |
+
sh * WEIGHTS['penjara seumur hidup'] * 1.2 +
|
| 162 |
+
pj * WEIGHTS['penjara'] * 1.0 +
|
| 163 |
+
dn * WEIGHTS['denda'] * 1.0 +
|
| 164 |
+
bb * WEIGHTS['bebas bersyarat'] * 2.0 +
|
| 165 |
+
bd * WEIGHTS['bebas dakwaan'] * 2.0
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
rata_penjara = to_float(row.get('rata-rata penjara', 0))
|
| 169 |
+
penjara_boost = rata_penjara * 4
|
| 170 |
+
|
| 171 |
+
rata_denda = to_float(row.get('rata-rata denda', 0))
|
| 172 |
+
denda_boost = np.log10(rata_denda + 10) * 8 if rata_denda > 0 else 0
|
| 173 |
+
|
| 174 |
+
return base_score + penjara_boost + denda_boost
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# --- LANGKAH 1: hitung skor sementara tanpa menyimpan ---
|
| 178 |
+
semua_skor = tabel_ringkasan.apply(hitung_score, axis=1).tolist()
|
| 179 |
+
|
| 180 |
+
# --- LANGKAH 2: hitung threshold otomatis ---
|
| 181 |
+
p33 = np.percentile(semua_skor, 33)
|
| 182 |
+
p66 = np.percentile(semua_skor, 66)
|
| 183 |
+
|
| 184 |
+
# --- LANGKAH 3: fungsi final klasifikasi ---
|
| 185 |
+
def klasifikasi_pidana(row):
|
| 186 |
+
score = hitung_score(row) # tidak disimpan
|
| 187 |
+
#print(f"{row['tindak Pidana']}: score={score:.2f}")
|
| 188 |
+
|
| 189 |
+
if score <= p33:
|
| 190 |
+
return "light"
|
| 191 |
+
elif score <= p66:
|
| 192 |
+
return "moderate"
|
| 193 |
+
else:
|
| 194 |
+
return "serious"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# --- LANGKAH 4: simpan hanya kategori ---
|
| 198 |
+
tabel_ringkasan['kategori_pidana'] = tabel_ringkasan.apply(klasifikasi_pidana, axis=1)
|
| 199 |
+
|
| 200 |
+
return tabel_ringkasan
|
| 201 |
+
|
| 202 |
+
def normalize_ringkasan(df):
|
| 203 |
+
numeric_cols = [
|
| 204 |
+
"penjara",
|
| 205 |
+
"penjara seumur hidup",
|
| 206 |
+
"denda",
|
| 207 |
+
"bebas bersyarat",
|
| 208 |
+
"bebas dakwaan",
|
| 209 |
+
"hukuman mati",
|
| 210 |
+
"kontribusi kasus"
|
| 211 |
+
]
|
| 212 |
+
|
| 213 |
+
for col in numeric_cols:
|
| 214 |
+
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
|
| 215 |
+
|
| 216 |
+
return df
|
| 217 |
+
|
| 218 |
+
def table_summary(df):
|
| 219 |
+
df["kategori_bersih"] = df["amar_lainnya"].apply(kategorikan)
|
| 220 |
+
|
| 221 |
+
# hapus baris yang tidak masuk 5 kategori utama
|
| 222 |
+
df = df[df["kategori_bersih"].notna()]
|
| 223 |
+
|
| 224 |
+
# Terapkan ke DataFrame
|
| 225 |
+
df["lama_penjara"], df["banyak_denda"] = zip(*df.apply(proses_amar, axis=1))
|
| 226 |
+
|
| 227 |
+
tabel = normalize_ringkasan(ringkasan(df))
|
| 228 |
+
|
| 229 |
+
return tabel
|
| 230 |
+
|
| 231 |
+
|
app/templates/heatmap.html
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="id">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Heatmap GeoPandas - Jawa Timur</title>
|
| 7 |
+
|
| 8 |
+
<style>
|
| 9 |
+
* {
|
| 10 |
+
margin: 0;
|
| 11 |
+
padding: 0;
|
| 12 |
+
box-sizing: border-box;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
body {
|
| 16 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 17 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 18 |
+
padding: 20px;
|
| 19 |
+
min-height: 100vh;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.container {
|
| 23 |
+
max-width: 1400px;
|
| 24 |
+
margin: 0 auto;
|
| 25 |
+
background: white;
|
| 26 |
+
border-radius: 15px;
|
| 27 |
+
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
| 28 |
+
overflow: hidden;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.header {
|
| 32 |
+
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
| 33 |
+
color: white;
|
| 34 |
+
padding: 30px;
|
| 35 |
+
text-align: center;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.header h1 {
|
| 39 |
+
font-size: 2.5em;
|
| 40 |
+
margin-bottom: 10px;
|
| 41 |
+
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.header p {
|
| 45 |
+
font-size: 1.2em;
|
| 46 |
+
opacity: 0.9;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.nav-buttons {
|
| 50 |
+
padding: 20px 30px;
|
| 51 |
+
background: #f8f9fa;
|
| 52 |
+
border-bottom: 2px solid #dee2e6;
|
| 53 |
+
display: flex;
|
| 54 |
+
gap: 15px;
|
| 55 |
+
flex-wrap: wrap;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.btn {
|
| 59 |
+
padding: 12px 24px;
|
| 60 |
+
border: none;
|
| 61 |
+
border-radius: 8px;
|
| 62 |
+
font-size: 16px;
|
| 63 |
+
cursor: pointer;
|
| 64 |
+
text-decoration: none;
|
| 65 |
+
display: inline-block;
|
| 66 |
+
transition: all 0.3s ease;
|
| 67 |
+
font-weight: 600;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.btn-primary {
|
| 71 |
+
background: #4CAF50;
|
| 72 |
+
color: white;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.btn-primary:hover {
|
| 76 |
+
background: #45a049;
|
| 77 |
+
transform: translateY(-2px);
|
| 78 |
+
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.btn-secondary {
|
| 82 |
+
background: #2196F3;
|
| 83 |
+
color: white;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.btn-secondary:hover {
|
| 87 |
+
background: #0b7dda;
|
| 88 |
+
transform: translateY(-2px);
|
| 89 |
+
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.btn-info {
|
| 93 |
+
background: #FF9800;
|
| 94 |
+
color: white;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.btn-info:hover {
|
| 98 |
+
background: #e68900;
|
| 99 |
+
transform: translateY(-2px);
|
| 100 |
+
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.content {
|
| 104 |
+
padding: 30px;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.info-box {
|
| 108 |
+
background: #e3f2fd;
|
| 109 |
+
border-left: 4px solid #2196F3;
|
| 110 |
+
padding: 15px 20px;
|
| 111 |
+
margin-bottom: 25px;
|
| 112 |
+
border-radius: 5px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.info-box h3 {
|
| 116 |
+
color: #1976d2;
|
| 117 |
+
margin-bottom: 10px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.info-box ul {
|
| 121 |
+
list-style: none;
|
| 122 |
+
padding-left: 0;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.info-box li {
|
| 126 |
+
padding: 5px 0;
|
| 127 |
+
color: #0d47a1;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.info-box li:before {
|
| 131 |
+
content: "✓ ";
|
| 132 |
+
color: #4CAF50;
|
| 133 |
+
font-weight: bold;
|
| 134 |
+
margin-right: 5px;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.map-image-container {
|
| 138 |
+
text-align: center;
|
| 139 |
+
background: #f5f5f5;
|
| 140 |
+
padding: 20px;
|
| 141 |
+
border-radius: 10px;
|
| 142 |
+
margin-top: 20px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.map-image {
|
| 146 |
+
max-width: 100%;
|
| 147 |
+
height: auto;
|
| 148 |
+
border: 3px solid #333;
|
| 149 |
+
border-radius: 8px;
|
| 150 |
+
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
|
| 151 |
+
cursor: pointer;
|
| 152 |
+
transition: transform 0.3s ease;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.map-image:hover {
|
| 156 |
+
transform: scale(1.02);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.caption {
|
| 160 |
+
margin-top: 15px;
|
| 161 |
+
font-size: 14px;
|
| 162 |
+
color: #666;
|
| 163 |
+
font-style: italic;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.stats {
|
| 167 |
+
display: grid;
|
| 168 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 169 |
+
gap: 20px;
|
| 170 |
+
margin-top: 20px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.stat-card {
|
| 174 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 175 |
+
color: white;
|
| 176 |
+
padding: 20px;
|
| 177 |
+
border-radius: 10px;
|
| 178 |
+
text-align: center;
|
| 179 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.stat-card h4 {
|
| 183 |
+
font-size: 14px;
|
| 184 |
+
opacity: 0.9;
|
| 185 |
+
margin-bottom: 10px;
|
| 186 |
+
text-transform: uppercase;
|
| 187 |
+
letter-spacing: 1px;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.stat-card .number {
|
| 191 |
+
font-size: 32px;
|
| 192 |
+
font-weight: bold;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.footer {
|
| 196 |
+
background: #263238;
|
| 197 |
+
color: white;
|
| 198 |
+
padding: 20px;
|
| 199 |
+
text-align: center;
|
| 200 |
+
margin-top: 30px;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/* Modal for fullscreen image */
|
| 204 |
+
.modal {
|
| 205 |
+
display: none;
|
| 206 |
+
position: fixed;
|
| 207 |
+
z-index: 1000;
|
| 208 |
+
left: 0;
|
| 209 |
+
top: 0;
|
| 210 |
+
width: 100%;
|
| 211 |
+
height: 100%;
|
| 212 |
+
background-color: rgba(0,0,0,0.9);
|
| 213 |
+
padding: 20px;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.modal-content {
|
| 217 |
+
max-width: 95%;
|
| 218 |
+
max-height: 95%;
|
| 219 |
+
margin: auto;
|
| 220 |
+
display: block;
|
| 221 |
+
position: relative;
|
| 222 |
+
top: 50%;
|
| 223 |
+
transform: translateY(-50%);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.close {
|
| 227 |
+
position: absolute;
|
| 228 |
+
top: 30px;
|
| 229 |
+
right: 40px;
|
| 230 |
+
color: #f1f1f1;
|
| 231 |
+
font-size: 50px;
|
| 232 |
+
font-weight: bold;
|
| 233 |
+
cursor: pointer;
|
| 234 |
+
z-index: 1001;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.close:hover {
|
| 238 |
+
color: #ff5252;
|
| 239 |
+
}
|
| 240 |
+
</style>
|
| 241 |
+
</head>
|
| 242 |
+
<body>
|
| 243 |
+
<div class="container">
|
| 244 |
+
<div class="header">
|
| 245 |
+
<h1>🗺️ Geographic Heatmap Jawa Timur</h1>
|
| 246 |
+
<p>Peta Choropleth 38 Kabupaten/Kota - Dibuat dengan GeoPandas</p>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<div class="nav-buttons">
|
| 250 |
+
<a href="/" class="btn btn-secondary">🏠 Home</a>
|
| 251 |
+
<a href="/map-folium" class="btn btn-info">🗺️ Peta Folium Interactive</a>
|
| 252 |
+
<button onclick="location.reload()" class="btn btn-info">🔄 Refresh Heatmap</button>
|
| 253 |
+
<button onclick="generateNewHeatmap()" class="btn btn-primary">🎨 Generate Ulang</button>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<div class="content">
|
| 257 |
+
<div class="info-box">
|
| 258 |
+
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<div class="stats">
|
| 262 |
+
<div class="stat-card">
|
| 263 |
+
<h4>Total Wilayah</h4>
|
| 264 |
+
<div class="number">38</div>
|
| 265 |
+
</div>
|
| 266 |
+
<div class="stat-card">
|
| 267 |
+
<h4>Provinsi</h4>
|
| 268 |
+
<div class="number">Jawa Timur</div>
|
| 269 |
+
</div>
|
| 270 |
+
<div class="stat-card">
|
| 271 |
+
<h4>Teknologi</h4>
|
| 272 |
+
<div class="number">GeoPandas</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
<div class="map-image-container">
|
| 277 |
+
<img src="{{ url_for('static', filename='img/heatmap_jatim.png') }}"
|
| 278 |
+
alt="Heatmap Jawa Timur"
|
| 279 |
+
class="map-image"
|
| 280 |
+
onclick="openModal(this)"
|
| 281 |
+
id="heatmapImage">
|
| 282 |
+
<p class="caption">Klik gambar untuk melihat dalam ukuran penuh</p>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<div class="footer">
|
| 287 |
+
<p>© 2025 Pemetaan Hukum Jawa Timur </p>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<!-- Modal for fullscreen -->
|
| 292 |
+
<div id="imageModal" class="modal" onclick="closeModal()">
|
| 293 |
+
<span class="close">×</span>
|
| 294 |
+
<img class="modal-content" id="modalImage">
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
<script>
|
| 298 |
+
function openModal(img) {
|
| 299 |
+
const modal = document.getElementById('imageModal');
|
| 300 |
+
const modalImg = document.getElementById('modalImage');
|
| 301 |
+
modal.style.display = 'block';
|
| 302 |
+
modalImg.src = img.src;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
function closeModal() {
|
| 306 |
+
document.getElementById('imageModal').style.display = 'none';
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function generateNewHeatmap() {
|
| 310 |
+
if (confirm('Generate heatmap baru dengan data random? Proses ini memerlukan beberapa detik.')) {
|
| 311 |
+
// Show loading
|
| 312 |
+
const img = document.getElementById('heatmapImage');
|
| 313 |
+
img.style.opacity = '0.5';
|
| 314 |
+
|
| 315 |
+
// Call API to regenerate
|
| 316 |
+
fetch('/generate-heatmap', {method: 'POST'})
|
| 317 |
+
.then(response => response.json())
|
| 318 |
+
.then(data => {
|
| 319 |
+
if (data.success) {
|
| 320 |
+
// Reload image with cache busting
|
| 321 |
+
img.src = data.image_url + '?t=' + new Date().getTime();
|
| 322 |
+
img.style.opacity = '1';
|
| 323 |
+
alert('Heatmap berhasil di-generate ulang!');
|
| 324 |
+
} else {
|
| 325 |
+
alert('Gagal generate heatmap: ' + data.error);
|
| 326 |
+
img.style.opacity = '1';
|
| 327 |
+
}
|
| 328 |
+
})
|
| 329 |
+
.catch(error => {
|
| 330 |
+
alert('Error: ' + error);
|
| 331 |
+
img.style.opacity = '1';
|
| 332 |
+
});
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
// Keyboard shortcut: ESC to close modal
|
| 337 |
+
document.addEventListener('keydown', function(event) {
|
| 338 |
+
if (event.key === 'Escape') {
|
| 339 |
+
closeModal();
|
| 340 |
+
}
|
| 341 |
+
});
|
| 342 |
+
</script>
|
| 343 |
+
</body>
|
| 344 |
+
</html>
|
app/templates/index.html
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="id">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>East Java Legal Cases Mapping</title>
|
| 7 |
+
|
| 8 |
+
<!-- Custom CSS -->
|
| 9 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 10 |
+
|
| 11 |
+
<!-- Font Awesome untuk icons -->
|
| 12 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
| 13 |
+
|
| 14 |
+
<!-- Chart.js untuk diagram interaktif -->
|
| 15 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<!-- Header -->
|
| 19 |
+
<header class="header">
|
| 20 |
+
<div class="container">
|
| 21 |
+
<h1 class="logo">
|
| 22 |
+
<i class="fas fa-balance-scale"></i>
|
| 23 |
+
East Java Legal Cases Mapping
|
| 24 |
+
</h1>
|
| 25 |
+
<nav class="nav">
|
| 26 |
+
<ul>
|
| 27 |
+
<li><a href="#home" class="nav-link">Home</a></li>
|
| 28 |
+
<li><a href="#statistics" class="nav-link">Statistics</a></li>
|
| 29 |
+
<li><a href="#about" class="nav-link">About</a></li>
|
| 30 |
+
</ul>
|
| 31 |
+
</nav>
|
| 32 |
+
</div>
|
| 33 |
+
</header>
|
| 34 |
+
|
| 35 |
+
<!-- Main Content -->
|
| 36 |
+
<main>
|
| 37 |
+
|
| 38 |
+
<!-- Search and Map Section -->
|
| 39 |
+
<section id="home" class="map-section">
|
| 40 |
+
<div class="container-fluid" style="max-width: 100%; padding: 20px 40px;">
|
| 41 |
+
<!-- Summary Widgets -->
|
| 42 |
+
<!-- Pastikan Font Awesome sudah di-include -->
|
| 43 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
| 44 |
+
|
| 45 |
+
<div class="summary-container" style="
|
| 46 |
+
display: flex;
|
| 47 |
+
gap: 20px;
|
| 48 |
+
margin-bottom: 30px;
|
| 49 |
+
flex-wrap: wrap;
|
| 50 |
+
">
|
| 51 |
+
<!-- Total Putusan -->
|
| 52 |
+
<div class="summary-card" style="
|
| 53 |
+
flex: 1 1 200px;
|
| 54 |
+
background: white;
|
| 55 |
+
padding: 20px;
|
| 56 |
+
border-radius: 10px;
|
| 57 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 58 |
+
text-align: center;
|
| 59 |
+
">
|
| 60 |
+
<div class="summary-icon" style="font-size: 24px; color: #2E86AB; margin-bottom: 8px;">
|
| 61 |
+
<i class="fas fa-gavel"></i>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="summary-title" style="font-size: 14px; color: #555;">Total Criminal Verdicts</div>
|
| 64 |
+
<div class="summary-value" id="totalPutusan" style="font-size: 24px; font-weight: bold;">0</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<!-- Total Pengadilan Negeri -->
|
| 68 |
+
<div class="summary-card" style="
|
| 69 |
+
flex: 1 1 200px;
|
| 70 |
+
background: white;
|
| 71 |
+
padding: 20px;
|
| 72 |
+
border-radius: 10px;
|
| 73 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 74 |
+
text-align: center;
|
| 75 |
+
">
|
| 76 |
+
<div class="summary-icon" style="font-size: 24px; color: #FF7F0E; margin-bottom: 8px;">
|
| 77 |
+
<i class="fas fa-landmark"></i>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="summary-title" style="font-size: 14px; color: #555;">Total District Courts</div>
|
| 80 |
+
<div class="summary-value" id="totalPN" style="font-size: 24px; font-weight: bold;">0</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
<!-- Filter Controls -->
|
| 87 |
+
<div class="filter-panel" style="background: white;
|
| 88 |
+
padding: 20px;
|
| 89 |
+
border-radius: 10px;
|
| 90 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 91 |
+
margin-bottom: 20px;
|
| 92 |
+
display: flex;
|
| 93 |
+
gap: 15px;
|
| 94 |
+
align-items: flex-end;
|
| 95 |
+
flex-wrap: wrap;">
|
| 96 |
+
|
| 97 |
+
<div style="flex: 1; min-width: 200px;">
|
| 98 |
+
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Categories:</label>
|
| 99 |
+
<select id="categoryFilter" style="width: 100%;
|
| 100 |
+
padding: 10px 15px;
|
| 101 |
+
border: 1px solid #ddd;
|
| 102 |
+
border-radius: 5px;
|
| 103 |
+
font-size: 14px;">
|
| 104 |
+
<option value="all">All Categories</option>
|
| 105 |
+
<option value="pidana">Pidana Umum</option>
|
| 106 |
+
</select>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div style="flex: 1; min-width: 200px;">
|
| 110 |
+
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Years:</label>
|
| 111 |
+
<select id="yearFilter" style="width: 100%;
|
| 112 |
+
padding: 10px 15px;
|
| 113 |
+
border: 1px solid #ddd;
|
| 114 |
+
border-radius: 5px;
|
| 115 |
+
font-size: 14px;">
|
| 116 |
+
<option value="all">All Years</option>
|
| 117 |
+
<!-- Will be populated dynamically from API -->
|
| 118 |
+
</select>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div style="flex: 1; min-width: 200px;">
|
| 122 |
+
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Crime Types:</label>
|
| 123 |
+
<select id="crimeFilter" style="width: 100%;
|
| 124 |
+
padding: 10px 15px;
|
| 125 |
+
border: 1px solid #ddd;
|
| 126 |
+
border-radius: 5px;
|
| 127 |
+
font-size: 14px;">
|
| 128 |
+
<option value="all">All Types</option>
|
| 129 |
+
<!-- Will be populated dynamically from API -->
|
| 130 |
+
</select>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div style="flex: 1; min-width: 200px;">
|
| 134 |
+
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Cities/Regencies:</label>
|
| 135 |
+
<select id="cityFilter" style="width: 100%;
|
| 136 |
+
padding: 10px 15px;
|
| 137 |
+
border: 1px solid #ddd;
|
| 138 |
+
border-radius: 5px;
|
| 139 |
+
font-size: 14px;">
|
| 140 |
+
<option value="all">All Regencies</option>
|
| 141 |
+
<!-- Will be populated dynamically from API -->
|
| 142 |
+
</select>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<!-- Interactive Heatmap with Click-to-Zoom -->
|
| 147 |
+
<div style="background: white;
|
| 148 |
+
border-radius: 10px;
|
| 149 |
+
overflow: hidden;
|
| 150 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
| 151 |
+
<div style="background: linear-gradient(135deg, #2C5F8D 0%, #1e3c72 100%);
|
| 152 |
+
padding: 15px;
|
| 153 |
+
color: white;">
|
| 154 |
+
<h3 style="margin: 0; font-size: 18px;">
|
| 155 |
+
East Java Legal Cases Interactive Map
|
| 156 |
+
</h3>
|
| 157 |
+
<p style="margin: 5px 0 0 0; font-size: 13px; opacity: 0.9;">
|
| 158 |
+
Hover for more information • Click regency for detailed cases
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
<iframe id="mapIframe" src="{{ url_for('map_view') }}"
|
| 162 |
+
style="width: 100%;
|
| 163 |
+
height: 650px;
|
| 164 |
+
border: none;">
|
| 165 |
+
</iframe>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<!-- Charts Section - Diagram Interaktif dan Dinamis -->
|
| 169 |
+
<div class="charts-row" style="
|
| 170 |
+
margin-top: 60px;
|
| 171 |
+
display: flex;
|
| 172 |
+
gap: 20px;
|
| 173 |
+
flex-wrap: wrap;
|
| 174 |
+
">
|
| 175 |
+
<!-- Chart 1 -->
|
| 176 |
+
<div style="
|
| 177 |
+
flex: 1;
|
| 178 |
+
min-width: 400px;
|
| 179 |
+
max-width: 50%;
|
| 180 |
+
background: white;
|
| 181 |
+
padding: 20px;
|
| 182 |
+
border-radius: 10px;
|
| 183 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 184 |
+
">
|
| 185 |
+
<h3 id="yearHeading" style="margin:0 0 15px 0; font-size:18px;">
|
| 186 |
+
Case Trends Per Year
|
| 187 |
+
</h3>
|
| 188 |
+
<canvas id="casesYearChart" height="120"></canvas>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<!-- Chart 2 -->
|
| 192 |
+
<div style="
|
| 193 |
+
flex: 1;
|
| 194 |
+
min-width: 400px;
|
| 195 |
+
max-width: 50%;
|
| 196 |
+
background: white;
|
| 197 |
+
padding: 20px;
|
| 198 |
+
border-radius: 10px;
|
| 199 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 200 |
+
">
|
| 201 |
+
<h3 id="seasonalityHeading" style="margin:0 0 15px 0; font-size:18px;">
|
| 202 |
+
Case Pattern Per Month
|
| 203 |
+
</h3>
|
| 204 |
+
<canvas id="seasonalityChart"></canvas>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<!-- Chart 3 -->
|
| 208 |
+
<div style="
|
| 209 |
+
width: 100%;
|
| 210 |
+
margin-top: 30px;
|
| 211 |
+
background: white;
|
| 212 |
+
padding: 20px;
|
| 213 |
+
border-radius: 10px;
|
| 214 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 215 |
+
">
|
| 216 |
+
<h3 id="forecast" style="margin:0 0 15px 0; font-size:18px;">
|
| 217 |
+
Case Forecast for Next Year
|
| 218 |
+
</h3>
|
| 219 |
+
<canvas id="forecastChart" height="120"></canvas>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<!-- Chart 4: Frekuensi 10 Jenis Tindak Pidana -->
|
| 223 |
+
<div style="
|
| 224 |
+
width: 100%;
|
| 225 |
+
margin-top: 30px;
|
| 226 |
+
background: white;
|
| 227 |
+
padding: 20px;
|
| 228 |
+
border-radius: 10px;
|
| 229 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 230 |
+
">
|
| 231 |
+
<h3 style="margin:0 0 15px 0; font-size:18px;">
|
| 232 |
+
10 Highest Types of Crimes
|
| 233 |
+
</h3>
|
| 234 |
+
<canvas id="crimeTypeChart"></canvas>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div style="
|
| 238 |
+
width: 100%;
|
| 239 |
+
margin-top: 20px;
|
| 240 |
+
background: white;
|
| 241 |
+
padding: 20px;
|
| 242 |
+
border-radius: 10px;
|
| 243 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 244 |
+
">
|
| 245 |
+
<h3 id="stackedHeading" style="margin-bottom: 15px; font-size: 18px;">
|
| 246 |
+
Sentencing Patterns for the 10 Highest Crimes
|
| 247 |
+
</h3>
|
| 248 |
+
|
| 249 |
+
<canvas id="stackedPNChart"></canvas>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<table id="kasusTable" class="table table-striped">
|
| 253 |
+
<thead>
|
| 254 |
+
<tr id="kasusHead"></tr>
|
| 255 |
+
</thead>
|
| 256 |
+
<tbody id="kasusBody"></tbody>
|
| 257 |
+
</table>
|
| 258 |
+
|
| 259 |
+
<div class="pagination-container" style="
|
| 260 |
+
display: flex;
|
| 261 |
+
gap: 10px;
|
| 262 |
+
align-items: center;
|
| 263 |
+
justify-content: flex-start;
|
| 264 |
+
margin-top: 15px;
|
| 265 |
+
flex-wrap: wrap;
|
| 266 |
+
">
|
| 267 |
+
<button id="prevKasus" class="btn-pagination">Prev</button>
|
| 268 |
+
<span id="kasusPageInfo" style="font-weight: 500; min-width: 80px; text-align: center;"></span>
|
| 269 |
+
<button id="nextKasus" class="btn-pagination">Next</button>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<style>
|
| 273 |
+
.btn-pagination {
|
| 274 |
+
background-color: #2E86AB;
|
| 275 |
+
color: white;
|
| 276 |
+
border: none;
|
| 277 |
+
padding: 6px 14px;
|
| 278 |
+
border-radius: 6px;
|
| 279 |
+
font-size: 0.9rem;
|
| 280 |
+
cursor: pointer;
|
| 281 |
+
transition: all 0.2s ease;
|
| 282 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.btn-pagination:hover {
|
| 286 |
+
background-color: #1B4F72;
|
| 287 |
+
transform: translateY(-1px);
|
| 288 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.btn-pagination:disabled {
|
| 292 |
+
background-color: #cccccc;
|
| 293 |
+
cursor: not-allowed;
|
| 294 |
+
box-shadow: none;
|
| 295 |
+
}
|
| 296 |
+
</style>
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</section>
|
| 303 |
+
</main>
|
| 304 |
+
|
| 305 |
+
<!-- Footer -->
|
| 306 |
+
<footer class="footer">
|
| 307 |
+
<div class="container">
|
| 308 |
+
</div>
|
| 309 |
+
</footer>
|
| 310 |
+
|
| 311 |
+
<!-- JavaScript for Filter Functionality -->
|
| 312 |
+
<script>
|
| 313 |
+
|
| 314 |
+
// Filter change events
|
| 315 |
+
// ==================== FILTER FUNCTIONALITY ====================
|
| 316 |
+
|
| 317 |
+
let currentFilters = {
|
| 318 |
+
year: 'all',
|
| 319 |
+
crime: 'all',
|
| 320 |
+
city: 'all'
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
let allCharts = {}; // Store chart instances
|
| 324 |
+
let filtersInitialized = false; // Track if event listeners are attached
|
| 325 |
+
|
| 326 |
+
function animateNumber(element, target) {
|
| 327 |
+
let current = 0;
|
| 328 |
+
const increment = target / 50;
|
| 329 |
+
const timer = setInterval(() => {
|
| 330 |
+
current += increment;
|
| 331 |
+
if (current >= target) {
|
| 332 |
+
element.textContent = target.toLocaleString();
|
| 333 |
+
clearInterval(timer);
|
| 334 |
+
} else {
|
| 335 |
+
element.textContent = Math.floor(current).toLocaleString();
|
| 336 |
+
}
|
| 337 |
+
}, 20);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
function applyFilters() {
|
| 341 |
+
const year = document.getElementById('yearFilter').value;
|
| 342 |
+
const crime = document.getElementById('crimeFilter').value;
|
| 343 |
+
const city = document.getElementById('cityFilter').value;
|
| 344 |
+
|
| 345 |
+
currentFilters.year = year;
|
| 346 |
+
currentFilters.crime = crime;
|
| 347 |
+
currentFilters.city = city;
|
| 348 |
+
|
| 349 |
+
// Build query string
|
| 350 |
+
const params = new URLSearchParams();
|
| 351 |
+
if (year !== 'all') params.append('year', year);
|
| 352 |
+
if (crime !== 'all') params.append('crime', crime);
|
| 353 |
+
if (city !== 'all') params.append('city', city);
|
| 354 |
+
|
| 355 |
+
const queryString = params.toString();
|
| 356 |
+
const statsUrl = '/api/statistics' + (queryString ? '?' + queryString : '');
|
| 357 |
+
const mapUrl = '/map' + (queryString ? '?' + queryString : '');
|
| 358 |
+
|
| 359 |
+
// Log for debugging
|
| 360 |
+
console.log('Applying filters - Year:', year, 'Crime:', crime, 'City:', city);
|
| 361 |
+
console.log('Stats URL:', statsUrl);
|
| 362 |
+
console.log('Map URL:', mapUrl);
|
| 363 |
+
|
| 364 |
+
// Reload map with filters
|
| 365 |
+
const mapIframe = document.getElementById('mapIframe');
|
| 366 |
+
if (mapIframe) {
|
| 367 |
+
mapIframe.src = mapUrl;
|
| 368 |
+
console.log('Map iframe reloaded');
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// Reload data with filters
|
| 372 |
+
loadStatistics(statsUrl);
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
// ==================== CHART.JS - DIAGRAM INTERAKTIF ====================
|
| 376 |
+
|
| 377 |
+
function loadStatistics(url) {
|
| 378 |
+
fetch(url)
|
| 379 |
+
.then(response => response.json())
|
| 380 |
+
.then(apiData => {
|
| 381 |
+
console.log('Data dari API:', apiData);
|
| 382 |
+
|
| 383 |
+
// Update statistics section
|
| 384 |
+
// updateStatistics(apiData);
|
| 385 |
+
|
| 386 |
+
if (apiData) {
|
| 387 |
+
document.getElementById("totalPutusan").innerText = apiData.total_cases || 0;
|
| 388 |
+
document.getElementById("totalPN").innerText = apiData.total_pn || 0;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
// Populate year filter dropdown if year_options available
|
| 393 |
+
if (apiData.year_options) {
|
| 394 |
+
const yearFilter = document.getElementById('yearFilter');
|
| 395 |
+
const prev = currentFilters.year;
|
| 396 |
+
yearFilter.innerHTML = '<option value="all">All Years</option>';
|
| 397 |
+
apiData.year_options.forEach(year => {
|
| 398 |
+
const option = document.createElement('option');
|
| 399 |
+
option.value = year.value;
|
| 400 |
+
option.textContent = `${year.label} (${year.count.toLocaleString()})`;
|
| 401 |
+
yearFilter.appendChild(option);
|
| 402 |
+
});
|
| 403 |
+
yearFilter.value = prev;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// Populate crime filter dropdown if crime_types available
|
| 407 |
+
if (apiData.crime_types) {
|
| 408 |
+
const crimeFilter = document.getElementById('crimeFilter');
|
| 409 |
+
const prev = currentFilters.crime;
|
| 410 |
+
crimeFilter.innerHTML = '<option value="all">All Types</option>';
|
| 411 |
+
apiData.crime_types.slice(0, 20).forEach(crime => {
|
| 412 |
+
const option = document.createElement('option');
|
| 413 |
+
option.value = crime.value;
|
| 414 |
+
option.textContent = `${crime.label} (${crime.count.toLocaleString()})`;
|
| 415 |
+
crimeFilter.appendChild(option);
|
| 416 |
+
});
|
| 417 |
+
crimeFilter.value = prev;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// Populate city filter dropdown if city_options available
|
| 421 |
+
if (apiData.city_options && !document.getElementById('cityFilter').dataset.populated) {
|
| 422 |
+
const cityFilter = document.getElementById('cityFilter');
|
| 423 |
+
apiData.city_options.forEach(city => {
|
| 424 |
+
const option = document.createElement('option');
|
| 425 |
+
option.value = city.value;
|
| 426 |
+
option.textContent = city.label;
|
| 427 |
+
cityFilter.appendChild(option);
|
| 428 |
+
});
|
| 429 |
+
cityFilter.dataset.populated = 'true';
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
// Initialize event listeners after dropdowns are populated
|
| 433 |
+
if (!filtersInitialized) {
|
| 434 |
+
document.getElementById('yearFilter').addEventListener('change', applyFilters);
|
| 435 |
+
document.getElementById('crimeFilter').addEventListener('change', applyFilters);
|
| 436 |
+
document.getElementById('cityFilter').addEventListener('change', applyFilters);
|
| 437 |
+
// document.getElementById('resetFilter').addEventListener('click', resetFilters);
|
| 438 |
+
filtersInitialized = true;
|
| 439 |
+
console.log('Filter event listeners initialized');
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// Clean existing chart instances
|
| 443 |
+
Object.values(allCharts).forEach(ch => ch.destroy());
|
| 444 |
+
|
| 445 |
+
// === TREN KASUS TAHUNAN - LINE CHART INTERAKTIF ===
|
| 446 |
+
if (apiData.year_options) {
|
| 447 |
+
|
| 448 |
+
const ctx = document.getElementById('casesYearChart').getContext('2d');
|
| 449 |
+
|
| 450 |
+
// Urutkan ascending khusus untuk chart
|
| 451 |
+
const sortedYears = apiData.year_options.slice().reverse();
|
| 452 |
+
|
| 453 |
+
const years = sortedYears.map(x => Number(x.value));
|
| 454 |
+
const counts = sortedYears.map(x => Number(x.count));
|
| 455 |
+
|
| 456 |
+
const avgCases = counts.reduce((a, b) => a + b, 0) / counts.length;
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
// Hapus chart lama
|
| 460 |
+
if (allCharts.casesYearChart) allCharts.casesYearChart.destroy();
|
| 461 |
+
|
| 462 |
+
// Hitung perubahan signifikan (>15%)
|
| 463 |
+
const annotations = [];
|
| 464 |
+
apiData.year_options.forEach((item, i) => {
|
| 465 |
+
if (i === 0) return;
|
| 466 |
+
const prev = apiData.year_options[i - 1].count;
|
| 467 |
+
const change = ((item.count - prev) / prev) * 100;
|
| 468 |
+
|
| 469 |
+
if (Math.abs(change) > 15) {
|
| 470 |
+
annotations.push({
|
| 471 |
+
type: 'label',
|
| 472 |
+
xValue: Number(item.value),
|
| 473 |
+
yValue: Number(item.count),
|
| 474 |
+
backgroundColor: 'white',
|
| 475 |
+
borderColor: '#444',
|
| 476 |
+
borderWidth: 1,
|
| 477 |
+
padding: 6,
|
| 478 |
+
content: `${change > 0 ? '+' : ''}${change.toFixed(0)}%`,
|
| 479 |
+
color: change > 0 ? 'green' : 'red',
|
| 480 |
+
font: { weight: 'bold' },
|
| 481 |
+
yAdjust: -20
|
| 482 |
+
});
|
| 483 |
+
}
|
| 484 |
+
});
|
| 485 |
+
|
| 486 |
+
allCharts.casesYearChart = new Chart(ctx, {
|
| 487 |
+
type: 'line',
|
| 488 |
+
data: {
|
| 489 |
+
labels: years,
|
| 490 |
+
datasets: [{
|
| 491 |
+
label: `Yearly Case Trends (Average ${avgCases.toFixed(0)} Case)`,
|
| 492 |
+
data: counts,
|
| 493 |
+
borderWidth: 3,
|
| 494 |
+
tension: 0.25,
|
| 495 |
+
borderColor: '#2E86AB',
|
| 496 |
+
pointBackgroundColor: 'white',
|
| 497 |
+
pointBorderColor: '#2E86AB',
|
| 498 |
+
pointBorderWidth: 2,
|
| 499 |
+
pointRadius: 6,
|
| 500 |
+
pointHoverRadius: 7
|
| 501 |
+
}]
|
| 502 |
+
},
|
| 503 |
+
options: {
|
| 504 |
+
responsive: true,
|
| 505 |
+
plugins: {
|
| 506 |
+
legend: { display: false },
|
| 507 |
+
tooltip: {
|
| 508 |
+
callbacks: {
|
| 509 |
+
label: function (ctx) {
|
| 510 |
+
const index = ctx.dataIndex;
|
| 511 |
+
const current = ctx.raw;
|
| 512 |
+
|
| 513 |
+
if (index === 0) {
|
| 514 |
+
return `${current.toLocaleString()} case (first year)`;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
const prev = ctx.chart.data.datasets[0].data[index - 1];
|
| 518 |
+
const change = ((current - prev) / prev) * 100;
|
| 519 |
+
const sign = change >= 0 ? '+' : '';
|
| 520 |
+
|
| 521 |
+
return `${current.toLocaleString()} case (${sign}${change.toFixed(1)}%)`;
|
| 522 |
+
}
|
| 523 |
+
}
|
| 524 |
+
},
|
| 525 |
+
annotation: {
|
| 526 |
+
annotations: annotations
|
| 527 |
+
}
|
| 528 |
+
},
|
| 529 |
+
scales: {
|
| 530 |
+
x: {
|
| 531 |
+
ticks: { autoSkip: false }
|
| 532 |
+
},
|
| 533 |
+
y: {
|
| 534 |
+
beginAtZero: false,
|
| 535 |
+
grid: { color: 'rgba(0,0,0,0.1)' }
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
});
|
| 540 |
+
|
| 541 |
+
document.getElementById('yearHeading').textContent =
|
| 542 |
+
allCharts.casesYearChart.data.datasets[0].label;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
if (apiData.seasonal_data) {
|
| 546 |
+
|
| 547 |
+
const ctx = document.getElementById('seasonalityChart').getContext('2d');
|
| 548 |
+
|
| 549 |
+
// Nama bulan
|
| 550 |
+
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
| 551 |
+
|
| 552 |
+
// Pastikan full 12 bulan tetap ada
|
| 553 |
+
const fullData = Array.from({ length: 12 }, (_, i) => {
|
| 554 |
+
const entry = apiData.seasonal_data.find(x => Number(x.month) === i + 1);
|
| 555 |
+
return entry ? Number(entry.count) : 0;
|
| 556 |
+
});
|
| 557 |
+
|
| 558 |
+
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
| 559 |
+
|
| 560 |
+
// Highest & lowest bulan dengan kasus > 0
|
| 561 |
+
const filtered = fullData.filter(x => x > 0);
|
| 562 |
+
const maxVal = Math.max(...filtered);
|
| 563 |
+
const minVal = Math.min(...filtered);
|
| 564 |
+
|
| 565 |
+
const maxMonth = fullData.indexOf(maxVal) + 1;
|
| 566 |
+
const minMonth = fullData.indexOf(minVal) + 1;
|
| 567 |
+
|
| 568 |
+
// Persentase perubahan bulan-ke-bulan
|
| 569 |
+
const percentChanges = fullData.map((count, i) => {
|
| 570 |
+
if (i === 0) return null;
|
| 571 |
+
const prev = fullData[i - 1];
|
| 572 |
+
return prev > 0 ? ((count - prev) / prev) * 100 : null;
|
| 573 |
+
});
|
| 574 |
+
|
| 575 |
+
// Rata-rata kasus per bulan
|
| 576 |
+
const avgCases = fullData.reduce((a, b) => a + b, 0) / fullData.length;
|
| 577 |
+
|
| 578 |
+
// Hapus chart lama
|
| 579 |
+
if (allCharts.seasonalityChart) allCharts.seasonalityChart.destroy();
|
| 580 |
+
|
| 581 |
+
allCharts.seasonalityChart = new Chart(ctx, {
|
| 582 |
+
type: 'bar',
|
| 583 |
+
data: {
|
| 584 |
+
labels: monthNames,
|
| 585 |
+
datasets: [{
|
| 586 |
+
label: `Monthly Cases (Average ${avgCases.toFixed(0)} Case)`,
|
| 587 |
+
data: fullData,
|
| 588 |
+
borderWidth: 2,
|
| 589 |
+
backgroundColor: fullData.map((v, i) =>
|
| 590 |
+
(i + 1 === maxMonth) ? '#E57373' :
|
| 591 |
+
(i + 1 === minMonth) ? '#81C784' :
|
| 592 |
+
'#B0C4DE'
|
| 593 |
+
),
|
| 594 |
+
borderColor: fullData.map((v, i) =>
|
| 595 |
+
(i + 1 === maxMonth) ? '#B71C1C' :
|
| 596 |
+
(i + 1 === minMonth) ? '#1B5E20' :
|
| 597 |
+
'#1E3A5F'
|
| 598 |
+
)
|
| 599 |
+
}]
|
| 600 |
+
},
|
| 601 |
+
options: {
|
| 602 |
+
responsive: true,
|
| 603 |
+
plugins: {
|
| 604 |
+
legend: { display: false },
|
| 605 |
+
tooltip: {
|
| 606 |
+
callbacks: {
|
| 607 |
+
label: ctx => {
|
| 608 |
+
const monthIdx = ctx.dataIndex;
|
| 609 |
+
const raw = ctx.raw.toLocaleString();
|
| 610 |
+
|
| 611 |
+
const pct = percentChanges[monthIdx];
|
| 612 |
+
if (pct === null) return ` ${raw} kasus`;
|
| 613 |
+
|
| 614 |
+
const sign = pct > 0 ? '+' : '';
|
| 615 |
+
return ` ${raw} case (${sign}${pct.toFixed(1)}%)`;
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
}
|
| 619 |
+
},
|
| 620 |
+
scales: {
|
| 621 |
+
y: {
|
| 622 |
+
beginAtZero: true,
|
| 623 |
+
grid: { color: 'rgba(0,0,0,0.1)' }
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
}
|
| 627 |
+
});
|
| 628 |
+
|
| 629 |
+
document.getElementById('seasonalityHeading').textContent =
|
| 630 |
+
allCharts.seasonalityChart.data.datasets[0].label;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
// === CHART FORECAST: TREND HISTORIS + PREDIKSI ===
|
| 634 |
+
if (apiData.forecast_result) {
|
| 635 |
+
|
| 636 |
+
const history = apiData.forecast_result.history || [];
|
| 637 |
+
const future = apiData.forecast_result.forecast || [];
|
| 638 |
+
|
| 639 |
+
// Jika tidak ada data, hentikan
|
| 640 |
+
if (history.length === 0 && future.length === 0) {
|
| 641 |
+
console.warn("Forecast data empty");
|
| 642 |
+
return;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
const allLabels = [
|
| 646 |
+
...history.map(r => r.date),
|
| 647 |
+
...future.map(r => r.date)
|
| 648 |
+
];
|
| 649 |
+
|
| 650 |
+
const historyValues = history.map(r => r.count);
|
| 651 |
+
|
| 652 |
+
// Forecast dimulai setelah historis → sisipkan null di awal
|
| 653 |
+
const forecastValues = [
|
| 654 |
+
...Array(history.length).fill(null),
|
| 655 |
+
...future.map(r => r.forecast)
|
| 656 |
+
];
|
| 657 |
+
|
| 658 |
+
const ctxForecast = document.getElementById('forecastChart').getContext('2d');
|
| 659 |
+
|
| 660 |
+
if (allCharts.forecastChart) allCharts.forecastChart.destroy();
|
| 661 |
+
|
| 662 |
+
allCharts.forecastChart = new Chart(ctxForecast, {
|
| 663 |
+
type: 'line',
|
| 664 |
+
data: {
|
| 665 |
+
labels: allLabels,
|
| 666 |
+
datasets: [
|
| 667 |
+
{
|
| 668 |
+
label: "Historical Data",
|
| 669 |
+
data: historyValues,
|
| 670 |
+
borderWidth: 2,
|
| 671 |
+
tension: 0.3
|
| 672 |
+
},
|
| 673 |
+
{
|
| 674 |
+
label: "Forecast",
|
| 675 |
+
data: forecastValues,
|
| 676 |
+
borderWidth: 2,
|
| 677 |
+
borderDash: [6, 4],
|
| 678 |
+
tension: 0.3
|
| 679 |
+
}
|
| 680 |
+
]
|
| 681 |
+
},
|
| 682 |
+
options: {
|
| 683 |
+
responsive: true,
|
| 684 |
+
plugins: {
|
| 685 |
+
legend: {
|
| 686 |
+
position: 'top'
|
| 687 |
+
},
|
| 688 |
+
tooltip: {
|
| 689 |
+
callbacks: {
|
| 690 |
+
label: ctx => {
|
| 691 |
+
const val = ctx.raw;
|
| 692 |
+
if (val === null) return null;
|
| 693 |
+
return `${ctx.dataset.label}: ${val.toLocaleString()}`;
|
| 694 |
+
}
|
| 695 |
+
}
|
| 696 |
+
}
|
| 697 |
+
},
|
| 698 |
+
scales: {
|
| 699 |
+
x: {
|
| 700 |
+
ticks: {
|
| 701 |
+
autoSkip: true,
|
| 702 |
+
maxTicksLimit: 12
|
| 703 |
+
}
|
| 704 |
+
},
|
| 705 |
+
y: {
|
| 706 |
+
beginAtZero: true
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
}
|
| 710 |
+
});
|
| 711 |
+
|
| 712 |
+
// Untuk debugging
|
| 713 |
+
document.getElementById('forecastChart').textContent =
|
| 714 |
+
allCharts.forecastChart.data.datasets[0].label;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
// === CHART 3: FREKUENSI 10 JENIS TINDAK PIDANA TERTINGGI ===
|
| 719 |
+
if (apiData.crime_types) {
|
| 720 |
+
|
| 721 |
+
// Ambil urutan berdasarkan count
|
| 722 |
+
const sortedCrimeTypes = apiData.crime_types
|
| 723 |
+
.sort((a, b) => b.count - a.count)
|
| 724 |
+
.slice(0, 10); // ambil 10 teratas
|
| 725 |
+
|
| 726 |
+
const labels = sortedCrimeTypes.map(x => x.label);
|
| 727 |
+
const values = sortedCrimeTypes.map(x => x.count);
|
| 728 |
+
|
| 729 |
+
const ctx3 = document.getElementById('crimeTypeChart').getContext('2d');
|
| 730 |
+
|
| 731 |
+
if (allCharts.crimeTypeChart) allCharts.crimeTypeChart.destroy();
|
| 732 |
+
|
| 733 |
+
allCharts.crimeTypeChart = new Chart(ctx3, {
|
| 734 |
+
type: 'bar',
|
| 735 |
+
data: {
|
| 736 |
+
labels: labels,
|
| 737 |
+
datasets: [{
|
| 738 |
+
label: 'Total Cases',
|
| 739 |
+
data: values,
|
| 740 |
+
borderWidth: 1
|
| 741 |
+
}]
|
| 742 |
+
},
|
| 743 |
+
options: {
|
| 744 |
+
indexAxis: 'y', // horizontal bar
|
| 745 |
+
responsive: true,
|
| 746 |
+
plugins: {
|
| 747 |
+
legend: { display: false },
|
| 748 |
+
tooltip: {
|
| 749 |
+
callbacks: {
|
| 750 |
+
label: ctx => ctx.raw.toLocaleString()
|
| 751 |
+
}
|
| 752 |
+
}
|
| 753 |
+
},
|
| 754 |
+
scales: {
|
| 755 |
+
x: {
|
| 756 |
+
beginAtZero: true,
|
| 757 |
+
ticks: {
|
| 758 |
+
callback: value => value.toLocaleString()
|
| 759 |
+
}
|
| 760 |
+
}
|
| 761 |
+
}
|
| 762 |
+
}
|
| 763 |
+
});
|
| 764 |
+
|
| 765 |
+
document.getElementById('crimeTypeChart').textContent =
|
| 766 |
+
allCharts.crimeTypeChart.data.datasets[0].label;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
if (apiData.kasus_percentage) {
|
| 770 |
+
|
| 771 |
+
const ctx = document.getElementById('stackedPNChart').getContext('2d');
|
| 772 |
+
|
| 773 |
+
// Ambil 10 teratas berdasarkan kontribusi_kasus
|
| 774 |
+
const top10 = apiData.kasus_percentage
|
| 775 |
+
.slice()
|
| 776 |
+
.sort((a, b) => Number(b.kontribusi_kasus) - Number(a.kontribusi_kasus))
|
| 777 |
+
.slice(0, 10);
|
| 778 |
+
|
| 779 |
+
const crimeLabels = top10.map(r => r.tindak_pidana);
|
| 780 |
+
|
| 781 |
+
// Kolom persentase yang tersedia
|
| 782 |
+
const percentageKeys = [
|
| 783 |
+
"penjara",
|
| 784 |
+
"penjara_seumur_hidup",
|
| 785 |
+
"denda",
|
| 786 |
+
"bebas_bersyarat",
|
| 787 |
+
"bebas_dakwaan",
|
| 788 |
+
"hukuman_mati",
|
| 789 |
+
].filter(k => top10[0].hasOwnProperty(k));
|
| 790 |
+
|
| 791 |
+
const colors = [
|
| 792 |
+
'#E57373', // soft red (selaras dengan max background)
|
| 793 |
+
'#81C784', // soft green (selaras dengan min background)
|
| 794 |
+
'#64B5F6', // soft light blue
|
| 795 |
+
'#FFB74D', // soft orange
|
| 796 |
+
'#BA68C8', // soft purple
|
| 797 |
+
'#4DB6AC', // soft teal
|
| 798 |
+
'#B0C4DE', // soft steel-blue (warna default kamu)
|
| 799 |
+
];
|
| 800 |
+
|
| 801 |
+
const kasusColumnLabels = {
|
| 802 |
+
"bebas_bersyarat": "Conditional Release",
|
| 803 |
+
"bebas_dakwaan": "Acquittal",
|
| 804 |
+
"denda": "Fine",
|
| 805 |
+
"hukuman_mati": "Death Penalty",
|
| 806 |
+
"penjara": "Imprisonment",
|
| 807 |
+
"penjara_seumur_hidup": "Life Imprisonment",
|
| 808 |
+
};
|
| 809 |
+
|
| 810 |
+
const datasets = percentageKeys.map((key, idx) => ({
|
| 811 |
+
label: kasusColumnLabels[key] || key.replace(/_/g, ' ').toUpperCase(),
|
| 812 |
+
data: top10.map(r => Number(r[key]) || 0),
|
| 813 |
+
backgroundColor: colors[idx],
|
| 814 |
+
borderWidth: 1
|
| 815 |
+
}));
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
if (allCharts.stackedPNChart) allCharts.stackedPNChart.destroy();
|
| 819 |
+
|
| 820 |
+
allCharts.stackedPNChart = new Chart(ctx, {
|
| 821 |
+
type: 'bar',
|
| 822 |
+
data: {
|
| 823 |
+
labels: crimeLabels,
|
| 824 |
+
datasets: datasets
|
| 825 |
+
},
|
| 826 |
+
options: {
|
| 827 |
+
indexAxis: 'y',
|
| 828 |
+
responsive: true,
|
| 829 |
+
scales: {
|
| 830 |
+
x: {
|
| 831 |
+
stacked: true,
|
| 832 |
+
max: 100,
|
| 833 |
+
ticks: {
|
| 834 |
+
callback: v => v + "%"
|
| 835 |
+
}
|
| 836 |
+
},
|
| 837 |
+
y: {
|
| 838 |
+
stacked: true
|
| 839 |
+
}
|
| 840 |
+
},
|
| 841 |
+
plugins: {
|
| 842 |
+
legend: { position: 'right' },
|
| 843 |
+
tooltip: {
|
| 844 |
+
callbacks: {
|
| 845 |
+
label: ctx => {
|
| 846 |
+
const raw = ctx.raw ?? 0;
|
| 847 |
+
if (raw <= 0) return null;
|
| 848 |
+
return `${ctx.dataset.label}: ${raw.toFixed(3)}%`;
|
| 849 |
+
}
|
| 850 |
+
}
|
| 851 |
+
}
|
| 852 |
+
}
|
| 853 |
+
}
|
| 854 |
+
});
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
kasusDataFull = apiData.kasus_percentage.slice();
|
| 858 |
+
kasusPage = 0;
|
| 859 |
+
renderKasusTablePage();
|
| 860 |
+
|
| 861 |
+
// Animasi saat scroll ke diagram
|
| 862 |
+
const observer = new IntersectionObserver((entries) => {
|
| 863 |
+
entries.forEach(entry => {
|
| 864 |
+
if (entry.isIntersecting) {
|
| 865 |
+
entry.target.style.opacity = '1';
|
| 866 |
+
entry.target.style.transform = 'translateY(0)';
|
| 867 |
+
}
|
| 868 |
+
});
|
| 869 |
+
}, { threshold: 0.1 });
|
| 870 |
+
|
| 871 |
+
document.querySelectorAll('canvas').forEach(canvas => {
|
| 872 |
+
canvas.parentElement.style.opacity = '0';
|
| 873 |
+
canvas.parentElement.style.transform = 'translateY(30px)';
|
| 874 |
+
canvas.parentElement.style.transition = 'all 0.6s ease-out';
|
| 875 |
+
observer.observe(canvas.parentElement);
|
| 876 |
+
});
|
| 877 |
+
})
|
| 878 |
+
.catch(error => {
|
| 879 |
+
console.error('Error loading statistics:', error);
|
| 880 |
+
alert('Failed to load statistic data. Please refresh the page.');
|
| 881 |
+
});
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
|
| 885 |
+
function renderKasusTablePage() {
|
| 886 |
+
const head = document.getElementById("kasusHead");
|
| 887 |
+
const body = document.getElementById("kasusBody");
|
| 888 |
+
const pageInfo = document.getElementById("kasusPageInfo");
|
| 889 |
+
|
| 890 |
+
body.innerHTML = "";
|
| 891 |
+
|
| 892 |
+
const sorted = kasusDataFull
|
| 893 |
+
.slice()
|
| 894 |
+
.sort((a, b) => Number(b.kontribusi_kasus) - Number(a.kontribusi_kasus));
|
| 895 |
+
|
| 896 |
+
const start = kasusPage * kasusPerPage;
|
| 897 |
+
const end = start + kasusPerPage;
|
| 898 |
+
|
| 899 |
+
const pageData = sorted.slice(start, end);
|
| 900 |
+
|
| 901 |
+
if (pageData.length === 0) return;
|
| 902 |
+
|
| 903 |
+
// Mapping kolom → label tampilan
|
| 904 |
+
const kasusColumnLabels = {
|
| 905 |
+
"bebas_bersyarat": "Conditional Release (%)",
|
| 906 |
+
"bebas_dakwaan": "Acquittal (%)",
|
| 907 |
+
"denda": "Fine (%)",
|
| 908 |
+
"hukuman_mati": "Death Penalty (%)",
|
| 909 |
+
"kategori_pidana": "Category",
|
| 910 |
+
"kontribusi_kasus": "Case Contribution (%)",
|
| 911 |
+
"penjara": "Imprisonment (%)",
|
| 912 |
+
"penjara_seumur_hidup": "Life Imprisonment (%)",
|
| 913 |
+
"rata-rata_denda": "Average Fine (Rupiah)",
|
| 914 |
+
"rata-rata_penjara": "Average Imprisonment (Months)",
|
| 915 |
+
"penjara_seumur_hidup": "Life Imprisonment (%)",
|
| 916 |
+
"tindak_pidana": "Crime Action"
|
| 917 |
+
};
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
// === HEADER ===
|
| 921 |
+
head.innerHTML = "";
|
| 922 |
+
Object.keys(pageData[0]).forEach(col => {
|
| 923 |
+
const label = kasusColumnLabels[col] || col;
|
| 924 |
+
head.innerHTML += `<th>${label}</th>`;
|
| 925 |
+
});
|
| 926 |
+
|
| 927 |
+
// === BODY ===
|
| 928 |
+
pageData.forEach(row => {
|
| 929 |
+
let rowHTML = "<tr>";
|
| 930 |
+
Object.keys(row).forEach(col => {
|
| 931 |
+
let val = row[col];
|
| 932 |
+
|
| 933 |
+
if (typeof val === "number" && !Number.isInteger(val)) {
|
| 934 |
+
val = val.toFixed(3);
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
if (val === null || val === undefined || val === "") {
|
| 938 |
+
val = "-";
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
rowHTML += `<td>${val}</td>`;
|
| 942 |
+
});
|
| 943 |
+
rowHTML += "</tr>";
|
| 944 |
+
body.innerHTML += rowHTML;
|
| 945 |
+
});
|
| 946 |
+
|
| 947 |
+
const totalPages = Math.ceil(kasusDataFull.length / kasusPerPage);
|
| 948 |
+
pageInfo.innerText = `Page ${kasusPage + 1} / ${totalPages}`;
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
// Buttons
|
| 953 |
+
document.getElementById("prevKasus").onclick = function () {
|
| 954 |
+
if (kasusPage > 0) {
|
| 955 |
+
kasusPage--;
|
| 956 |
+
renderKasusTablePage();
|
| 957 |
+
}
|
| 958 |
+
};
|
| 959 |
+
document.getElementById("nextKasus").onclick = function () {
|
| 960 |
+
const totalPages = Math.ceil(kasusDataFull.length / kasusPerPage);
|
| 961 |
+
if (kasusPage < totalPages - 1) {
|
| 962 |
+
kasusPage++;
|
| 963 |
+
renderKasusTablePage();
|
| 964 |
+
}
|
| 965 |
+
};
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
// ================= GLOBAL PAGINATION STATE =================
|
| 970 |
+
let kasusPage = 0;
|
| 971 |
+
const kasusPerPage = 10;
|
| 972 |
+
let kasusDataFull = [];
|
| 973 |
+
|
| 974 |
+
// Load statistics on page load
|
| 975 |
+
loadStatistics('/api/statistics');
|
| 976 |
+
</script>
|
| 977 |
+
</body>
|
| 978 |
+
</html>
|
app/templates/map.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="id">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Peta Polygon Jawa Timur - 38 Kabupaten/Kota</title>
|
| 7 |
+
|
| 8 |
+
<!-- Custom CSS -->
|
| 9 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 10 |
+
|
| 11 |
+
<style>
|
| 12 |
+
/* Override styles for full map display */
|
| 13 |
+
.map-container {
|
| 14 |
+
border: 2px solid #333;
|
| 15 |
+
border-radius: 8px;
|
| 16 |
+
overflow: hidden;
|
| 17 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
| 18 |
+
}
|
| 19 |
+
.map-container iframe {
|
| 20 |
+
width: 100%;
|
| 21 |
+
height: 700px;
|
| 22 |
+
border: none;
|
| 23 |
+
}
|
| 24 |
+
</style>
|
| 25 |
+
</head>
|
| 26 |
+
<body>
|
| 27 |
+
|
| 28 |
+
<main>
|
| 29 |
+
<section id="map" class="map-section">
|
| 30 |
+
<div class="container" style="max-width: 100%; padding: 20px;">
|
| 31 |
+
<div id="mapContainer" class="map-container" style="width: 100%; height: 700px; margin-top: 20px;">
|
| 32 |
+
<!-- Server-side generated Folium map with vibrant colors -->
|
| 33 |
+
{{ map_html|safe }}
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</section>
|
| 37 |
+
</main>
|
| 38 |
+
|
| 39 |
+
</body>
|
| 40 |
+
</html>
|
cleaned_data.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:674384dfb6097ae4c6f2b5edc2649964328fe3067a52b09433095302e8259a37
|
| 3 |
+
size 33198579
|
data/geojson/Jawa Timur Map Chart.svg
ADDED
|
|
data/geojson/Kab_Kota SHP.7z.001
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5c0be61f0d037fa5c9047e666c25bd546404f9a7c11306b145935a5d6f7e58e9
|
| 3 |
+
size 47185920
|
data/geojson/TASWIL5000020230907KABKOTA.xml
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<MD_Metadata xmlns="http://www.isotc211.org/2005/gmd" xmlns:gco="http://www.isotc211.org/2005/gco" xmlns:gts="http://www.isotc211.org/2005/gts" xmlns:srv="http://www.isotc211.org/2005/srv" xmlns:gml="http://www.opengis.net/gml/3.2" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
| 2 |
+
<fileIdentifier>
|
| 3 |
+
<gco:CharacterString>TASWIL5000020230907KABKOTA</gco:CharacterString>
|
| 4 |
+
</fileIdentifier>
|
| 5 |
+
<language>
|
| 6 |
+
<LanguageCode codeList="http://www.loc.gov/standards/iso639-2/php/code_list.php" codeListValue="ind" codeSpace="ISO639-2">ind</LanguageCode>
|
| 7 |
+
</language>
|
| 8 |
+
<characterSet>
|
| 9 |
+
<MD_CharacterSetCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_CharacterSetCode" codeListValue="utf8" codeSpace="ISOTC211/19115">utf8</MD_CharacterSetCode>
|
| 10 |
+
</characterSet>
|
| 11 |
+
<hierarchyLevel>
|
| 12 |
+
<MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode" codeListValue="dataset" codeSpace="ISOTC211/19115">dataset</MD_ScopeCode>
|
| 13 |
+
</hierarchyLevel>
|
| 14 |
+
<hierarchyLevelName>
|
| 15 |
+
<gco:CharacterString>dataset</gco:CharacterString>
|
| 16 |
+
</hierarchyLevelName>
|
| 17 |
+
<contact>
|
| 18 |
+
<CI_ResponsibleParty>
|
| 19 |
+
<individualName>
|
| 20 |
+
<gco:CharacterString>Astrit Rimayanti</gco:CharacterString>
|
| 21 |
+
</individualName>
|
| 22 |
+
<organisationName>
|
| 23 |
+
<gco:CharacterString>Pusat Pemetaan Batas Wilayah</gco:CharacterString>
|
| 24 |
+
</organisationName>
|
| 25 |
+
<positionName>
|
| 26 |
+
<gco:CharacterString>Kepala Pusat Pemetaan Batas Wilayah</gco:CharacterString>
|
| 27 |
+
</positionName>
|
| 28 |
+
<role>
|
| 29 |
+
<CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode" codeListValue="author" codeSpace="ISOTC211/19115">author</CI_RoleCode>
|
| 30 |
+
</role>
|
| 31 |
+
</CI_ResponsibleParty>
|
| 32 |
+
</contact>
|
| 33 |
+
<dateStamp>
|
| 34 |
+
<gco:Date>2023-09-07</gco:Date>
|
| 35 |
+
</dateStamp>
|
| 36 |
+
<metadataStandardName>
|
| 37 |
+
<gco:CharacterString>ISO 19139 Geographic Information - Metadata - Implementation Specification</gco:CharacterString>
|
| 38 |
+
</metadataStandardName>
|
| 39 |
+
<metadataStandardVersion>
|
| 40 |
+
<gco:CharacterString>2007</gco:CharacterString>
|
| 41 |
+
</metadataStandardVersion>
|
| 42 |
+
<spatialRepresentationInfo>
|
| 43 |
+
<MD_VectorSpatialRepresentation>
|
| 44 |
+
<topologyLevel>
|
| 45 |
+
<MD_TopologyLevelCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_TopologyLevelCode" codeListValue="geometryOnly" codeSpace="ISOTC211/19115">geometryOnly</MD_TopologyLevelCode>
|
| 46 |
+
</topologyLevel>
|
| 47 |
+
<geometricObjects>
|
| 48 |
+
<MD_GeometricObjects>
|
| 49 |
+
<geometricObjectType>
|
| 50 |
+
<MD_GeometricObjectTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_GeometricObjectTypeCode" codeListValue="composite" codeSpace="ISOTC211/19115">composite</MD_GeometricObjectTypeCode>
|
| 51 |
+
</geometricObjectType>
|
| 52 |
+
<geometricObjectCount>
|
| 53 |
+
<gco:Integer>548</gco:Integer>
|
| 54 |
+
</geometricObjectCount>
|
| 55 |
+
</MD_GeometricObjects>
|
| 56 |
+
</geometricObjects>
|
| 57 |
+
</MD_VectorSpatialRepresentation>
|
| 58 |
+
</spatialRepresentationInfo>
|
| 59 |
+
<referenceSystemInfo>
|
| 60 |
+
<MD_ReferenceSystem>
|
| 61 |
+
<referenceSystemIdentifier>
|
| 62 |
+
<RS_Identifier>
|
| 63 |
+
<code>
|
| 64 |
+
<gco:CharacterString>EPSG 4326</gco:CharacterString>
|
| 65 |
+
</code>
|
| 66 |
+
<codeSpace>
|
| 67 |
+
<gco:CharacterString>EPSG</gco:CharacterString>
|
| 68 |
+
</codeSpace>
|
| 69 |
+
<version>
|
| 70 |
+
<gco:CharacterString>6.14(3.0.1)</gco:CharacterString>
|
| 71 |
+
</version>
|
| 72 |
+
</RS_Identifier>
|
| 73 |
+
</referenceSystemIdentifier>
|
| 74 |
+
</MD_ReferenceSystem>
|
| 75 |
+
</referenceSystemInfo>
|
| 76 |
+
<identificationInfo>
|
| 77 |
+
<MD_DataIdentification>
|
| 78 |
+
<citation>
|
| 79 |
+
<CI_Citation>
|
| 80 |
+
<title>
|
| 81 |
+
<gco:CharacterString>ADMINSITRASI_AR_KABKOTA</gco:CharacterString>
|
| 82 |
+
</title>
|
| 83 |
+
<date>
|
| 84 |
+
<CI_Date>
|
| 85 |
+
<date>
|
| 86 |
+
<gco:Date>2023-09-07</gco:Date>
|
| 87 |
+
</date>
|
| 88 |
+
<dateType>
|
| 89 |
+
<CI_DateTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_DateTypeCode" codeListValue="revision" codeSpace="ISOTC211/19115">revision</CI_DateTypeCode>
|
| 90 |
+
</dateType>
|
| 91 |
+
</CI_Date>
|
| 92 |
+
</date>
|
| 93 |
+
<edition>
|
| 94 |
+
<gco:CharacterString>Edisi Tahun 2023</gco:CharacterString>
|
| 95 |
+
</edition>
|
| 96 |
+
<editionDate>
|
| 97 |
+
<gco:Date>2023-09-07</gco:Date>
|
| 98 |
+
</editionDate>
|
| 99 |
+
<citedResponsibleParty>
|
| 100 |
+
<CI_ResponsibleParty>
|
| 101 |
+
<individualName>
|
| 102 |
+
<gco:CharacterString>Astrit Rimayanti</gco:CharacterString>
|
| 103 |
+
</individualName>
|
| 104 |
+
<organisationName>
|
| 105 |
+
<gco:CharacterString>Pusat Pemetaan Batas Wilayah</gco:CharacterString>
|
| 106 |
+
</organisationName>
|
| 107 |
+
<positionName>
|
| 108 |
+
<gco:CharacterString>Kepala Pusat Pemetaan Batas Wilayah</gco:CharacterString>
|
| 109 |
+
</positionName>
|
| 110 |
+
<role>
|
| 111 |
+
<CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode" codeListValue="resourceProvider" codeSpace="ISOTC211/19115">resourceProvider</CI_RoleCode>
|
| 112 |
+
</role>
|
| 113 |
+
</CI_ResponsibleParty>
|
| 114 |
+
</citedResponsibleParty>
|
| 115 |
+
<presentationForm>
|
| 116 |
+
<CI_PresentationFormCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_PresentationFormCode" codeListValue="mapDigital" codeSpace="ISOTC211/19115">mapDigital</CI_PresentationFormCode>
|
| 117 |
+
</presentationForm>
|
| 118 |
+
</CI_Citation>
|
| 119 |
+
</citation>
|
| 120 |
+
<abstract>
|
| 121 |
+
<gco:CharacterString>Geodatabase data batas wilayah administrasi kabupaten/kota edisi September 2023 (merupakan pemutakhiran dari geodatabase batas wilayah administrasi kabupaten/kota bulan Desember tahun 2022. Proses pemutakhiran yang dilakukan antara lain pemutakhiran segmen batas daerah hasil kesepakatan dan yang telah ditetapkan melalui Permendagri, penyesuaian alokasi wilayah administrasi di wilayah Papua, penyesuaian alokasi pulau dan wilayah terapung.Sumber data yang digunakan untuk fitur area batas wilayah administrasi antara lain:(1) Data batas wilayah provinsi dan kabupaten/kota yang belum ditegaskan dari data peta Rupabumi Indonesia skala 1:25.000 dan 1:50.000; (2) Data batas wilayah administrasi kabupaten/kota yang belum ditegaskan hasil kegiatan ajudikasi batas kabupaten/kota tahun 2013 dan 2014; (3) Data batas daerah hasil kesepakatan yang bersumber dari data digital Kemendagri edisi April 2023 untuk wilayah Sumatera, Kalimantan, Jawa, Bali Nusa Tenggara, Maluku, dan Papua; (4) Data batas daerah yang telah ditetapkan dalam Peraturan Menteri Dalam Negeri.Sumber data yang digunakan untuk fitur wilayah administrasi antara lain: (1) Unsur batas wilayah administrasi kabupaten/kota (ADMINISTRASI_LN); (2) IGD Garis Pantai dari Pusat Pemetaan Kelautan dan Lingkungan Pantai edisi Tahun 2022, yang merupakan pemutakhiran garis pantai penetapan tahun 2021; (3) Data batas negara edisi Agustus 2018. Data ini masih terdapat kesalahan topologi pada fitur ADMINISTRASI_LN yang disebabkan oleh garis batas wilayah yang telah ditetapkan dalam Permendagri masih ada yang saling berpotongan dan ujung batasnya masih menggantung atau belum terhubung dengan ujung batas lainnya.</gco:CharacterString>
|
| 122 |
+
</abstract>
|
| 123 |
+
<pointOfContact>
|
| 124 |
+
<CI_ResponsibleParty>
|
| 125 |
+
<individualName>
|
| 126 |
+
<gco:CharacterString>Astrit Rimayanti</gco:CharacterString>
|
| 127 |
+
</individualName>
|
| 128 |
+
<organisationName>
|
| 129 |
+
<gco:CharacterString>Pusat Pemetaan Batas Wilayah</gco:CharacterString>
|
| 130 |
+
</organisationName>
|
| 131 |
+
<positionName>
|
| 132 |
+
<gco:CharacterString>Kepala Pusat Pemetaan Batas Wilayah</gco:CharacterString>
|
| 133 |
+
</positionName>
|
| 134 |
+
<role>
|
| 135 |
+
<CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode" codeListValue="resourceProvider" codeSpace="ISOTC211/19115">resourceProvider</CI_RoleCode>
|
| 136 |
+
</role>
|
| 137 |
+
</CI_ResponsibleParty>
|
| 138 |
+
</pointOfContact>
|
| 139 |
+
<resourceMaintenance>
|
| 140 |
+
<MD_MaintenanceInformation>
|
| 141 |
+
<maintenanceAndUpdateFrequency>
|
| 142 |
+
<MD_MaintenanceFrequencyCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_MaintenanceFrequencyCode" codeListValue="annually" codeSpace="ISOTC211/19115">annually</MD_MaintenanceFrequencyCode>
|
| 143 |
+
</maintenanceAndUpdateFrequency>
|
| 144 |
+
<updateScope>
|
| 145 |
+
<MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode" codeListValue="attribute" codeSpace="ISOTC211/19115">attribute</MD_ScopeCode>
|
| 146 |
+
</updateScope>
|
| 147 |
+
<updateScope>
|
| 148 |
+
<MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode" codeListValue="feature" codeSpace="ISOTC211/19115">feature</MD_ScopeCode>
|
| 149 |
+
</updateScope>
|
| 150 |
+
</MD_MaintenanceInformation>
|
| 151 |
+
</resourceMaintenance>
|
| 152 |
+
<descriptiveKeywords>
|
| 153 |
+
<MD_Keywords>
|
| 154 |
+
<keyword>
|
| 155 |
+
<gco:CharacterString>Wilayah Administrasi Kabupaten/Kota Indonesia</gco:CharacterString>
|
| 156 |
+
</keyword>
|
| 157 |
+
</MD_Keywords>
|
| 158 |
+
</descriptiveKeywords>
|
| 159 |
+
<descriptiveKeywords>
|
| 160 |
+
<MD_Keywords>
|
| 161 |
+
<keyword>
|
| 162 |
+
<gco:CharacterString>Downloadable Data</gco:CharacterString>
|
| 163 |
+
</keyword>
|
| 164 |
+
<thesaurusName uuidref="723f6998-058e-11dc-8314-0800200c9a66" />
|
| 165 |
+
</MD_Keywords>
|
| 166 |
+
</descriptiveKeywords>
|
| 167 |
+
<resourceConstraints>
|
| 168 |
+
<MD_Constraints>
|
| 169 |
+
<useLimitation>
|
| 170 |
+
<gco:CharacterString>Data batas yang berstatus indikatif tidak dapat dijadikan referensi hukum.</gco:CharacterString>
|
| 171 |
+
</useLimitation>
|
| 172 |
+
</MD_Constraints>
|
| 173 |
+
</resourceConstraints>
|
| 174 |
+
<spatialRepresentationType>
|
| 175 |
+
<MD_SpatialRepresentationTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_SpatialRepresentationTypeCode" codeListValue="vector" codeSpace="ISOTC211/19115">vector</MD_SpatialRepresentationTypeCode>
|
| 176 |
+
</spatialRepresentationType>
|
| 177 |
+
<spatialResolution>
|
| 178 |
+
<MD_Resolution>
|
| 179 |
+
<equivalentScale>
|
| 180 |
+
<MD_RepresentativeFraction>
|
| 181 |
+
<denominator>
|
| 182 |
+
<gco:Integer>50000</gco:Integer>
|
| 183 |
+
</denominator>
|
| 184 |
+
</MD_RepresentativeFraction>
|
| 185 |
+
</equivalentScale>
|
| 186 |
+
</MD_Resolution>
|
| 187 |
+
</spatialResolution>
|
| 188 |
+
<language>
|
| 189 |
+
<LanguageCode codeList="http://www.loc.gov/standards/iso639-2/php/code_list.php" codeListValue="ind" codeSpace="ISO639-2">ind</LanguageCode>
|
| 190 |
+
</language>
|
| 191 |
+
<characterSet>
|
| 192 |
+
<MD_CharacterSetCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_CharacterSetCode" codeListValue="utf8" codeSpace="ISOTC211/19115">utf8</MD_CharacterSetCode>
|
| 193 |
+
</characterSet>
|
| 194 |
+
<topicCategory>
|
| 195 |
+
<MD_TopicCategoryCode>boundaries</MD_TopicCategoryCode>
|
| 196 |
+
</topicCategory>
|
| 197 |
+
<environmentDescription>
|
| 198 |
+
<gco:CharacterString> Version 6.2 (Build 9200) ; Esri ArcGIS 10.8.1.14362</gco:CharacterString>
|
| 199 |
+
</environmentDescription>
|
| 200 |
+
<extent>
|
| 201 |
+
<EX_Extent>
|
| 202 |
+
<geographicElement>
|
| 203 |
+
<EX_GeographicBoundingBox>
|
| 204 |
+
<extentTypeCode>
|
| 205 |
+
<gco:Boolean>true</gco:Boolean>
|
| 206 |
+
</extentTypeCode>
|
| 207 |
+
<westBoundLongitude>
|
| 208 |
+
<gco:Decimal>94.971911</gco:Decimal>
|
| 209 |
+
</westBoundLongitude>
|
| 210 |
+
<eastBoundLongitude>
|
| 211 |
+
<gco:Decimal>141.020042</gco:Decimal>
|
| 212 |
+
</eastBoundLongitude>
|
| 213 |
+
<southBoundLatitude>
|
| 214 |
+
<gco:Decimal>-11.007615</gco:Decimal>
|
| 215 |
+
</southBoundLatitude>
|
| 216 |
+
<northBoundLatitude>
|
| 217 |
+
<gco:Decimal>6.076832</gco:Decimal>
|
| 218 |
+
</northBoundLatitude>
|
| 219 |
+
</EX_GeographicBoundingBox>
|
| 220 |
+
</geographicElement>
|
| 221 |
+
<verticalElement>
|
| 222 |
+
<EX_VerticalExtent>
|
| 223 |
+
<minimumValue>
|
| 224 |
+
<gco:Real>0</gco:Real>
|
| 225 |
+
</minimumValue>
|
| 226 |
+
<maximumValue>
|
| 227 |
+
<gco:Real>0</gco:Real>
|
| 228 |
+
</maximumValue>
|
| 229 |
+
<verticalCRS gco:nilReason="other:see_referenceSystemInfo" />
|
| 230 |
+
</EX_VerticalExtent>
|
| 231 |
+
</verticalElement>
|
| 232 |
+
</EX_Extent>
|
| 233 |
+
</extent>
|
| 234 |
+
</MD_DataIdentification>
|
| 235 |
+
</identificationInfo>
|
| 236 |
+
<contentInfo>
|
| 237 |
+
<MD_FeatureCatalogueDescription>
|
| 238 |
+
<complianceCode>
|
| 239 |
+
<gco:Boolean>true</gco:Boolean>
|
| 240 |
+
</complianceCode>
|
| 241 |
+
<language>
|
| 242 |
+
<LanguageCode codeList="http://www.loc.gov/standards/iso639-2/php/code_list.php" codeListValue="ind" codeSpace="ISO639-2">ind</LanguageCode>
|
| 243 |
+
</language>
|
| 244 |
+
<includedWithDataset>
|
| 245 |
+
<gco:Boolean>true</gco:Boolean>
|
| 246 |
+
</includedWithDataset>
|
| 247 |
+
<featureCatalogueCitation>
|
| 248 |
+
<CI_Citation>
|
| 249 |
+
<title>
|
| 250 |
+
<gco:CharacterString>Batas Wilayah Administrasi Kabupaten/Kota Indonesia</gco:CharacterString>
|
| 251 |
+
</title>
|
| 252 |
+
<date>
|
| 253 |
+
<CI_Date>
|
| 254 |
+
<date>
|
| 255 |
+
<gco:Date>2023-09-07</gco:Date>
|
| 256 |
+
</date>
|
| 257 |
+
<dateType>
|
| 258 |
+
<CI_DateTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_DateTypeCode" codeListValue="revision" codeSpace="ISOTC211/19115">revision</CI_DateTypeCode>
|
| 259 |
+
</dateType>
|
| 260 |
+
</CI_Date>
|
| 261 |
+
</date>
|
| 262 |
+
<edition>
|
| 263 |
+
<gco:CharacterString>Edisi Tahun 2023</gco:CharacterString>
|
| 264 |
+
</edition>
|
| 265 |
+
<editionDate>
|
| 266 |
+
<gco:Date>2023-09-07</gco:Date>
|
| 267 |
+
</editionDate>
|
| 268 |
+
</CI_Citation>
|
| 269 |
+
</featureCatalogueCitation>
|
| 270 |
+
</MD_FeatureCatalogueDescription>
|
| 271 |
+
</contentInfo>
|
| 272 |
+
<distributionInfo>
|
| 273 |
+
<MD_Distribution>
|
| 274 |
+
<distributionFormat>
|
| 275 |
+
<MD_Format>
|
| 276 |
+
<name>
|
| 277 |
+
<gco:CharacterString>File Geodatabase Feature Class</gco:CharacterString>
|
| 278 |
+
</name>
|
| 279 |
+
<version>
|
| 280 |
+
<gco:CharacterString>1</gco:CharacterString>
|
| 281 |
+
</version>
|
| 282 |
+
</MD_Format>
|
| 283 |
+
</distributionFormat>
|
| 284 |
+
</MD_Distribution>
|
| 285 |
+
</distributionInfo>
|
| 286 |
+
<dataQualityInfo>
|
| 287 |
+
<DQ_DataQuality>
|
| 288 |
+
<scope>
|
| 289 |
+
<DQ_Scope>
|
| 290 |
+
<level>
|
| 291 |
+
<MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode" codeListValue="dataset" codeSpace="ISOTC211/19115">dataset</MD_ScopeCode>
|
| 292 |
+
</level>
|
| 293 |
+
</DQ_Scope>
|
| 294 |
+
</scope>
|
| 295 |
+
<lineage>
|
| 296 |
+
<LI_Lineage>
|
| 297 |
+
<statement>
|
| 298 |
+
<gco:CharacterString>Data ini merupakan kompilasi batas wilayah administrasi kabupaten/kota dari berbagai sumber data.</gco:CharacterString>
|
| 299 |
+
</statement>
|
| 300 |
+
<processStep>
|
| 301 |
+
<LI_ProcessStep>
|
| 302 |
+
<description>
|
| 303 |
+
<gco:CharacterString>Geodatabase data batas wilayah administrasi kabupaten/kota edisi September 2023 merupakan pemutakhiran geodatabase batas wilayah administrasi kabupaten/kota edisi Desember tahun 2022. Proses pemutakhiran yang dilakukan antara lain pembaharuan data batas daerah yang telah ditetapkan dalam Peraturan Menteri Dalam Negeri hingga tahun 2022, hasil kesepakatan hingga April 2023, dan pemutakhiran garis pantai edisi tahun 2022, penyesuaian alokasi wilayah administrasi pulau dan wilayah terapung.</gco:CharacterString>
|
| 304 |
+
</description>
|
| 305 |
+
</LI_ProcessStep>
|
| 306 |
+
</processStep>
|
| 307 |
+
<source>
|
| 308 |
+
<LI_Source>
|
| 309 |
+
<description>
|
| 310 |
+
<gco:CharacterString>Sumber data yang digunakan untuk fitur garis batas wilayah administrasi antara lain:(1) Data batas wilayah provinsi dan kabupaten/kota yang belum ditegaskan dari data peta Rupabumi Indonesia skala 1:25.000 dan 1:50.000; (2) Data batas wilayah administrasi kabupaten/kota yang belum ditegaskan hasil kegiatan ajudikasi batas kabupaten/kota tahun 2013 dan 2014; (3) Data batas daerah hasil kesepakatan yang bersumber dari data digital Kemendagri edisi April 2023 untuk wilayah Sumatera, Kalimantan, Jawa, Bali Nusa Tenggara, Maluku, dan Papua; (4) Data batas daerah yang telah ditetapkan dalam Peraturan Menteri Dalam Negeri.
|
| 311 |
+
|
| 312 |
+
Sumber data yang digunakan untuk fitur wilayah administrasi antara lain: (1) Unsur batas wilayah administrasi kabupaten/kota (ADMINISTRASI_LN); (2) IGD Garis Pantai dari Pusat Pemetaan Kelautan dan Lingkungan Pantai edisi Tahun 2022, yang merupakan pemutakhiran garis pantai penetapan tahun 2021; (3) Data batas negara edisi Agustus 2018.
|
| 313 |
+
|
| 314 |
+
Sumber data untuk alokasi wilayah administrasi pulau antara lain: - Kepmendagri 100.1.1-6117 tahun 2022 tentang Pemberian dan Pemutakhiran Kode, Data Wilayah Administrasi Pemerintahan dan Pulau Tahun 2022.</gco:CharacterString>
|
| 315 |
+
</description>
|
| 316 |
+
</LI_Source>
|
| 317 |
+
</source>
|
| 318 |
+
</LI_Lineage>
|
| 319 |
+
</lineage>
|
| 320 |
+
</DQ_DataQuality>
|
| 321 |
+
</dataQualityInfo>
|
| 322 |
+
<applicationSchemaInfo>
|
| 323 |
+
<MD_ApplicationSchemaInformation>
|
| 324 |
+
<name gco:nilReason="missing" />
|
| 325 |
+
<schemaLanguage>
|
| 326 |
+
<gco:CharacterString>xml</gco:CharacterString>
|
| 327 |
+
</schemaLanguage>
|
| 328 |
+
<constraintLanguage gco:nilReason="missing" />
|
| 329 |
+
</MD_ApplicationSchemaInformation>
|
| 330 |
+
</applicationSchemaInfo>
|
| 331 |
+
<metadataMaintenance>
|
| 332 |
+
<MD_MaintenanceInformation>
|
| 333 |
+
<maintenanceAndUpdateFrequency>
|
| 334 |
+
<MD_MaintenanceFrequencyCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_MaintenanceFrequencyCode" codeListValue="annually" codeSpace="ISOTC211/19115">annually</MD_MaintenanceFrequencyCode>
|
| 335 |
+
</maintenanceAndUpdateFrequency>
|
| 336 |
+
<updateScope>
|
| 337 |
+
<MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode" codeListValue="attribute" codeSpace="ISOTC211/19115">attribute</MD_ScopeCode>
|
| 338 |
+
</updateScope>
|
| 339 |
+
<updateScope>
|
| 340 |
+
<MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode" codeListValue="feature" codeSpace="ISOTC211/19115">feature</MD_ScopeCode>
|
| 341 |
+
</updateScope>
|
| 342 |
+
</MD_MaintenanceInformation>
|
| 343 |
+
</metadataMaintenance>
|
| 344 |
+
</MD_Metadata>
|
data/geojson/jatim.geojson
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
c:\Users\Fedi Arta\Downloads\TASWIL5000020230907KABKOTA.xml{
|
| 2 |
+
"type": "FeatureCollection",
|
| 3 |
+
"features": [
|
| 4 |
+
{
|
| 5 |
+
"type": "Feature",
|
| 6 |
+
"properties": {
|
| 7 |
+
"name": "Kota Surabaya",
|
| 8 |
+
"population": 3000000,
|
| 9 |
+
"area": 350.7
|
| 10 |
+
},
|
| 11 |
+
"geometry": {
|
| 12 |
+
"type": "Polygon",
|
| 13 |
+
"coordinates": [
|
| 14 |
+
[
|
| 15 |
+
[112.6401, -7.2756],
|
| 16 |
+
[112.6401, -7.2500],
|
| 17 |
+
[112.6500, -7.2500],
|
| 18 |
+
[112.6500, -7.2756],
|
| 19 |
+
[112.6401, -7.2756]
|
| 20 |
+
]
|
| 21 |
+
]
|
| 22 |
+
}
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"type": "Feature",
|
| 26 |
+
"properties": {
|
| 27 |
+
"name": "Kota Malang",
|
| 28 |
+
"population": 900000,
|
| 29 |
+
"area": 147.5
|
| 30 |
+
},
|
| 31 |
+
"geometry": {
|
| 32 |
+
"type": "Polygon",
|
| 33 |
+
"coordinates": [
|
| 34 |
+
[
|
| 35 |
+
[112.6200, -7.9790],
|
| 36 |
+
[112.6200, -7.9500],
|
| 37 |
+
[112.6400, -7.9500],
|
| 38 |
+
[112.6400, -7.9790],
|
| 39 |
+
[112.6200, -7.9790]
|
| 40 |
+
]
|
| 41 |
+
]
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
]
|
| 45 |
+
}
|
data/geojson/jatim_kabkota.geojson
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask
|
| 2 |
+
folium
|
| 3 |
+
geopandas
|
| 4 |
+
pandas
|
| 5 |
+
numpy
|
| 6 |
+
contextily
|
| 7 |
+
matplotlib
|
| 8 |
+
pyproj
|
| 9 |
+
shapely
|
| 10 |
+
requests
|
| 11 |
+
pmdarima
|
| 12 |
+
statsmodels
|
run.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import app
|
| 2 |
+
|
| 3 |
+
if __name__ == '__main__':
|
| 4 |
+
app.run(debug=True, port=5555)
|
scripts/generate_heatmap_geopandas.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate a choropleth heatmap for Jawa Timur kabupaten/kota using GeoPandas.
|
| 3 |
+
|
| 4 |
+
This script:
|
| 5 |
+
- loads `data/geojson/jatim_kabkota.geojson` (expects a `properties.name` field),
|
| 6 |
+
- optionally merges a CSV of metrics (by `name`),
|
| 7 |
+
- creates a choropleth PNG saved to `app/static/img/heatmap_jatim.png`,
|
| 8 |
+
- writes an augmented GeoJSON to `app/static/geojson/jatim_kabkota_metric.geojson`.
|
| 9 |
+
|
| 10 |
+
Run: python scripts/generate_heatmap_geopandas.py [--metrics data/metrics.csv]
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
import random
|
| 18 |
+
|
| 19 |
+
import geopandas as gpd
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import pandas as pd
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
import contextily as ctx
|
| 26 |
+
except Exception:
|
| 27 |
+
ctx = None
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
import matplotlib.patheffects as pe
|
| 31 |
+
except Exception:
|
| 32 |
+
pe = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_geodata(path: str) -> gpd.GeoDataFrame:
|
| 36 |
+
gdf = gpd.read_file(path)
|
| 37 |
+
if gdf.crs is None:
|
| 38 |
+
# most GeoJSONs are in WGS84
|
| 39 |
+
gdf = gdf.set_crs(epsg=4326, allow_override=True)
|
| 40 |
+
return gdf
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def prepare_metric(gdf: gpd.GeoDataFrame, metrics_csv: str | None) -> gpd.GeoDataFrame:
|
| 44 |
+
gdf = gdf.copy()
|
| 45 |
+
if metrics_csv and os.path.exists(metrics_csv):
|
| 46 |
+
dfm = pd.read_csv(metrics_csv)
|
| 47 |
+
# assume merge key is 'name'
|
| 48 |
+
if 'name' not in dfm.columns:
|
| 49 |
+
raise ValueError('metrics CSV must contain a `name` column to join on')
|
| 50 |
+
gdf = gdf.merge(dfm, on='name', how='left')
|
| 51 |
+
if 'metric' not in gdf.columns:
|
| 52 |
+
# user may have different metric column; try to pick the first numeric
|
| 53 |
+
numeric_cols = dfm.select_dtypes('number').columns.tolist()
|
| 54 |
+
if numeric_cols:
|
| 55 |
+
gdf['metric'] = gdf[numeric_cols[0]]
|
| 56 |
+
else:
|
| 57 |
+
raise ValueError('metrics CSV provided but no numeric column found to use as metric')
|
| 58 |
+
else:
|
| 59 |
+
# fallback: create a reproducible random metric for demonstration
|
| 60 |
+
random.seed(42)
|
| 61 |
+
gdf['metric'] = np.random.randint(5, 100, size=len(gdf))
|
| 62 |
+
# fill missing with 0
|
| 63 |
+
gdf['metric'] = gdf['metric'].fillna(0).astype(float)
|
| 64 |
+
return gdf
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def plot_heatmap(gdf: gpd.GeoDataFrame, out_png: str, out_geojson: str, cmap: str = 'OrRd') -> None:
|
| 68 |
+
# project to web mercator for basemap / correct area calculations
|
| 69 |
+
gdf_web = gdf.to_crs(epsg=3857)
|
| 70 |
+
|
| 71 |
+
fig, ax = plt.subplots(1, 1, figsize=(12, 12))
|
| 72 |
+
|
| 73 |
+
# plot choropleth
|
| 74 |
+
gdf_web.plot(
|
| 75 |
+
column='metric',
|
| 76 |
+
cmap=cmap,
|
| 77 |
+
linewidth=0.5,
|
| 78 |
+
edgecolor='white',
|
| 79 |
+
ax=ax,
|
| 80 |
+
legend=True,
|
| 81 |
+
legend_kwds={'shrink': 0.6},
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# add labels at centroids
|
| 85 |
+
for idx, row in gdf_web.iterrows():
|
| 86 |
+
try:
|
| 87 |
+
cent = row['geometry'].centroid
|
| 88 |
+
x, y = cent.x, cent.y
|
| 89 |
+
name = row.get('name') or row.get('NAME') or ''
|
| 90 |
+
short = str(name).replace('Kab. ', '').replace('Kota ', '')
|
| 91 |
+
if short:
|
| 92 |
+
txt = ax.text(
|
| 93 |
+
x, y, short,
|
| 94 |
+
fontsize=8, ha='center', va='center', color='white'
|
| 95 |
+
)
|
| 96 |
+
if pe is not None:
|
| 97 |
+
txt.set_path_effects([pe.Stroke(linewidth=2, foreground='black'), pe.Normal()])
|
| 98 |
+
except Exception:
|
| 99 |
+
# some geometries may be empty; skip
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
# add basemap if contextily is available
|
| 103 |
+
if ctx is not None:
|
| 104 |
+
try:
|
| 105 |
+
ctx.add_basemap(ax, source=ctx.providers.CartoDB.Positron)
|
| 106 |
+
except Exception:
|
| 107 |
+
# fallback: ignore basemap if provider errors
|
| 108 |
+
pass
|
| 109 |
+
|
| 110 |
+
ax.set_axis_off()
|
| 111 |
+
ax.set_title('Heatmap: Jawa Timur - Kabupaten/Kota', fontsize=16)
|
| 112 |
+
|
| 113 |
+
# ensure output directory exists
|
| 114 |
+
os.makedirs(os.path.dirname(out_png), exist_ok=True)
|
| 115 |
+
fig.savefig(out_png, dpi=150, bbox_inches='tight')
|
| 116 |
+
plt.close(fig)
|
| 117 |
+
|
| 118 |
+
# write augmented geojson (keep original CRS WGS84 for web use)
|
| 119 |
+
try:
|
| 120 |
+
gdf.to_file(out_geojson, driver='GeoJSON')
|
| 121 |
+
except Exception as e:
|
| 122 |
+
print('Warning: failed to write GeoJSON:', e, file=sys.stderr)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main(argv=None):
|
| 126 |
+
parser = argparse.ArgumentParser()
|
| 127 |
+
parser.add_argument('--geojson', default=os.path.join('data', 'geojson', 'jatim_kabkota.geojson'))
|
| 128 |
+
parser.add_argument('--metrics', default=None, help='optional CSV with columns `name` and a numeric metric')
|
| 129 |
+
parser.add_argument('--out-png', default=os.path.join('app', 'static', 'img', 'heatmap_jatim.png'))
|
| 130 |
+
parser.add_argument('--out-geojson', default=os.path.join('app', 'static', 'geojson', 'jatim_kabkota_metric.geojson'))
|
| 131 |
+
parser.add_argument('--cmap', default='OrRd')
|
| 132 |
+
args = parser.parse_args(argv)
|
| 133 |
+
|
| 134 |
+
if not os.path.exists(args.geojson):
|
| 135 |
+
print('GeoJSON not found at', args.geojson, file=sys.stderr)
|
| 136 |
+
sys.exit(2)
|
| 137 |
+
|
| 138 |
+
gdf = load_geodata(args.geojson)
|
| 139 |
+
gdf = prepare_metric(gdf, args.metrics)
|
| 140 |
+
|
| 141 |
+
# ensure static folders exist
|
| 142 |
+
os.makedirs(os.path.dirname(args.out_geojson), exist_ok=True)
|
| 143 |
+
os.makedirs(os.path.dirname(args.out_png), exist_ok=True)
|
| 144 |
+
|
| 145 |
+
plot_heatmap(gdf, args.out_png, args.out_geojson, cmap=args.cmap)
|
| 146 |
+
|
| 147 |
+
print('Heatmap image written to:', args.out_png)
|
| 148 |
+
print('Augmented GeoJSON written to:', args.out_geojson)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == '__main__':
|
| 152 |
+
main()
|
scripts/generate_map.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import folium
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
def generate_map(geojson_file, output_file):
|
| 6 |
+
# Load GeoJSON data
|
| 7 |
+
with open(geojson_file) as f:
|
| 8 |
+
geojson_data = json.load(f)
|
| 9 |
+
|
| 10 |
+
# Create a base map
|
| 11 |
+
m = folium.Map(location=[-7.5, 112.5], zoom_start=7)
|
| 12 |
+
|
| 13 |
+
# Add GeoJSON overlay
|
| 14 |
+
folium.GeoJson(
|
| 15 |
+
geojson_data,
|
| 16 |
+
name='geojson',
|
| 17 |
+
tooltip=folium.GeoJsonTooltip(fields=['name'], aliases=['Region:'])
|
| 18 |
+
).add_to(m)
|
| 19 |
+
|
| 20 |
+
# Add layer control
|
| 21 |
+
folium.LayerControl().add_to(m)
|
| 22 |
+
|
| 23 |
+
# Save the map to an HTML file
|
| 24 |
+
m.save(output_file)
|
| 25 |
+
print(f'Map has been generated and saved to {output_file}')
|
| 26 |
+
|
| 27 |
+
if __name__ == '__main__':
|
| 28 |
+
geojson_path = os.path.join('data', 'geojson', 'jatim.geojson')
|
| 29 |
+
output_path = os.path.join('app', 'templates', 'map.html')
|
| 30 |
+
generate_map(geojson_path, output_path)
|
test_api.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.routes import app
|
| 2 |
+
|
| 3 |
+
with app.test_client() as client:
|
| 4 |
+
print('Testing /api/statistics...')
|
| 5 |
+
response = client.get('/api/statistics')
|
| 6 |
+
print(f'Status Code: {response.status_code}')
|
| 7 |
+
|
| 8 |
+
if response.status_code != 200:
|
| 9 |
+
print(f'Error Response:')
|
| 10 |
+
print(response.get_data(as_text=True)[:1000])
|
| 11 |
+
else:
|
| 12 |
+
data = response.get_json()
|
| 13 |
+
print(f'\n✅ API SUCCESS!')
|
| 14 |
+
print(f'Total Cases: {data.get("total_cases", 0)}')
|
| 15 |
+
print(f'Total PN: {data.get("total_pn", 0)}')
|
| 16 |
+
print(f'City Options: {len(data.get("city_options", []))}')
|
| 17 |
+
print(f'Year Options: {len(data.get("year_options", []))}')
|
| 18 |
+
print(f'Crime Types: {len(data.get("crime_types", []))}')
|
| 19 |
+
|
| 20 |
+
if data.get("city_options"):
|
| 21 |
+
print(f'\nFirst 5 Cities:')
|
| 22 |
+
for city in data["city_options"][:5]:
|
| 23 |
+
print(f' - {city["label"]}')
|
test_app.py
ADDED
|
File without changes
|