Initial commit
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const KNOWN_DUPLICATES: [number, number][] = [
|
||||
[1, 400], [2, 395], [3, 432], [5, 495], [8, 449],
|
||||
[9, 318], [11, 483], [14, 476], [15, 468], [17, 438],
|
||||
[18, 466], [19, 481], [20, 444], [22, 460], [24, 489],
|
||||
[25, 486], [27, 472], [28, 496], [31, 493], [32, 504],
|
||||
[36, 457], [37, 508], [38, 471], [39, 420], [40, 510],
|
||||
[41, 507], [42, 455], [43, 501], [44, 502], [45, 464],
|
||||
[46, 485], [47, 498], [48, 497], [49, 505], [50, 491],
|
||||
[51, 506], [52, 488], [53, 500], [54, 459], [55, 478],
|
||||
[56, 492], [57, 474], [58, 490], [59, 484], [61, 463],
|
||||
[62, 469], [64, 503], [66, 494], [67, 509],
|
||||
];
|
||||
|
||||
function countDuplicates(answers: Record<number, number>): number {
|
||||
let count = 0;
|
||||
for (const [a, b] of KNOWN_DUPLICATES) {
|
||||
if (answers[a] !== undefined && answers[b] !== undefined && answers[a] !== answers[b]) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
answers: Record<number, number>;
|
||||
totalQuestions: number;
|
||||
responseType: string;
|
||||
}
|
||||
|
||||
/* Arc path helper: relative to 0,0 with radius r */
|
||||
function arcPath(r: number, startAngle: number, endAngle: number, sweep: boolean): string {
|
||||
const large = (endAngle - startAngle) > Math.PI ? 1 : 0;
|
||||
const sx = r * Math.cos(startAngle);
|
||||
const sy = r * Math.sin(startAngle);
|
||||
const ex = r * Math.cos(endAngle);
|
||||
const ey = r * Math.sin(endAngle);
|
||||
return `M 0,0 L ${sx.toFixed(1)},${sy.toFixed(1)} A ${r} ${r} 0 ${large} ${sweep ? 1 : 0} ${ex.toFixed(1)},${ey.toFixed(1)} Z`;
|
||||
}
|
||||
|
||||
const AnswerPieChart: React.FC<Props> = ({ answers, totalQuestions, responseType }) => {
|
||||
if (!answers || typeof answers !== 'object') return null;
|
||||
const entries = Object.entries(answers).filter(([k]) => !k.startsWith('_'));
|
||||
const answered = entries.length;
|
||||
const unanswered = totalQuestions - answered;
|
||||
|
||||
if (answered === 0) return null;
|
||||
|
||||
const isYesNo = responseType === 'yesno';
|
||||
const yesCount = entries.filter(([, v]) => Number(v) === 1).length;
|
||||
const noCount = entries.filter(([, v]) => Number(v) === 0).length;
|
||||
const otherCount = answered - yesCount - noCount;
|
||||
const duplicateCount = isYesNo ? countDuplicates(answers) : 0;
|
||||
|
||||
const segments: { label: string; value: number; color: string }[] = [];
|
||||
if (isYesNo) {
|
||||
if (yesCount > 0) segments.push({ label: `选「是」(${yesCount})`, value: yesCount, color: '#22c55e' });
|
||||
if (noCount > 0) segments.push({ label: `选「否」(${noCount})`, value: noCount, color: '#64748b' });
|
||||
if (otherCount > 0) segments.push({ label: `其他(${otherCount})`, value: otherCount, color: '#f59e0b' });
|
||||
} else {
|
||||
segments.push({ label: `已回答(${answered})`, value: answered, color: '#2563eb' });
|
||||
}
|
||||
if (unanswered > 0) segments.push({ label: `未作答(${unanswered})`, value: unanswered, color: '#e2e8f0' });
|
||||
if (duplicateCount > 0) segments.push({ label: `矛盾回答(${duplicateCount}对)`, value: duplicateCount, color: '#ef4444' });
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const total = segments.reduce((s, seg) => s + seg.value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
const r = 100;
|
||||
let curAngle = -Math.PI / 2;
|
||||
|
||||
const arcs = segments.map(seg => {
|
||||
const angle = (seg.value / total) * 2 * Math.PI;
|
||||
const path = seg.value === total
|
||||
? `M -${r},0 A ${r},${r} 0 1,1 ${r - 0.01},0 Z`
|
||||
: arcPath(r, curAngle, curAngle + angle, true);
|
||||
const labelAngle = curAngle + angle / 2;
|
||||
const labelR = r * 0.65;
|
||||
const lx = labelR * Math.cos(labelAngle);
|
||||
const ly = labelR * Math.sin(labelAngle);
|
||||
curAngle += angle;
|
||||
return { path, color: seg.color, label: seg.label, lx, ly, pct: Math.round((seg.value / total) * 100) };
|
||||
});
|
||||
|
||||
const legend = segments.filter(s => s.value > 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 14, marginBottom: 8, display: 'block' }}>答题分布</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 24, justifyContent: 'center' }}>
|
||||
<svg viewBox="-120 -120 240 240" style={{ width: 200, height: 200, flexShrink: 0 }}>
|
||||
{arcs.map((arc, i) => (
|
||||
<g key={i}>
|
||||
<path d={arc.path} fill={arc.color} stroke="#fff" strokeWidth={2} />
|
||||
{arc.pct >= 5 && (
|
||||
<text x={arc.lx} y={arc.ly} textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize={11} fill="#fff" fontWeight="bold">
|
||||
{arc.pct}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
<circle cx={0} cy={0} r={35} fill="#fff" />
|
||||
<text x={0} y={-6} textAnchor="middle" fontSize={13} fontWeight="bold" fill="#1e293b">
|
||||
{answered}/{totalQuestions}
|
||||
</text>
|
||||
<text x={0} y={10} textAnchor="middle" fontSize={10} fill="#64748b">已作答</text>
|
||||
</svg>
|
||||
<div>
|
||||
{legend.map((seg, i) => {
|
||||
const pct = Math.round((seg.value / total) * 100);
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 2, background: seg.color, display: 'inline-block' }} />
|
||||
<Text style={{ fontSize: 12 }}>{seg.label}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{pct}%</Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnswerPieChart;
|
||||
Reference in New Issue
Block a user