From a9d6c6f4ee1dfd7a8e49c106267a6a90f1d430c4 Mon Sep 17 00:00:00 2001 From: Faris Armoush Date: Wed, 14 May 2025 12:03:47 +0300 Subject: [PATCH] 1511-occupancy-heat-map-tooltip. --- .../occupancy/widgets/heat_map_tooltip.dart | 54 +++++++++ .../widgets/interactive_heat_map.dart | 108 ++++++++++++++++++ .../occupancy/widgets/occupancy_heat_map.dart | 24 ++-- .../occupancy/widgets/occupancy_painter.dart | 51 ++++++--- 4 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 lib/pages/analytics/modules/occupancy/widgets/heat_map_tooltip.dart create mode 100644 lib/pages/analytics/modules/occupancy/widgets/interactive_heat_map.dart diff --git a/lib/pages/analytics/modules/occupancy/widgets/heat_map_tooltip.dart b/lib/pages/analytics/modules/occupancy/widgets/heat_map_tooltip.dart new file mode 100644 index 00000000..c7695064 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/heat_map_tooltip.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class HeatMapTooltip extends StatelessWidget { + const HeatMapTooltip({ + required this.date, + required this.value, + super.key, + }); + + final DateTime date; + final int value; + + @override + Widget build(BuildContext context) { + return FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.topStart, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: ColorsManager.grey700, + borderRadius: BorderRadius.circular(3), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + DateFormat('MMM d, yyyy').format(date), + style: context.textTheme.bodySmall?.copyWith( + fontSize: 12, + fontWeight: FontWeight.w700, + color: ColorsManager.whiteColors, + ), + ), + const Divider(height: 2, thickness: 1), + Text( + '$value Occupants', + style: context.textTheme.bodySmall?.copyWith( + fontSize: 10, + fontWeight: FontWeight.w500, + color: ColorsManager.whiteColors, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/analytics/modules/occupancy/widgets/interactive_heat_map.dart b/lib/pages/analytics/modules/occupancy/widgets/interactive_heat_map.dart new file mode 100644 index 00000000..a652ae73 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/interactive_heat_map.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/heat_map_tooltip.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart'; + +class InteractiveHeatMap extends StatefulWidget { + const InteractiveHeatMap({ + required this.items, + required this.maxValue, + required this.cellSize, + super.key, + }); + + final List items; + final int maxValue; + final double cellSize; + + @override + State createState() => _InteractiveHeatMapState(); +} + +class _InteractiveHeatMapState extends State { + OccupancyPaintItem? _hoveredItem; + OverlayEntry? _overlayEntry; + final LayerLink _layerLink = LayerLink(); + + @override + void dispose() { + _removeOverlay(); + _overlayEntry?.dispose(); + super.dispose(); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void _showTooltip(OccupancyPaintItem item, Offset localPosition) { + _removeOverlay(); + + final column = item.index ~/ 7; + final row = item.index % 7; + final x = column * widget.cellSize; + final y = row * widget.cellSize; + + _overlayEntry = OverlayEntry( + builder: (context) => Positioned( + child: CompositedTransformFollower( + link: _layerLink, + offset: Offset(x + widget.cellSize, y), + child: Material( + color: Colors.transparent, + child: Transform.translate( + offset: Offset(-(widget.cellSize * 2.5), -50), + child: HeatMapTooltip(date: item.date, value: item.value), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_overlayEntry!); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: MouseRegion( + onHover: (event) { + final column = event.localPosition.dx ~/ widget.cellSize; + final row = event.localPosition.dy ~/ widget.cellSize; + final index = column * 7 + row; + + if (index >= 0 && index < widget.items.length) { + final item = widget.items[index]; + if (_hoveredItem != item) { + setState(() => _hoveredItem = item); + _showTooltip(item, event.localPosition); + } + } else { + _removeOverlay(); + setState(() => _hoveredItem = null); + } + }, + onExit: (_) { + _removeOverlay(); + setState(() => _hoveredItem = null); + }, + child: CustomPaint( + isComplex: true, + size: _painterSize, + painter: OccupancyPainter( + items: widget.items, + maxValue: widget.maxValue, + hoveredItem: _hoveredItem, + ), + ), + ), + ); + } + + Size get _painterSize { + final height = 7 * widget.cellSize; + final width = widget.items.length ~/ 7 * widget.cellSize; + return Size(width, height); + } +} diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart index 6fd4b259..92a3f7df 100644 --- a/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart @@ -1,6 +1,7 @@ import 'dart:math' as math show max; import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/interactive_heat_map.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_days.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_gradient.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_months.dart'; @@ -14,9 +15,8 @@ class OccupancyHeatMap extends StatelessWidget { static const _cellSize = 16.0; static const _totalWeeks = 53; - int get _maxValue => heatMapData.isNotEmpty - ? heatMapData.keys.map((key) => heatMapData[key]!).reduce(math.max) - : 0; + int get _maxValue => + heatMapData.isNotEmpty ? heatMapData.values.reduce(math.max) : 0; DateTime _getStartingDate() { final jan1 = DateTime(DateTime.now().year, 1, 1); @@ -28,7 +28,7 @@ class OccupancyHeatMap extends StatelessWidget { return List.generate(_totalWeeks * 7, (index) { final date = startDate.add(Duration(days: index)); final value = heatMapData[date] ?? 0; - return OccupancyPaintItem(index: index, value: value); + return OccupancyPaintItem(index: index, value: value, date: date); }); } @@ -58,15 +58,13 @@ class OccupancyHeatMap extends StatelessWidget { child: Row( children: [ const OccupancyHeatMapDays(cellSize: _cellSize), - CustomPaint( - size: const Size(_totalWeeks * _cellSize, 7 * _cellSize), - child: CustomPaint( - isComplex: true, - size: const Size(_totalWeeks * _cellSize, 7 * _cellSize), - painter: OccupancyPainter( - items: paintItems, - maxValue: _maxValue, - ), + SizedBox( + width: _totalWeeks * _cellSize, + height: 7 * _cellSize, + child: InteractiveHeatMap( + items: paintItems, + maxValue: _maxValue, + cellSize: _cellSize, ), ), ], diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart index 8cdb61e4..633b8c54 100644 --- a/lib/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart @@ -4,18 +4,25 @@ import 'package:syncrow_web/utils/color_manager.dart'; class OccupancyPaintItem { final int index; final int value; + final DateTime date; - const OccupancyPaintItem({required this.index, required this.value}); + const OccupancyPaintItem({ + required this.index, + required this.value, + required this.date, + }); } class OccupancyPainter extends CustomPainter { OccupancyPainter({ required this.items, required this.maxValue, + this.hoveredItem, }); final List items; final int maxValue; + final OccupancyPaintItem? hoveredItem; static const double cellSize = 16.0; @@ -25,6 +32,10 @@ class OccupancyPainter extends CustomPainter { final Paint borderPaint = Paint() ..color = ColorsManager.grayBorder.withValues(alpha: 0.4) ..style = PaintingStyle.stroke; + final Paint hoveredBorderPaint = Paint() + ..color = Colors.black + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; for (final item in items) { final column = item.index ~/ 7; @@ -37,22 +48,27 @@ class OccupancyPainter extends CustomPainter { final rect = Rect.fromLTWH(x, y, cellSize, cellSize); canvas.drawRect(rect, fillPaint); - _drawDashedLine( - canvas, - Offset(x, y), - Offset(x + cellSize, y), - borderPaint, - ); - _drawDashedLine( - canvas, - Offset(x, y + cellSize), - Offset(x + cellSize, y + cellSize), - borderPaint, - ); + // Highlight the hovered item + if (hoveredItem != null && hoveredItem!.index == item.index) { + canvas.drawRect(rect, hoveredBorderPaint); + } else { + _drawDashedLine( + canvas, + Offset(x, y), + Offset(x + cellSize, y), + borderPaint, + ); + _drawDashedLine( + canvas, + Offset(x, y + cellSize), + Offset(x + cellSize, y + cellSize), + borderPaint, + ); - canvas.drawLine(Offset(x, y), Offset(x, y + cellSize), borderPaint); - canvas.drawLine( - Offset(x + cellSize, y), Offset(x + cellSize, y + cellSize), borderPaint); + canvas.drawLine(Offset(x, y), Offset(x, y + cellSize), borderPaint); + canvas.drawLine(Offset(x + cellSize, y), Offset(x + cellSize, y + cellSize), + borderPaint); + } } } @@ -80,5 +96,6 @@ class OccupancyPainter extends CustomPainter { } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + bool shouldRepaint(covariant OccupancyPainter oldDelegate) => + oldDelegate.hoveredItem != hoveredItem; }