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 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 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(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 = [items[i]]; double worst(List r) { final s = r.fold(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(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(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)), ]), ), ), ), ), ); } }