// ───────────────────────────────────────────────────────────
// Huio Control Panel — Charts & Visual Generators (cp-charts.jsx)
// ───────────────────────────────────────────────────────────
if (!window.CP_MOCK_DATA) throw new Error('[cp-charts] window.CP_MOCK_DATA not loaded (check cp-mock-data.jsx)');
const { generate30DaysData, generate12MonthsData } = window.CP_MOCK_DATA;
if (!generate30DaysData || !generate12MonthsData) {
throw new Error('[cp-charts] missing mock data generators on window.CP_MOCK_DATA');
}
// ───── HIGH QUALITY SVG MOCK CCCD GENERATOR ─────
function renderCccdSvg(side, cccdNum, name, dob, status) {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
// Values mirror huio.css DS tokens: --hu-bg-card / --hu-fg / --hu-fg-2.
// Kept as literals because SVG fill attribute doesn't read CSS vars reliably.
const cardBg = isDark ? '#1E1E26' : '#FFFFFF';
const textPrimary = isDark ? '#F4F1EA' : '#1C1A15';
const textSecondary = isDark ? '#B5B1BD' : '#6E6757';
if (side === 'front') {
return (
{/* Card background styling */}
{/* Guilloche border effects */}
{/* Top Header */}
CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM
Độc lập - Tự do - Hạnh phúc
{/* Emblem placeholder */}
{/* Title */}
CĂN CƯỚC CÔNG DÂN
CHIP-BASED CITIZEN IDENTITY CARD
{/* Profile Silhouette */}
{/* Head */}
{/* Body */}
{/* Hologram Circle */}
{/* Info Fields */}
Số / No.:
{cccdNum}
Họ và tên / Full name:
{name}
Ngày sinh / Date of birth:
{dob}
Giới tính / Sex: Nam | Quốc tịch / Nationality: Việt Nam
);
} else {
return (
{/* Backside layout details */}
ĐẶC ĐIỂM NHẬN DẠNG / PERSONAL IDENTIFICATION ITEMS
Nốt ruồi cách 1cm dưới đuôi mắt trái.
{/* QR Code and Chip Layout */}
{/* Mock QR matrix lines */}
{/* Thumbprint boxes */}
VÂN TAY TRÁI
{/* Thumbprint abstract spirals */}
VÂN TAY PHẢI
{/* Code stripes */}
I<VNM1234567897<<<<<<<<<<<<<<<9403125M2504128VNM<<<<<<<<<<<8
);
}
}
// ───── CASHFLOW TIMELINE CHART COMPONENT ─────
function CashflowTimelineChart({ lang, t }) {
const [scale, setScale] = React.useState('30d');
const [hoveredIndex, setHoveredIndex] = React.useState(null);
const data = React.useMemo(() => {
if (scale === '30d') {
return generate30DaysData();
} else {
return generate12MonthsData(lang);
}
}, [scale, lang]);
const paddingLeft = 70;
const paddingRight = 70;
const paddingTop = 30;
const paddingBottom = 40;
const width = 800;
const height = 300;
const chartWidth = width - paddingLeft - paddingRight;
const chartHeight = height - paddingTop - paddingBottom;
const maxVolume = React.useMemo(() => {
const maxVal = Math.max(...data.map(d => d.volume));
return maxVal || 1;
}, [data]);
const maxCommission = React.useMemo(() => {
const maxVal = Math.max(...data.map(d => d.commission));
return maxVal || 1;
}, [data]);
const points = React.useMemo(() => {
return data.map((d, i) => {
const x = paddingLeft + (i / (data.length - 1 || 1)) * chartWidth;
const yVolume = paddingTop + chartHeight - (d.volume / maxVolume) * chartHeight;
const yCommission = paddingTop + chartHeight - (d.commission / maxCommission) * chartHeight;
return { x, yVolume, yCommission, ...d };
});
}, [data, maxVolume, maxCommission, chartWidth, chartHeight]);
const getBezierPath = (pts, key, tension = 0.25) => {
if (pts.length === 0) return "";
let path = `M ${pts[0].x} ${pts[0][key]}`;
for (let i = 0; i < pts.length - 1; i++) {
const curr = pts[i];
const next = pts[i + 1];
const cp1x = curr.x + (next.x - curr.x) * tension;
const cp1y = curr[key];
const cp2x = next.x - (next.x - curr.x) * tension;
const cp2y = next[key];
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${next.x} ${next[key]}`;
}
return path;
};
const getBezierAreaPath = (pts, key, tension = 0.25) => {
if (pts.length === 0) return "";
const linePath = getBezierPath(pts, key, tension);
const lastPoint = pts[pts.length - 1];
const firstPoint = pts[0];
const bottomY = paddingTop + chartHeight;
return `${linePath} L ${lastPoint.x} ${bottomY} L ${firstPoint.x} ${bottomY} Z`;
};
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const ratio = (mouseX - paddingLeft) / (rect.width - paddingLeft - paddingRight) * chartWidth;
const pct = ratio / chartWidth;
const index = Math.min(Math.max(Math.round(pct * (data.length - 1)), 0), data.length - 1);
setHoveredIndex(index);
};
const activePoint = hoveredIndex !== null ? points[hoveredIndex] : null;
const gridTicks = [0, 0.25, 0.5, 0.75, 1];
return (
{lang === 'vi' ? 'Biểu đồ Dòng tiền & Hoa hồng' : 'Cashflow & Commissions Timeline'}
{lang === 'vi' ? 'Giám sát khối lượng giao dịch và hoa hồng của hệ thống' : 'Monitor transaction volume and platform commission fees'}
setScale('30d')}
className={`hu-btn hu-btn-sm ${scale === '30d' ? 'hu-btn-primary' : 'hu-btn-ghost'}`}
style={{ fontSize: '11px', padding: '4px 12px', minHeight: '28px', border: 'none', boxShadow: 'none' }}
>
{lang === 'vi' ? '30 ngày' : '30 Days'}
setScale('12m')}
className={`hu-btn hu-btn-sm ${scale === '12m' ? 'hu-btn-primary' : 'hu-btn-ghost'}`}
style={{ fontSize: '11px', padding: '4px 12px', minHeight: '28px', border: 'none', boxShadow: 'none' }}
>
{lang === 'vi' ? '12 tháng' : '12 Months'}
{gridTicks.map((tick, i) => {
const y = paddingTop + chartHeight - tick * chartHeight;
return (
{window.vndShort(tick * maxVolume)}
{window.vndShort(tick * maxCommission)}
);
})}
{points.map((pt, i) => {
const showLabel = scale === '12m' || i % 3 === 0 || i === points.length - 1;
if (!showLabel) return null;
return (
{pt.date}
);
})}
{activePoint && (
)}
{
if (e.touches && e.touches[0]) {
const rect = e.currentTarget.getBoundingClientRect();
const mouseX = e.touches[0].clientX - rect.left;
const pct = mouseX / rect.width;
const index = Math.min(Math.max(Math.round(pct * (data.length - 1)), 0), data.length - 1);
setHoveredIndex(index);
}
}}
onMouseLeave={() => setHoveredIndex(null)}
onTouchEnd={() => setHoveredIndex(null)}
/>
{activePoint && (
width / 2 ? 'auto' : `${(activePoint.x / width) * 100}%`,
right: activePoint.x > width / 2 ? `${100 - (activePoint.x / width) * 100}%` : 'auto',
top: '40px',
transform: activePoint.x > width / 2 ? 'translateX(-15px)' : 'translateX(15px)',
pointerEvents: 'none',
backgroundColor: 'var(--hu-bg-inverse)',
color: 'var(--hu-fg-inverse)',
padding: '12px 16px',
borderRadius: 'var(--hu-r-md)',
fontSize: '12px',
zIndex: 10,
boxShadow: 'var(--hu-shadow-lg)',
border: '1.5px solid var(--hu-border-strong)',
display: 'flex',
flexDirection: 'column',
gap: '6px',
minWidth: '200px',
transition: 'left 0.1s ease-out, right 0.1s ease-out'
}}
>
{scale === '30d'
? (lang === 'vi' ? `Ngày ${activePoint.date}` : `Date: ${activePoint.date}`)
: (lang === 'vi' ? `Tháng ${activePoint.date}` : `Month: ${activePoint.date}`)
}
{lang === 'vi' ? 'Dòng tiền:' : 'Cashflow:'}
{window.vnd(activePoint.volume)}
{lang === 'vi' ? 'Hoa hồng:' : 'Commission:'}
{window.vnd(activePoint.commission)}
)}
{lang === 'vi' ? 'Dòng tiền (Trục trái)' : 'Cashflow Volume (Left Axis)'}
{lang === 'vi' ? 'Hoa hồng (Trục phải)' : 'Commissions (Right Axis)'}
);
}
// ───── CCCD VERIFICATION DONUT CHART COMPONENT ─────
function CccdVerificationDonutChart({ users, lang, t }) {
const [hoveredIndex, setHoveredIndex] = React.useState(null);
const counts = React.useMemo(() => {
const res = { verified: 0, pending: 0, rejected: 0, unsigned: 0 };
if (!Array.isArray(users)) return res;
for (let i = 0; i < users.length; i++) {
const s = users[i] && users[i].status;
if (res[s] !== undefined) res[s]++;
}
return res;
}, [users]);
const verifiedCount = counts.verified;
const pendingCount = counts.pending;
const rejectedCount = counts.rejected;
const unsignedCount = counts.unsigned;
const totalCccd = verifiedCount + pendingCount + rejectedCount + unsignedCount;
const categories = React.useMemo(() => {
return [
{ key: 'verified', label: t('status.verified'), count: verifiedCount, color: 'var(--hu-forest)' },
{ key: 'pending', label: t('status.pending'), count: pendingCount, color: 'var(--hu-ochre)' },
{ key: 'rejected', label: t('status.rejected'), count: rejectedCount, color: 'var(--hu-rust)' },
{ key: 'unsigned', label: t('status.unsigned'), count: unsignedCount, color: 'var(--hu-stone)' }
].filter(c => c.count > 0);
}, [verifiedCount, pendingCount, rejectedCount, unsignedCount, t]);
const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
};
const getDonutSegmentPath = (x, y, innerRadius, outerRadius, startAngle, endAngle) => {
let adjustedEndAngle = endAngle;
if (endAngle - startAngle >= 360) {
adjustedEndAngle = startAngle + 359.99;
}
const start = polarToCartesian(x, y, outerRadius, adjustedEndAngle);
const end = polarToCartesian(x, y, outerRadius, startAngle);
const startInner = polarToCartesian(x, y, innerRadius, adjustedEndAngle);
const endInner = polarToCartesian(x, y, innerRadius, startAngle);
const largeArcFlag = adjustedEndAngle - startAngle <= 180 ? "0" : "1";
return [
"M", start.x, start.y,
"A", outerRadius, outerRadius, 0, largeArcFlag, 0, end.x, end.y,
"L", endInner.x, endInner.y,
"A", innerRadius, innerRadius, 0, largeArcFlag, 1, startInner.x, startInner.y,
"Z"
].join(" ");
};
const segments = React.useMemo(() => {
let currentAngle = 0;
return categories.map((cat, idx) => {
const percent = cat.count / (totalCccd || 1);
const angleDelta = percent * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angleDelta;
currentAngle = endAngle;
return {
...cat,
startAngle,
endAngle,
percent,
index: idx
};
});
}, [categories, totalCccd]);
const activeSegment = hoveredIndex !== null ? segments[hoveredIndex] : null;
const centerTitle = activeSegment ? activeSegment.label : (lang === 'vi' ? 'Tổng số' : 'Total');
const centerValue = activeSegment ? activeSegment.count : totalCccd;
return (
{lang === 'vi' ? 'Thống kê định danh CCCD' : 'CCCD Verification Registry'}
{lang === 'vi'
? 'Phân tích tỷ lệ xác thực tài khoản và kiểm tra chéo cơ sở dữ liệu VNeID.'
: 'Operational telemetry of verified, pending, and unsubmitted user credentials.'}
{segments.map((seg, idx) => {
const isHovered = hoveredIndex === idx;
return (
setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
>
{seg.label}
{seg.count}
({Math.round(seg.percent * 100)}%)
);
})}
{totalCccd === 0 ? (
{lang === 'vi' ? 'Không có dữ liệu' : 'No Data'}
) : (
{segments.map((segment) => {
const isHovered = hoveredIndex === segment.index;
const midAngle = (segment.startAngle + segment.endAngle) / 2;
const rad = (midAngle - 90) * Math.PI / 180;
const dx = isHovered ? Math.cos(rad) * 6 : 0;
const dy = isHovered ? Math.sin(rad) * 6 : 0;
return (
setHoveredIndex(segment.index)}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
})}
{centerTitle}
{centerValue}
)}
);
}
window.CpCharts = {
renderCccdSvg,
CashflowTimelineChart,
CccdVerificationDonutChart,
};