import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../planet_data.dart'; /// TOTAL FPI 오각 그래프 — Figma 명세 「TOTAL FPI 영역」 · 시안 `Frame 1984081147`(232×140). /// /// *데이터 : TOTAL FPI 값 · 육각형 그래프 · 규칙 : 실시간 데이터 기준 노출* /// /// 시안 — 다각형 114×108(외곽 #2c2c2e 2 + 안쪽 3단 1), 데이터 면 #5bd4de 20% · 선 1, /// 꼭짓점 6 #64eff9. 축 이름 42dot Sans 10/500 #dde3e4 는 위 · 좌우 · 아래 두 칸에 놓는다. /// 지표가 다섯이 아니면 축 이름을 꼭짓점 바깥에 방사형으로 놓는다. class FpiHexagon extends StatelessWidget { const FpiHexagon({super.key, required this.scores, required this.max}); final List scores; final int max; static const size = Size(232, 140); @override Widget build(BuildContext context) => SizedBox.fromSize( size: size, child: CustomPaint( painter: _HexPainter( scores: scores, max: max, labelStyle: const TextStyle( fontFamily: AppFonts.body, fontSize: 10, fontWeight: FontWeight.w500, height: 1, color: Color(0xFFDDE3E4), ), ), ), ); } class _HexPainter extends CustomPainter { _HexPainter({ required this.scores, required this.max, required this.labelStyle, }); final List scores; final int max; final TextStyle labelStyle; /// 격자 단 비율 — 시안 Polygon 3·4·5(93.9 · 63.9 · 32 / 114). static const rings = [1.0, 0.82, 0.56, 0.28]; @override void paint(Canvas canvas, Size size) { if (scores.isEmpty) return; final n = scores.length; // 외접원 반지름 — 오각형 높이 108 = R(1 + cos 36°). final radius = 108 / (1 + math.cos(math.pi / 5)) * size.height / 140; final center = Offset(size.width / 2, size.height * 16 / 140 + radius); Offset at(int i, double r) { // 12시 방향에서 시작해 시계 방향. final a = -math.pi / 2 + (2 * math.pi * i) / n; return center + Offset(math.cos(a) * r, math.sin(a) * r); } Path poly(double r) { final p = Path(); for (var i = 0; i < n; i++) { final o = at(i, r); i == 0 ? p.moveTo(o.dx, o.dy) : p.lineTo(o.dx, o.dy); } return p..close(); } for (final (i, k) in rings.indexed) { canvas.drawPath( poly(radius * k), Paint() ..style = PaintingStyle.stroke ..strokeWidth = i == 0 ? 2 : 1 ..color = AppColors.cosmicOutline, ); } // 데이터 다각형. final data = Path(); for (var i = 0; i < n; i++) { final o = at(i, radius * scores[i].normalized(max)); i == 0 ? data.moveTo(o.dx, o.dy) : data.lineTo(o.dx, o.dy); } data.close(); const line = Color(0xFF5BD4DE); canvas ..drawPath(data, Paint()..color = line.withValues(alpha: 0.2)) ..drawPath( data, Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1 ..color = line, ); // 시안 좌표(232×140 기준) — 위 · 오른쪽 · 오른쪽 아래 · 왼쪽 아래 · 왼쪽. const anchors = [ Offset(116, 5), Offset(201, 70), Offset(179.5, 135), Offset(59, 135), Offset(25, 70), ]; final sx = size.width / 232, sy = size.height / 140; for (var i = 0; i < n; i++) { canvas.drawCircle( at(i, radius * scores[i].normalized(max)), 3, Paint()..color = AppColors.neonCyan, ); final tp = TextPainter( text: TextSpan(text: scores[i].metric.label, style: labelStyle), textDirection: TextDirection.ltr, )..layout(); final o = n == 5 ? Offset(anchors[i].dx * sx, anchors[i].dy * sy) : at(i, radius + 14); tp.paint(canvas, o - Offset(tp.width / 2, tp.height / 2)); } } @override bool shouldRepaint(_HexPainter old) => old.scores != scores || old.max != max; } /// 그리기 비용 측정용 진입점. 사유는 [galaxyFieldPainterForTest] 와 같다. @visibleForTesting CustomPainter fpiHexagonPainterForTest({ required List scores, required int max, TextStyle labelStyle = const TextStyle(fontSize: 10), }) => _HexPainter(scores: scores, max: max, labelStyle: labelStyle);