JJ_MKVMaker/lib/ui/storage_chart.dart

122 lines
4.9 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../core/file_ops.dart' show formatSize;
import 'theme.dart';
/// X-plore 비교 P3 (87 위): 저장 공간 그림 - 크기만큼 넓이를 차지하는 칸 (squarified treemap).
/// 칸을 누르면 그 폴더 안으로 ([path] 가 없는 칸 - 이 폴더의 파일 · 나머지 - 은 누를 수 없음).
class StorageBlock {
final String label;
final int size;
final String? path;
const StorageBlock(this.label, this.size, [this.path]);
}
class StorageTreemap extends StatelessWidget {
final List<StorageBlock> blocks;
final void Function(StorageBlock b) onTap;
const StorageTreemap({super.key, required this.blocks, required this.onTap});
/// 칸 색 (크기 차례대로 돌려 씀)
static const _colors = [
Color(0xFF2E7D9A), Color(0xFF7E57C2), Color(0xFF43A047), Color(0xFFEF6C00),
Color(0xFFC2185B), Color(0xFF00897B), Color(0xFF5C6BC0), Color(0xFF8D6E63),
];
/// [blocks] (큰 것부터) 를 [area] 안에 나눠 놓은 사각형들. 크기가 0 인 것은 빠짐
static List<(StorageBlock, Rect)> layout(List<StorageBlock> blocks, Rect area) {
final items = [for (final b in blocks) if (b.size > 0) b]..sort((a, b) => b.size.compareTo(a.size));
final total = items.fold<int>(0, (s, b) => s + b.size);
if (items.isEmpty || total == 0 || area.isEmpty) return const [];
final scale = area.width * area.height / total;
final out = <(StorageBlock, Rect)>[];
var rect = area;
var i = 0;
while (i < items.length) {
final side = math.min(rect.width, rect.height);
// 한 줄에 넣을 것: 가장 나쁜 가로세로 비가 더 나빠지기 전까지
final row = <StorageBlock>[items[i]];
double worst(List<StorageBlock> r) {
final s = r.fold<double>(0, (t, b) => t + b.size * scale);
final mx = r.map((b) => b.size * scale).reduce(math.max), mn = r.map((b) => b.size * scale).reduce(math.min);
return math.max(side * side * mx / (s * s), (s * s) / (side * side * mn));
}
var j = i + 1;
while (j < items.length && worst([...row, items[j]]) <= worst(row)) {
row.add(items[j]);
j++;
}
final rowArea = row.fold<double>(0, (t, b) => t + b.size * scale);
final thick = side == 0 ? 0.0 : rowArea / side;
var at = 0.0;
final horizontal = rect.width >= rect.height; // 넓으면 왼쪽에 세로 줄, 높으면 위에 가로 줄
for (final b in row) {
final len = thick == 0 ? 0.0 : b.size * scale / thick;
out.add((
b,
horizontal
? Rect.fromLTWH(rect.left, rect.top + at, thick, len)
: Rect.fromLTWH(rect.left + at, rect.top, len, thick),
));
at += len;
}
rect = horizontal
? Rect.fromLTRB(rect.left + thick, rect.top, rect.right, rect.bottom)
: Rect.fromLTRB(rect.left, rect.top + thick, rect.right, rect.bottom);
i = j;
}
return out;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, c) {
final cells = layout(blocks, Offset.zero & Size(c.maxWidth, c.maxHeight));
if (cells.isEmpty) return const SizedBox();
final total = blocks.fold<int>(0, (s, b) => s + math.max(0, b.size));
return Stack(children: [
for (var k = 0; k < cells.length; k++)
Positioned.fromRect(
rect: cells[k].$2.deflate(1),
child: _cell(cells[k].$1, k, total),
),
]);
});
}
Widget _cell(StorageBlock b, int k, int total) {
final color = b.path == null ? JjColors.panelHigh : _colors[k % _colors.length];
final pct = total == 0 ? 0 : (b.size * 100 / total);
return Tooltip(
message: '${b.label} · ${formatSize(b.size)} · ${pct.toStringAsFixed(1)}%',
child: Material(
color: color,
borderRadius: BorderRadius.circular(3),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: b.path == null ? null : () => onTap(b),
child: LayoutBuilder(
builder: (_, c) => c.maxWidth < 44 || c.maxHeight < 30
? const SizedBox()
: Padding(
padding: const EdgeInsets.all(4),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(b.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)),
if (c.maxHeight >= 46)
Text('${formatSize(b.size)} · ${pct.toStringAsFixed(pct < 10 ? 1 : 0)}%',
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white70)),
]),
),
),
),
),
);
}
}