144 lines
4.3 KiB
Dart
144 lines
4.3 KiB
Dart
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);
|