// ─────────────────────────────────────────────────────────── // 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'}
{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, };