atsuga commited on
Commit
d3b7829
·
verified ·
1 Parent(s): 6d9f164

Update app/map_py_heatmap.py

Browse files
Files changed (1) hide show
  1. app/map_py_heatmap.py +544 -544
app/map_py_heatmap.py CHANGED
@@ -1,544 +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_()
 
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 = "/app/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_()