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:
@@ -338,11 +338,47 @@ a {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
display: none;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-loading-overlay.visible {
|
.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 */
|
/* Charts & tables layout */
|
||||||
|
|||||||
@@ -49,6 +49,30 @@ export function initDailyChart(ctx) {
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
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: {
|
interaction: {
|
||||||
mode: 'index',
|
mode: 'index',
|
||||||
intersect: false
|
intersect: false
|
||||||
@@ -132,8 +156,8 @@ export function updateDailyChart({ labels, counts, amounts }) {
|
|||||||
dailyLineChart.data.datasets[0].data = finalCounts;
|
dailyLineChart.data.datasets[0].data = finalCounts;
|
||||||
dailyLineChart.data.datasets[1].data = finalAmounts;
|
dailyLineChart.data.datasets[1].data = finalAmounts;
|
||||||
|
|
||||||
// Update chart dengan mode 'none' untuk menghindari animasi yang tidak perlu
|
// Update chart dengan animasi naik turun yang smooth
|
||||||
dailyLineChart.update('none');
|
dailyLineChart.update('active');
|
||||||
|
|
||||||
console.log('[Charts] Daily chart updated:', {
|
console.log('[Charts] Daily chart updated:', {
|
||||||
labelsCount: finalLabels.length,
|
labelsCount: finalLabels.length,
|
||||||
@@ -222,7 +246,7 @@ export function updateCategoryChart({ labels, values }) {
|
|||||||
categoryChart.data.datasets[0].data = safeValues;
|
categoryChart.data.datasets[0].data = safeValues;
|
||||||
|
|
||||||
// Update chart dengan mode 'none' untuk menghindari animasi yang tidak perlu
|
// Update chart dengan mode 'none' untuk menghindari animasi yang tidak perlu
|
||||||
categoryChart.update('none');
|
categoryChart.update('active');
|
||||||
|
|
||||||
console.log('[Charts] Category chart updated:', {
|
console.log('[Charts] Category chart updated:', {
|
||||||
labels: safeLabels,
|
labels: safeLabels,
|
||||||
|
|||||||
@@ -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 }) {
|
function renderSummary({ totalAmount, personCount, motorCount, carCount }) {
|
||||||
const amountEl = document.getElementById('card-total-amount');
|
const amountEl = document.getElementById('card-total-amount');
|
||||||
const personEl = document.getElementById('card-person-count');
|
const personEl = document.getElementById('card-person-count');
|
||||||
const motorEl = document.getElementById('card-motor-count');
|
const motorEl = document.getElementById('card-motor-count');
|
||||||
const carEl = document.getElementById('card-car-count');
|
const carEl = document.getElementById('card-car-count');
|
||||||
|
|
||||||
if (amountEl) amountEl.textContent = formatCurrency(totalAmount || 0);
|
// Use counter animation for all values
|
||||||
if (personEl) personEl.textContent = formatNumber(personCount || 0);
|
animateCounter(amountEl, totalAmount || 0, formatCurrency, 1500);
|
||||||
if (motorEl) motorEl.textContent = formatNumber(motorCount || 0);
|
animateCounter(personEl, personCount || 0, formatNumber, 1200);
|
||||||
if (carEl) carEl.textContent = formatNumber(carCount || 0);
|
animateCounter(motorEl, motorCount || 0, formatNumber, 1200);
|
||||||
|
animateCounter(carEl, carCount || 0, formatNumber, 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
@@ -1104,14 +1154,14 @@ function setupFilters() {
|
|||||||
dailyChartInstance.data.labels = [];
|
dailyChartInstance.data.labels = [];
|
||||||
dailyChartInstance.data.datasets[0].data = [];
|
dailyChartInstance.data.datasets[0].data = [];
|
||||||
dailyChartInstance.data.datasets[1].data = [];
|
dailyChartInstance.data.datasets[1].data = [];
|
||||||
dailyChartInstance.update('none');
|
dailyChartInstance.update('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryChartInstance = getCategoryChart();
|
const categoryChartInstance = getCategoryChart();
|
||||||
if (categoryChartInstance) {
|
if (categoryChartInstance) {
|
||||||
categoryChartInstance.data.labels = [];
|
categoryChartInstance.data.labels = [];
|
||||||
categoryChartInstance.data.datasets[0].data = [];
|
categoryChartInstance.data.datasets[0].data = [];
|
||||||
categoryChartInstance.update('none');
|
categoryChartInstance.update('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user