|
|
<!DOCTYPE html> |
|
|
<html lang="id"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>East Java Legal Cases Mapping</title> |
|
|
|
|
|
|
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |
|
|
|
|
|
|
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> |
|
|
|
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> |
|
|
</head> |
|
|
<body> |
|
|
|
|
|
<header class="header"> |
|
|
<div class="container"> |
|
|
<h1 class="logo"> |
|
|
<i class="fas fa-balance-scale"></i> |
|
|
East Java Legal Cases Mapping |
|
|
</h1> |
|
|
<nav class="nav"> |
|
|
<ul> |
|
|
<li><a href="#home" class="nav-link">Home</a></li> |
|
|
<li><a href="#statistics" class="nav-link">Statistics</a></li> |
|
|
<li><a href="#about" class="nav-link">About</a></li> |
|
|
</ul> |
|
|
</nav> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
|
|
|
<main> |
|
|
|
|
|
|
|
|
<section id="home" class="map-section"> |
|
|
<div class="container-fluid" style="max-width: 100%; padding: 20px 40px;"> |
|
|
|
|
|
|
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /> |
|
|
|
|
|
<div class="summary-container" style=" |
|
|
display: flex; |
|
|
gap: 20px; |
|
|
margin-bottom: 30px; |
|
|
flex-wrap: wrap; |
|
|
"> |
|
|
|
|
|
<div class="summary-card" style=" |
|
|
flex: 1 1 200px; |
|
|
background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
text-align: center; |
|
|
"> |
|
|
<div class="summary-icon" style="font-size: 24px; color: #2E86AB; margin-bottom: 8px;"> |
|
|
<i class="fas fa-gavel"></i> |
|
|
</div> |
|
|
<div class="summary-title" style="font-size: 14px; color: #555;">Total Criminal Verdicts</div> |
|
|
<div class="summary-value" id="totalPutusan" style="font-size: 24px; font-weight: bold;">0</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="summary-card" style=" |
|
|
flex: 1 1 200px; |
|
|
background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
text-align: center; |
|
|
"> |
|
|
<div class="summary-icon" style="font-size: 24px; color: #FF7F0E; margin-bottom: 8px;"> |
|
|
<i class="fas fa-landmark"></i> |
|
|
</div> |
|
|
<div class="summary-title" style="font-size: 14px; color: #555;">Total District Courts</div> |
|
|
<div class="summary-value" id="totalPN" style="font-size: 24px; font-weight: bold;">0</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="filter-panel" style="background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
margin-bottom: 20px; |
|
|
display: flex; |
|
|
gap: 15px; |
|
|
align-items: flex-end; |
|
|
flex-wrap: wrap;"> |
|
|
|
|
|
<div style="flex: 1; min-width: 200px;"> |
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Categories:</label> |
|
|
<select id="categoryFilter" style="width: 100%; |
|
|
padding: 10px 15px; |
|
|
border: 1px solid #ddd; |
|
|
border-radius: 5px; |
|
|
font-size: 14px;"> |
|
|
<option value="all">All Categories</option> |
|
|
<option value="pidana">Pidana Umum</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div style="flex: 1; min-width: 200px;"> |
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Years:</label> |
|
|
<select id="yearFilter" style="width: 100%; |
|
|
padding: 10px 15px; |
|
|
border: 1px solid #ddd; |
|
|
border-radius: 5px; |
|
|
font-size: 14px;"> |
|
|
<option value="all">All Years</option> |
|
|
|
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div style="flex: 1; min-width: 200px;"> |
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Crime Types:</label> |
|
|
<select id="crimeFilter" style="width: 100%; |
|
|
padding: 10px 15px; |
|
|
border: 1px solid #ddd; |
|
|
border-radius: 5px; |
|
|
font-size: 14px;"> |
|
|
<option value="all">All Types</option> |
|
|
|
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div style="flex: 1; min-width: 200px;"> |
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Cities/Regencies:</label> |
|
|
<select id="cityFilter" style="width: 100%; |
|
|
padding: 10px 15px; |
|
|
border: 1px solid #ddd; |
|
|
border-radius: 5px; |
|
|
font-size: 14px;"> |
|
|
<option value="all">All Regencies</option> |
|
|
|
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div style="background: white; |
|
|
border-radius: 10px; |
|
|
overflow: hidden; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> |
|
|
<div style="background: linear-gradient(135deg, #2C5F8D 0%, #1e3c72 100%); |
|
|
padding: 15px; |
|
|
color: white;"> |
|
|
<h3 style="margin: 0; font-size: 18px;"> |
|
|
East Java Legal Cases Interactive Map |
|
|
</h3> |
|
|
<p style="margin: 5px 0 0 0; font-size: 13px; opacity: 0.9;"> |
|
|
Hover for more information • Click regency for detailed cases |
|
|
</p> |
|
|
</div> |
|
|
<iframe id="mapIframe" src="{{ url_for('map_view') }}" |
|
|
style="width: 100%; |
|
|
height: 650px; |
|
|
border: none;"> |
|
|
</iframe> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="charts-row" style=" |
|
|
margin-top: 60px; |
|
|
display: flex; |
|
|
gap: 20px; |
|
|
flex-wrap: wrap; |
|
|
"> |
|
|
|
|
|
<div style=" |
|
|
flex: 1; |
|
|
min-width: 400px; |
|
|
max-width: 50%; |
|
|
background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
"> |
|
|
<h3 id="yearHeading" style="margin:0 0 15px 0; font-size:18px;"> |
|
|
Case Trends Per Year |
|
|
</h3> |
|
|
<canvas id="casesYearChart" height="120"></canvas> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div style=" |
|
|
flex: 1; |
|
|
min-width: 400px; |
|
|
max-width: 50%; |
|
|
background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
"> |
|
|
<h3 id="seasonalityHeading" style="margin:0 0 15px 0; font-size:18px;"> |
|
|
Case Pattern Per Month |
|
|
</h3> |
|
|
<canvas id="seasonalityChart"></canvas> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div style=" |
|
|
width: 100%; |
|
|
margin-top: 30px; |
|
|
background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
"> |
|
|
<h3 id="forecast" style="margin:0 0 15px 0; font-size:18px;"> |
|
|
Case Forecast for Next Year |
|
|
</h3> |
|
|
<canvas id="forecastChart" height="120"></canvas> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div style=" |
|
|
width: 100%; |
|
|
margin-top: 30px; |
|
|
background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
"> |
|
|
<h3 style="margin:0 0 15px 0; font-size:18px;"> |
|
|
10 Highest Types of Crimes |
|
|
</h3> |
|
|
<canvas id="crimeTypeChart"></canvas> |
|
|
</div> |
|
|
|
|
|
<div style=" |
|
|
width: 100%; |
|
|
margin-top: 20px; |
|
|
background: white; |
|
|
padding: 20px; |
|
|
border-radius: 10px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
"> |
|
|
<h3 id="stackedHeading" style="margin-bottom: 15px; font-size: 18px;"> |
|
|
Sentencing Patterns for the 10 Highest Crimes |
|
|
</h3> |
|
|
|
|
|
<canvas id="stackedPNChart"></canvas> |
|
|
</div> |
|
|
|
|
|
<table id="kasusTable" class="table table-striped"> |
|
|
<thead> |
|
|
<tr id="kasusHead"></tr> |
|
|
</thead> |
|
|
<tbody id="kasusBody"></tbody> |
|
|
</table> |
|
|
|
|
|
<div class="pagination-container" style=" |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
align-items: center; |
|
|
justify-content: flex-start; |
|
|
margin-top: 15px; |
|
|
flex-wrap: wrap; |
|
|
"> |
|
|
<button id="prevKasus" class="btn-pagination">Prev</button> |
|
|
<span id="kasusPageInfo" style="font-weight: 500; min-width: 80px; text-align: center;"></span> |
|
|
<button id="nextKasus" class="btn-pagination">Next</button> |
|
|
</div> |
|
|
|
|
|
<style> |
|
|
.btn-pagination { |
|
|
background-color: #2E86AB; |
|
|
color: white; |
|
|
border: none; |
|
|
padding: 6px 14px; |
|
|
border-radius: 6px; |
|
|
font-size: 0.9rem; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s ease; |
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.15); |
|
|
} |
|
|
|
|
|
.btn-pagination:hover { |
|
|
background-color: #1B4F72; |
|
|
transform: translateY(-1px); |
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.2); |
|
|
} |
|
|
|
|
|
.btn-pagination:disabled { |
|
|
background-color: #cccccc; |
|
|
cursor: not-allowed; |
|
|
box-shadow: none; |
|
|
} |
|
|
</style> |
|
|
|
|
|
|
|
|
|
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
</main> |
|
|
|
|
|
|
|
|
<footer class="footer"> |
|
|
<div class="container"> |
|
|
</div> |
|
|
</footer> |
|
|
|
|
|
|
|
|
<script> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let currentFilters = { |
|
|
year: 'all', |
|
|
crime: 'all', |
|
|
city: 'all' |
|
|
}; |
|
|
|
|
|
let allCharts = {}; |
|
|
let filtersInitialized = false; |
|
|
|
|
|
function animateNumber(element, target) { |
|
|
let current = 0; |
|
|
const increment = target / 50; |
|
|
const timer = setInterval(() => { |
|
|
current += increment; |
|
|
if (current >= target) { |
|
|
element.textContent = target.toLocaleString(); |
|
|
clearInterval(timer); |
|
|
} else { |
|
|
element.textContent = Math.floor(current).toLocaleString(); |
|
|
} |
|
|
}, 20); |
|
|
} |
|
|
|
|
|
function applyFilters() { |
|
|
const year = document.getElementById('yearFilter').value; |
|
|
const crime = document.getElementById('crimeFilter').value; |
|
|
const city = document.getElementById('cityFilter').value; |
|
|
|
|
|
currentFilters.year = year; |
|
|
currentFilters.crime = crime; |
|
|
currentFilters.city = city; |
|
|
|
|
|
|
|
|
const params = new URLSearchParams(); |
|
|
if (year !== 'all') params.append('year', year); |
|
|
if (crime !== 'all') params.append('crime', crime); |
|
|
if (city !== 'all') params.append('city', city); |
|
|
|
|
|
const queryString = params.toString(); |
|
|
const statsUrl = '/api/statistics' + (queryString ? '?' + queryString : ''); |
|
|
const mapUrl = '/map' + (queryString ? '?' + queryString : ''); |
|
|
|
|
|
|
|
|
console.log('Applying filters - Year:', year, 'Crime:', crime, 'City:', city); |
|
|
console.log('Stats URL:', statsUrl); |
|
|
console.log('Map URL:', mapUrl); |
|
|
|
|
|
|
|
|
const mapIframe = document.getElementById('mapIframe'); |
|
|
if (mapIframe) { |
|
|
mapIframe.src = mapUrl; |
|
|
console.log('Map iframe reloaded'); |
|
|
} |
|
|
|
|
|
|
|
|
loadStatistics(statsUrl); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function loadStatistics(url) { |
|
|
fetch(url) |
|
|
.then(response => response.json()) |
|
|
.then(apiData => { |
|
|
console.log('Data dari API:', apiData); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (apiData) { |
|
|
document.getElementById("totalPutusan").innerText = apiData.total_cases || 0; |
|
|
document.getElementById("totalPN").innerText = apiData.total_pn || 0; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (apiData.year_options) { |
|
|
const yearFilter = document.getElementById('yearFilter'); |
|
|
const prev = currentFilters.year; |
|
|
yearFilter.innerHTML = '<option value="all">All Years</option>'; |
|
|
apiData.year_options.forEach(year => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = year.value; |
|
|
option.textContent = `${year.label} (${year.count.toLocaleString()})`; |
|
|
yearFilter.appendChild(option); |
|
|
}); |
|
|
yearFilter.value = prev; |
|
|
} |
|
|
|
|
|
|
|
|
if (apiData.crime_types) { |
|
|
const crimeFilter = document.getElementById('crimeFilter'); |
|
|
const prev = currentFilters.crime; |
|
|
crimeFilter.innerHTML = '<option value="all">All Types</option>'; |
|
|
apiData.crime_types.slice(0, 20).forEach(crime => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = crime.value; |
|
|
option.textContent = `${crime.label} (${crime.count.toLocaleString()})`; |
|
|
crimeFilter.appendChild(option); |
|
|
}); |
|
|
crimeFilter.value = prev; |
|
|
} |
|
|
|
|
|
|
|
|
if (apiData.city_options && !document.getElementById('cityFilter').dataset.populated) { |
|
|
const cityFilter = document.getElementById('cityFilter'); |
|
|
apiData.city_options.forEach(city => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = city.value; |
|
|
option.textContent = city.label; |
|
|
cityFilter.appendChild(option); |
|
|
}); |
|
|
cityFilter.dataset.populated = 'true'; |
|
|
} |
|
|
|
|
|
|
|
|
if (!filtersInitialized) { |
|
|
document.getElementById('yearFilter').addEventListener('change', applyFilters); |
|
|
document.getElementById('crimeFilter').addEventListener('change', applyFilters); |
|
|
document.getElementById('cityFilter').addEventListener('change', applyFilters); |
|
|
|
|
|
filtersInitialized = true; |
|
|
console.log('Filter event listeners initialized'); |
|
|
} |
|
|
|
|
|
|
|
|
Object.values(allCharts).forEach(ch => ch.destroy()); |
|
|
|
|
|
|
|
|
if (apiData.year_options) { |
|
|
|
|
|
const ctx = document.getElementById('casesYearChart').getContext('2d'); |
|
|
|
|
|
|
|
|
const sortedYears = apiData.year_options.slice().reverse(); |
|
|
|
|
|
const years = sortedYears.map(x => Number(x.value)); |
|
|
const counts = sortedYears.map(x => Number(x.count)); |
|
|
|
|
|
const avgCases = counts.reduce((a, b) => a + b, 0) / counts.length; |
|
|
|
|
|
|
|
|
|
|
|
if (allCharts.casesYearChart) allCharts.casesYearChart.destroy(); |
|
|
|
|
|
|
|
|
const annotations = []; |
|
|
apiData.year_options.forEach((item, i) => { |
|
|
if (i === 0) return; |
|
|
const prev = apiData.year_options[i - 1].count; |
|
|
const change = ((item.count - prev) / prev) * 100; |
|
|
|
|
|
if (Math.abs(change) > 15) { |
|
|
annotations.push({ |
|
|
type: 'label', |
|
|
xValue: Number(item.value), |
|
|
yValue: Number(item.count), |
|
|
backgroundColor: 'white', |
|
|
borderColor: '#444', |
|
|
borderWidth: 1, |
|
|
padding: 6, |
|
|
content: `${change > 0 ? '+' : ''}${change.toFixed(0)}%`, |
|
|
color: change > 0 ? 'green' : 'red', |
|
|
font: { weight: 'bold' }, |
|
|
yAdjust: -20 |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
allCharts.casesYearChart = new Chart(ctx, { |
|
|
type: 'line', |
|
|
data: { |
|
|
labels: years, |
|
|
datasets: [{ |
|
|
label: `Yearly Case Trends (Average ${avgCases.toFixed(0)} Case)`, |
|
|
data: counts, |
|
|
borderWidth: 3, |
|
|
tension: 0.25, |
|
|
borderColor: '#2E86AB', |
|
|
pointBackgroundColor: 'white', |
|
|
pointBorderColor: '#2E86AB', |
|
|
pointBorderWidth: 2, |
|
|
pointRadius: 6, |
|
|
pointHoverRadius: 7 |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
plugins: { |
|
|
legend: { display: false }, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: function (ctx) { |
|
|
const index = ctx.dataIndex; |
|
|
const current = ctx.raw; |
|
|
|
|
|
if (index === 0) { |
|
|
return `${current.toLocaleString()} case (first year)`; |
|
|
} |
|
|
|
|
|
const prev = ctx.chart.data.datasets[0].data[index - 1]; |
|
|
const change = ((current - prev) / prev) * 100; |
|
|
const sign = change >= 0 ? '+' : ''; |
|
|
|
|
|
return `${current.toLocaleString()} case (${sign}${change.toFixed(1)}%)`; |
|
|
} |
|
|
} |
|
|
}, |
|
|
annotation: { |
|
|
annotations: annotations |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
x: { |
|
|
ticks: { autoSkip: false } |
|
|
}, |
|
|
y: { |
|
|
beginAtZero: false, |
|
|
grid: { color: 'rgba(0,0,0,0.1)' } |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('yearHeading').textContent = |
|
|
allCharts.casesYearChart.data.datasets[0].label; |
|
|
} |
|
|
|
|
|
if (apiData.seasonal_data) { |
|
|
|
|
|
const ctx = document.getElementById('seasonalityChart').getContext('2d'); |
|
|
|
|
|
|
|
|
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; |
|
|
|
|
|
|
|
|
const fullData = Array.from({ length: 12 }, (_, i) => { |
|
|
const entry = apiData.seasonal_data.find(x => Number(x.month) === i + 1); |
|
|
return entry ? Number(entry.count) : 0; |
|
|
}); |
|
|
|
|
|
const months = Array.from({ length: 12 }, (_, i) => i + 1); |
|
|
|
|
|
|
|
|
const filtered = fullData.filter(x => x > 0); |
|
|
const maxVal = Math.max(...filtered); |
|
|
const minVal = Math.min(...filtered); |
|
|
|
|
|
const maxMonth = fullData.indexOf(maxVal) + 1; |
|
|
const minMonth = fullData.indexOf(minVal) + 1; |
|
|
|
|
|
|
|
|
const percentChanges = fullData.map((count, i) => { |
|
|
if (i === 0) return null; |
|
|
const prev = fullData[i - 1]; |
|
|
return prev > 0 ? ((count - prev) / prev) * 100 : null; |
|
|
}); |
|
|
|
|
|
|
|
|
const avgCases = fullData.reduce((a, b) => a + b, 0) / fullData.length; |
|
|
|
|
|
|
|
|
if (allCharts.seasonalityChart) allCharts.seasonalityChart.destroy(); |
|
|
|
|
|
allCharts.seasonalityChart = new Chart(ctx, { |
|
|
type: 'bar', |
|
|
data: { |
|
|
labels: monthNames, |
|
|
datasets: [{ |
|
|
label: `Monthly Cases (Average ${avgCases.toFixed(0)} Case)`, |
|
|
data: fullData, |
|
|
borderWidth: 2, |
|
|
backgroundColor: fullData.map((v, i) => |
|
|
(i + 1 === maxMonth) ? '#E57373' : |
|
|
(i + 1 === minMonth) ? '#81C784' : |
|
|
'#B0C4DE' |
|
|
), |
|
|
borderColor: fullData.map((v, i) => |
|
|
(i + 1 === maxMonth) ? '#B71C1C' : |
|
|
(i + 1 === minMonth) ? '#1B5E20' : |
|
|
'#1E3A5F' |
|
|
) |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
plugins: { |
|
|
legend: { display: false }, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: ctx => { |
|
|
const monthIdx = ctx.dataIndex; |
|
|
const raw = ctx.raw.toLocaleString(); |
|
|
|
|
|
const pct = percentChanges[monthIdx]; |
|
|
if (pct === null) return ` ${raw} kasus`; |
|
|
|
|
|
const sign = pct > 0 ? '+' : ''; |
|
|
return ` ${raw} case (${sign}${pct.toFixed(1)}%)`; |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
y: { |
|
|
beginAtZero: true, |
|
|
grid: { color: 'rgba(0,0,0,0.1)' } |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('seasonalityHeading').textContent = |
|
|
allCharts.seasonalityChart.data.datasets[0].label; |
|
|
} |
|
|
|
|
|
|
|
|
if (apiData.forecast_result) { |
|
|
|
|
|
const history = apiData.forecast_result.history || []; |
|
|
const future = apiData.forecast_result.forecast || []; |
|
|
|
|
|
|
|
|
if (history.length === 0 && future.length === 0) { |
|
|
console.warn("Forecast data empty"); |
|
|
return; |
|
|
} |
|
|
|
|
|
const allLabels = [ |
|
|
...history.map(r => r.date), |
|
|
...future.map(r => r.date) |
|
|
]; |
|
|
|
|
|
const historyValues = history.map(r => r.count); |
|
|
|
|
|
|
|
|
const forecastValues = [ |
|
|
...Array(history.length).fill(null), |
|
|
...future.map(r => r.forecast) |
|
|
]; |
|
|
|
|
|
const ctxForecast = document.getElementById('forecastChart').getContext('2d'); |
|
|
|
|
|
if (allCharts.forecastChart) allCharts.forecastChart.destroy(); |
|
|
|
|
|
allCharts.forecastChart = new Chart(ctxForecast, { |
|
|
type: 'line', |
|
|
data: { |
|
|
labels: allLabels, |
|
|
datasets: [ |
|
|
{ |
|
|
label: "Historical Data", |
|
|
data: historyValues, |
|
|
borderWidth: 2, |
|
|
tension: 0.3 |
|
|
}, |
|
|
{ |
|
|
label: "Forecast", |
|
|
data: forecastValues, |
|
|
borderWidth: 2, |
|
|
borderDash: [6, 4], |
|
|
tension: 0.3 |
|
|
} |
|
|
] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
plugins: { |
|
|
legend: { |
|
|
position: 'top' |
|
|
}, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: ctx => { |
|
|
const val = ctx.raw; |
|
|
if (val === null) return null; |
|
|
return `${ctx.dataset.label}: ${val.toLocaleString()}`; |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
x: { |
|
|
ticks: { |
|
|
autoSkip: true, |
|
|
maxTicksLimit: 12 |
|
|
} |
|
|
}, |
|
|
y: { |
|
|
beginAtZero: true |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('forecastChart').textContent = |
|
|
allCharts.forecastChart.data.datasets[0].label; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (apiData.crime_types) { |
|
|
|
|
|
|
|
|
const sortedCrimeTypes = apiData.crime_types |
|
|
.sort((a, b) => b.count - a.count) |
|
|
.slice(0, 10); |
|
|
|
|
|
const labels = sortedCrimeTypes.map(x => x.label); |
|
|
const values = sortedCrimeTypes.map(x => x.count); |
|
|
|
|
|
const ctx3 = document.getElementById('crimeTypeChart').getContext('2d'); |
|
|
|
|
|
if (allCharts.crimeTypeChart) allCharts.crimeTypeChart.destroy(); |
|
|
|
|
|
allCharts.crimeTypeChart = new Chart(ctx3, { |
|
|
type: 'bar', |
|
|
data: { |
|
|
labels: labels, |
|
|
datasets: [{ |
|
|
label: 'Total Cases', |
|
|
data: values, |
|
|
borderWidth: 1 |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
indexAxis: 'y', |
|
|
responsive: true, |
|
|
plugins: { |
|
|
legend: { display: false }, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: ctx => ctx.raw.toLocaleString() |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
x: { |
|
|
beginAtZero: true, |
|
|
ticks: { |
|
|
callback: value => value.toLocaleString() |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('crimeTypeChart').textContent = |
|
|
allCharts.crimeTypeChart.data.datasets[0].label; |
|
|
} |
|
|
|
|
|
if (apiData.kasus_percentage) { |
|
|
|
|
|
const ctx = document.getElementById('stackedPNChart').getContext('2d'); |
|
|
|
|
|
|
|
|
const top10 = apiData.kasus_percentage |
|
|
.slice() |
|
|
.sort((a, b) => Number(b.kontribusi_kasus) - Number(a.kontribusi_kasus)) |
|
|
.slice(0, 10); |
|
|
|
|
|
const crimeLabels = top10.map(r => r.tindak_pidana); |
|
|
|
|
|
|
|
|
const percentageKeys = [ |
|
|
"penjara", |
|
|
"penjara_seumur_hidup", |
|
|
"denda", |
|
|
"bebas_bersyarat", |
|
|
"bebas_dakwaan", |
|
|
"hukuman_mati", |
|
|
].filter(k => top10[0].hasOwnProperty(k)); |
|
|
|
|
|
const colors = [ |
|
|
'#E57373', |
|
|
'#81C784', |
|
|
'#64B5F6', |
|
|
'#FFB74D', |
|
|
'#BA68C8', |
|
|
'#4DB6AC', |
|
|
'#B0C4DE', |
|
|
]; |
|
|
|
|
|
const kasusColumnLabels = { |
|
|
"bebas_bersyarat": "Conditional Release", |
|
|
"bebas_dakwaan": "Acquittal", |
|
|
"denda": "Fine", |
|
|
"hukuman_mati": "Death Penalty", |
|
|
"penjara": "Imprisonment", |
|
|
"penjara_seumur_hidup": "Life Imprisonment", |
|
|
}; |
|
|
|
|
|
const datasets = percentageKeys.map((key, idx) => ({ |
|
|
label: kasusColumnLabels[key] || key.replace(/_/g, ' ').toUpperCase(), |
|
|
data: top10.map(r => Number(r[key]) || 0), |
|
|
backgroundColor: colors[idx], |
|
|
borderWidth: 1 |
|
|
})); |
|
|
|
|
|
|
|
|
if (allCharts.stackedPNChart) allCharts.stackedPNChart.destroy(); |
|
|
|
|
|
allCharts.stackedPNChart = new Chart(ctx, { |
|
|
type: 'bar', |
|
|
data: { |
|
|
labels: crimeLabels, |
|
|
datasets: datasets |
|
|
}, |
|
|
options: { |
|
|
indexAxis: 'y', |
|
|
responsive: true, |
|
|
scales: { |
|
|
x: { |
|
|
stacked: true, |
|
|
max: 100, |
|
|
ticks: { |
|
|
callback: v => v + "%" |
|
|
} |
|
|
}, |
|
|
y: { |
|
|
stacked: true |
|
|
} |
|
|
}, |
|
|
plugins: { |
|
|
legend: { position: 'right' }, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: ctx => { |
|
|
const raw = ctx.raw ?? 0; |
|
|
if (raw <= 0) return null; |
|
|
return `${ctx.dataset.label}: ${raw.toFixed(3)}%`; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
kasusDataFull = apiData.kasus_percentage.slice(); |
|
|
kasusPage = 0; |
|
|
renderKasusTablePage(); |
|
|
|
|
|
|
|
|
const observer = new IntersectionObserver((entries) => { |
|
|
entries.forEach(entry => { |
|
|
if (entry.isIntersecting) { |
|
|
entry.target.style.opacity = '1'; |
|
|
entry.target.style.transform = 'translateY(0)'; |
|
|
} |
|
|
}); |
|
|
}, { threshold: 0.1 }); |
|
|
|
|
|
document.querySelectorAll('canvas').forEach(canvas => { |
|
|
canvas.parentElement.style.opacity = '0'; |
|
|
canvas.parentElement.style.transform = 'translateY(30px)'; |
|
|
canvas.parentElement.style.transition = 'all 0.6s ease-out'; |
|
|
observer.observe(canvas.parentElement); |
|
|
}); |
|
|
}) |
|
|
.catch(error => { |
|
|
console.error('Error loading statistics:', error); |
|
|
alert('Failed to load statistic data. Please refresh the page.'); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function renderKasusTablePage() { |
|
|
const head = document.getElementById("kasusHead"); |
|
|
const body = document.getElementById("kasusBody"); |
|
|
const pageInfo = document.getElementById("kasusPageInfo"); |
|
|
|
|
|
body.innerHTML = ""; |
|
|
|
|
|
const sorted = kasusDataFull |
|
|
.slice() |
|
|
.sort((a, b) => Number(b.kontribusi_kasus) - Number(a.kontribusi_kasus)); |
|
|
|
|
|
const start = kasusPage * kasusPerPage; |
|
|
const end = start + kasusPerPage; |
|
|
|
|
|
const pageData = sorted.slice(start, end); |
|
|
|
|
|
if (pageData.length === 0) return; |
|
|
|
|
|
|
|
|
const kasusColumnLabels = { |
|
|
"bebas_bersyarat": "Conditional Release (%)", |
|
|
"bebas_dakwaan": "Acquittal (%)", |
|
|
"denda": "Fine (%)", |
|
|
"hukuman_mati": "Death Penalty (%)", |
|
|
"kategori_pidana": "Category", |
|
|
"kontribusi_kasus": "Case Contribution (%)", |
|
|
"penjara": "Imprisonment (%)", |
|
|
"penjara_seumur_hidup": "Life Imprisonment (%)", |
|
|
"rata-rata_denda": "Average Fine (Rupiah)", |
|
|
"rata-rata_penjara": "Average Imprisonment (Months)", |
|
|
"penjara_seumur_hidup": "Life Imprisonment (%)", |
|
|
"tindak_pidana": "Crime Action" |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
head.innerHTML = ""; |
|
|
Object.keys(pageData[0]).forEach(col => { |
|
|
const label = kasusColumnLabels[col] || col; |
|
|
head.innerHTML += `<th>${label}</th>`; |
|
|
}); |
|
|
|
|
|
|
|
|
pageData.forEach(row => { |
|
|
let rowHTML = "<tr>"; |
|
|
Object.keys(row).forEach(col => { |
|
|
let val = row[col]; |
|
|
|
|
|
if (typeof val === "number" && !Number.isInteger(val)) { |
|
|
val = val.toFixed(3); |
|
|
} |
|
|
|
|
|
if (val === null || val === undefined || val === "") { |
|
|
val = "-"; |
|
|
} |
|
|
|
|
|
rowHTML += `<td>${val}</td>`; |
|
|
}); |
|
|
rowHTML += "</tr>"; |
|
|
body.innerHTML += rowHTML; |
|
|
}); |
|
|
|
|
|
const totalPages = Math.ceil(kasusDataFull.length / kasusPerPage); |
|
|
pageInfo.innerText = `Page ${kasusPage + 1} / ${totalPages}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("prevKasus").onclick = function () { |
|
|
if (kasusPage > 0) { |
|
|
kasusPage--; |
|
|
renderKasusTablePage(); |
|
|
} |
|
|
}; |
|
|
document.getElementById("nextKasus").onclick = function () { |
|
|
const totalPages = Math.ceil(kasusDataFull.length / kasusPerPage); |
|
|
if (kasusPage < totalPages - 1) { |
|
|
kasusPage++; |
|
|
renderKasusTablePage(); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let kasusPage = 0; |
|
|
const kasusPerPage = 10; |
|
|
let kasusDataFull = []; |
|
|
|
|
|
|
|
|
loadStatistics('/api/statistics'); |
|
|
</script> |
|
|
</body> |
|
|
</html> |