Files
fanuzu/app/lib/features/planet/widgets/fpi_hexagon.dart
T

144 lines
4.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<FpiScore> 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<FpiScore> 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<FpiScore> scores,
required int max,
TextStyle labelStyle = const TextStyle(fontSize: 10),
}) => _HexPainter(scores: scores, max: max, labelStyle: labelStyle);