1511-occupancy-heat-map-tooltip.

This commit is contained in:
Faris Armoush
2025-05-14 12:03:47 +03:00
parent 7f9d044f7e
commit a9d6c6f4ee
4 changed files with 207 additions and 30 deletions

View File

@ -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,
),
),
],
),
),
);
}
}

View File

@ -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<OccupancyPaintItem> items;
final int maxValue;
final double cellSize;
@override
State<InteractiveHeatMap> createState() => _InteractiveHeatMapState();
}
class _InteractiveHeatMapState extends State<InteractiveHeatMap> {
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);
}
}

View File

@ -1,6 +1,7 @@
import 'dart:math' as math show max; import 'dart:math' as math show max;
import 'package:flutter/material.dart'; 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_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_gradient.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_months.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 _cellSize = 16.0;
static const _totalWeeks = 53; static const _totalWeeks = 53;
int get _maxValue => heatMapData.isNotEmpty int get _maxValue =>
? heatMapData.keys.map((key) => heatMapData[key]!).reduce(math.max) heatMapData.isNotEmpty ? heatMapData.values.reduce(math.max) : 0;
: 0;
DateTime _getStartingDate() { DateTime _getStartingDate() {
final jan1 = DateTime(DateTime.now().year, 1, 1); final jan1 = DateTime(DateTime.now().year, 1, 1);
@ -28,7 +28,7 @@ class OccupancyHeatMap extends StatelessWidget {
return List.generate(_totalWeeks * 7, (index) { return List.generate(_totalWeeks * 7, (index) {
final date = startDate.add(Duration(days: index)); final date = startDate.add(Duration(days: index));
final value = heatMapData[date] ?? 0; 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( child: Row(
children: [ children: [
const OccupancyHeatMapDays(cellSize: _cellSize), const OccupancyHeatMapDays(cellSize: _cellSize),
CustomPaint( SizedBox(
size: const Size(_totalWeeks * _cellSize, 7 * _cellSize), width: _totalWeeks * _cellSize,
child: CustomPaint( height: 7 * _cellSize,
isComplex: true, child: InteractiveHeatMap(
size: const Size(_totalWeeks * _cellSize, 7 * _cellSize), items: paintItems,
painter: OccupancyPainter( maxValue: _maxValue,
items: paintItems, cellSize: _cellSize,
maxValue: _maxValue,
),
), ),
), ),
], ],

View File

@ -4,18 +4,25 @@ import 'package:syncrow_web/utils/color_manager.dart';
class OccupancyPaintItem { class OccupancyPaintItem {
final int index; final int index;
final int value; 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 { class OccupancyPainter extends CustomPainter {
OccupancyPainter({ OccupancyPainter({
required this.items, required this.items,
required this.maxValue, required this.maxValue,
this.hoveredItem,
}); });
final List<OccupancyPaintItem> items; final List<OccupancyPaintItem> items;
final int maxValue; final int maxValue;
final OccupancyPaintItem? hoveredItem;
static const double cellSize = 16.0; static const double cellSize = 16.0;
@ -25,6 +32,10 @@ class OccupancyPainter extends CustomPainter {
final Paint borderPaint = Paint() final Paint borderPaint = Paint()
..color = ColorsManager.grayBorder.withValues(alpha: 0.4) ..color = ColorsManager.grayBorder.withValues(alpha: 0.4)
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
final Paint hoveredBorderPaint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
for (final item in items) { for (final item in items) {
final column = item.index ~/ 7; final column = item.index ~/ 7;
@ -37,22 +48,27 @@ class OccupancyPainter extends CustomPainter {
final rect = Rect.fromLTWH(x, y, cellSize, cellSize); final rect = Rect.fromLTWH(x, y, cellSize, cellSize);
canvas.drawRect(rect, fillPaint); canvas.drawRect(rect, fillPaint);
_drawDashedLine( // Highlight the hovered item
canvas, if (hoveredItem != null && hoveredItem!.index == item.index) {
Offset(x, y), canvas.drawRect(rect, hoveredBorderPaint);
Offset(x + cellSize, y), } else {
borderPaint, _drawDashedLine(
); canvas,
_drawDashedLine( Offset(x, y),
canvas, Offset(x + cellSize, y),
Offset(x, y + cellSize), borderPaint,
Offset(x + cellSize, y + cellSize), );
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, y), Offset(x, y + cellSize), borderPaint);
canvas.drawLine( canvas.drawLine(Offset(x + cellSize, y), Offset(x + cellSize, y + cellSize),
Offset(x + cellSize, y), Offset(x + cellSize, y + cellSize), borderPaint); borderPaint);
}
} }
} }
@ -80,5 +96,6 @@ class OccupancyPainter extends CustomPainter {
} }
@override @override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false; bool shouldRepaint(covariant OccupancyPainter oldDelegate) =>
oldDelegate.hoveredItem != hoveredItem;
} }