atsuga commited on
Commit
6f10462
·
verified ·
1 Parent(s): 6f21d8f

Upload 40 files

Browse files
.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

  • SHA256: fe1b4e5882784a83a9d9c2a265fd0642e6131aa43ff703277d31f8be5fd2de3a
  • Pointer size: 131 Bytes
  • Size of remote file: 268 kB
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>&copy; 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">&times;</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