Feat: Tambah animasi counter di KPI cards dan animasi naik turun di chart trending harian

- Counter animation dari angka terkecil ke target dengan easing smooth
- Chart animation naik dari bawah tanpa animasi horizontal
- Loading overlay dengan spinner animation
- Fade animation untuk card values saat update
This commit is contained in:
BTekno Dev
2026-01-02 00:12:03 +07:00
parent e7e2042e86
commit 768a1e146c
3 changed files with 121 additions and 11 deletions

View File

@@ -49,6 +49,30 @@ export function initDailyChart(ctx) {
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 1500,
easing: 'linear',
x: {
duration: 0 // Tidak ada animasi horizontal (kanan ke kiri)
},
y: {
type: 'number',
easing: 'linear',
duration: 1500,
from: (ctx) => {
// Animasi naik dari bawah (0) untuk efek naik turun yang smooth
try {
const chart = ctx.chart;
if (chart && chart.scales && chart.scales.y) {
return chart.scales.y.getPixelForValue(0);
}
} catch (e) {
console.warn('[Charts] Error getting scale for animation:', e);
}
return 0;
}
}
},
interaction: {
mode: 'index',
intersect: false
@@ -132,8 +156,8 @@ export function updateDailyChart({ labels, counts, amounts }) {
dailyLineChart.data.datasets[0].data = finalCounts;
dailyLineChart.data.datasets[1].data = finalAmounts;
// Update chart dengan mode 'none' untuk menghindari animasi yang tidak perlu
dailyLineChart.update('none');
// Update chart dengan animasi naik turun yang smooth
dailyLineChart.update('active');
console.log('[Charts] Daily chart updated:', {
labelsCount: finalLabels.length,
@@ -222,7 +246,7 @@ export function updateCategoryChart({ labels, values }) {
categoryChart.data.datasets[0].data = safeValues;
// Update chart dengan mode 'none' untuk menghindari animasi yang tidak perlu
categoryChart.update('none');
categoryChart.update('active');
console.log('[Charts] Category chart updated:', {
labels: safeLabels,

View File

@@ -294,16 +294,66 @@ async function loadGates() {
}
}
// Counter animation function
function animateCounter(element, targetValue, formatter, duration = 1500) {
if (!element) return;
// Get current value from element text
const currentText = element.textContent || '0';
let currentValue = 0;
// Parse current value based on formatter type
if (formatter === formatCurrency) {
// Remove "Rp ", dots, and spaces, then parse
const cleaned = currentText.replace(/Rp\s?/g, '').replace(/\./g, '').replace(/\s/g, '');
currentValue = parseInt(cleaned) || 0;
} else {
// Remove dots and spaces for number format
const cleaned = currentText.replace(/\./g, '').replace(/\s/g, '');
currentValue = parseInt(cleaned) || 0;
}
const target = targetValue || 0;
const startValue = currentValue;
const difference = target - startValue;
const startTime = performance.now();
// Add updating class for fade effect
element.classList.add('updating');
function updateCounter(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (easeOutCubic) for smooth animation
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
const current = Math.floor(startValue + (difference * easeOutCubic));
element.textContent = formatter(current);
if (progress < 1) {
requestAnimationFrame(updateCounter);
} else {
// Ensure final value is exact
element.textContent = formatter(target);
element.classList.remove('updating');
}
}
requestAnimationFrame(updateCounter);
}
function renderSummary({ totalAmount, personCount, motorCount, carCount }) {
const amountEl = document.getElementById('card-total-amount');
const personEl = document.getElementById('card-person-count');
const motorEl = document.getElementById('card-motor-count');
const carEl = document.getElementById('card-car-count');
if (amountEl) amountEl.textContent = formatCurrency(totalAmount || 0);
if (personEl) personEl.textContent = formatNumber(personCount || 0);
if (motorEl) motorEl.textContent = formatNumber(motorCount || 0);
if (carEl) carEl.textContent = formatNumber(carCount || 0);
// Use counter animation for all values
animateCounter(amountEl, totalAmount || 0, formatCurrency, 1500);
animateCounter(personEl, personCount || 0, formatNumber, 1200);
animateCounter(motorEl, motorCount || 0, formatNumber, 1200);
animateCounter(carEl, carCount || 0, formatNumber, 1200);
}
function showError(message) {
@@ -1104,14 +1154,14 @@ function setupFilters() {
dailyChartInstance.data.labels = [];
dailyChartInstance.data.datasets[0].data = [];
dailyChartInstance.data.datasets[1].data = [];
dailyChartInstance.update('none');
dailyChartInstance.update('active');
}
const categoryChartInstance = getCategoryChart();
if (categoryChartInstance) {
categoryChartInstance.data.labels = [];
categoryChartInstance.data.datasets[0].data = [];
categoryChartInstance.update('none');
categoryChartInstance.update('active');
}
}