From 768a1e146cf8bc3d72a4e29a2acd3d3a652cd03a Mon Sep 17 00:00:00 2001 From: BTekno Dev Date: Fri, 2 Jan 2026 00:12:03 +0700 Subject: [PATCH] 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 --- public/dashboard/css/app.css | 40 +++++++++++++++++++-- public/dashboard/js/charts.js | 30 ++++++++++++++-- public/dashboard/js/dashboard.js | 62 ++++++++++++++++++++++++++++---- 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/public/dashboard/css/app.css b/public/dashboard/css/app.css index e55a6a7..883b072 100644 --- a/public/dashboard/css/app.css +++ b/public/dashboard/css/app.css @@ -338,11 +338,47 @@ a { justify-content: center; font-size: 0.8rem; color: #6b7280; - display: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + z-index: 10; } .card-loading-overlay.visible { - display: flex; + opacity: 1; + visibility: visible; +} + +/* Spinner animation - add spinner element before text */ +.card-loading-overlay { + gap: 0.5rem; +} + +.card-loading-overlay::before { + content: ''; + width: 20px; + height: 20px; + border: 3px solid #e5e7eb; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Fade animation for card values */ +.card-value { + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.card-value.updating { + opacity: 0.5; + transform: scale(0.95); } /* Charts & tables layout */ diff --git a/public/dashboard/js/charts.js b/public/dashboard/js/charts.js index f64871c..ae6dbb9 100644 --- a/public/dashboard/js/charts.js +++ b/public/dashboard/js/charts.js @@ -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, diff --git a/public/dashboard/js/dashboard.js b/public/dashboard/js/dashboard.js index 00dddce..9737354 100644 --- a/public/dashboard/js/dashboard.js +++ b/public/dashboard/js/dashboard.js @@ -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'); } }