diff --git a/lib/rubin_chart.dart b/lib/rubin_chart.dart index 8c1e21f..3a077d7 100644 --- a/lib/rubin_chart.dart +++ b/lib/rubin_chart.dart @@ -45,6 +45,7 @@ export 'src/ui/chart.dart'; export 'src/ui/legend.dart'; export 'src/ui/selection_controller.dart'; export 'src/ui/series_painter.dart'; +export 'src/ui/chart_tooltip.dart'; export 'src/utils/quadtree.dart'; export 'src/utils/utils.dart'; diff --git a/lib/src/models/axes/axis.dart b/lib/src/models/axes/axis.dart index 55c5bf7..f8fbca6 100644 --- a/lib/src/models/axes/axis.dart +++ b/lib/src/models/axes/axis.dart @@ -128,19 +128,44 @@ class AxisId { String toString() => "AxisId($location, $axesId)"; /// Convert the [AxisId] to a JSON object. - Map toJson() { - return { - "location": location.name, - "axesId": axesId, - }; + String toJson() { + // Use string format for simplicity when used as map keys + return "${location.toString().split('.').last},$axesId"; } /// Create an [AxisId] from a JSON object. - factory AxisId.fromJson(Map json) { - return AxisId( - AxisLocation.values.firstWhere((e) => e.toString().split(".").last == json["location"]), - json["axesId"], - ); + factory AxisId.fromJson(dynamic json) { + if (json is String) { + // Handle string format for backwards compatibility or simple serialization + // Expected format: "AxisLocation.locationName,axesId" + List parts = json.split(','); + if (parts.length != 2) { + throw ArgumentError("Invalid AxisId string format: $json"); + } + + AxisLocation location = AxisLocation.values.firstWhere( + (e) => e.toString() == parts[0] || e.toString().split('.').last == parts[0], + orElse: () => throw ArgumentError("Unknown AxisLocation: ${parts[0]}"), + ); + + Object axesId; + // Try to parse as int first, fallback to string + try { + axesId = int.parse(parts[1]); + } catch (e) { + axesId = parts[1]; + } + + return AxisId(location, axesId); + } else if (json is Map) { + // Handle map format + return AxisId( + AxisLocation.values.firstWhere((e) => e.toString().split(".").last == json["location"]), + json["axesId"], + ); + } else { + throw ArgumentError("AxisId.fromJson expects String or Map, got ${json.runtimeType}"); + } } } diff --git a/lib/src/models/binned.dart b/lib/src/models/binned.dart index 005d47d..488a7dd 100644 --- a/lib/src/models/binned.dart +++ b/lib/src/models/binned.dart @@ -33,6 +33,7 @@ import 'package:rubin_chart/src/ui/chart.dart'; import 'package:rubin_chart/src/ui/charts/box.dart'; import 'package:rubin_chart/src/ui/charts/cartesian.dart'; import 'package:rubin_chart/src/ui/selection_controller.dart'; +import 'package:rubin_chart/src/ui/chart_tooltip.dart'; /// A class that represents binned data. abstract class BinnedData { @@ -333,14 +334,8 @@ abstract class BinnedChartState extends State /// The [BinnedChartInfo] for the chart. BinnedChartInfo get info; - /// The tooltip if the user is hovering over a bin. - OverlayEntry? hoverOverlay; - - @override - SeriesList get seriesList => SeriesList( - widget.info.allSeries, - widget.info.colorCycle ?? widget.info.theme.colorCycle, - ); + /// Manager for chart tooltips + late ChartTooltipManager tooltipManager; /// The axes of the chart. @override @@ -365,12 +360,6 @@ abstract class BinnedChartState extends State SelectedBin? lastRangeEnd; - /// A timer used to determine if the user is hovering over a bin. - Timer? _hoverTimer; - - /// Whether the user is currently hovering over a bin. - bool _isHovering = false; - /// The location of the base of the histogram bins. /// This is used to determine the orientation and layout of the histogram. late AxisLocation baseLocation; @@ -425,15 +414,7 @@ abstract class BinnedChartState extends State @override void dispose() { - try { - hoverOverlay?.remove(); - } catch (e) { - // Log the error if necessary, but avoid crashing. - throw StateError("Failed to clear hoverOverlay during dispose: $e"); - } - hoverOverlay = null; - _hoverTimer?.cancel(); - _hoverTimer = null; + tooltipManager.dispose(); focusNode.removeListener(focusNodeListener); if (widget.selectionController != null) { widget.selectionController!.unsubscribe(widget.info.id); @@ -449,6 +430,11 @@ abstract class BinnedChartState extends State void initState() { super.initState(); + // Initialize tooltip manager + tooltipManager = ChartTooltipManager( + getOverlay: () => Overlay.of(context), + ); + // Add key detector focusNode.addListener(focusNodeListener); @@ -480,8 +466,6 @@ abstract class BinnedChartState extends State void updateAxesAndBins(); /// Get the tooltip widget for the given bin. - /// This is not implemented in the base class because - /// histograms and box charts have different tooltips. Widget getTooltip({ required PointerHoverEvent event, required ChartAxis mainAxis, @@ -489,65 +473,15 @@ abstract class BinnedChartState extends State required BinnedData bin, }); - /// Create the tooltip if the user is hovering over a bin - void onHoverStart({ + /// Called to show a tooltip. Subclasses implement this using their tooltip manager. + void showTooltip({ required PointerHoverEvent event, - required BinnedData? bin, - }) { - if (bin == null) return; - - ChartAxis mainAxis; - ChartAxis crossAxis; - if (mainAxisAlignment == AxisOrientation.horizontal) { - mainAxis = allAxes.values.first.axes.values.first; - crossAxis = allAxes.values.first.axes.values.last; - } else { - mainAxis = allAxes.values.first.axes.values.last; - crossAxis = allAxes.values.first.axes.values.first; - } - - // Convert local position to global - final RenderBox renderBox = context.findRenderObject() as RenderBox; - final Offset globalPosition = renderBox.localToGlobal(event.localPosition); - - // Build tooltip widget - Widget tooltip = IgnorePointer( - // Tooltip won't block interactions with the chart - ignoring: true, - child: Material( - color: Colors.transparent, - child: getTooltip( - event: event, - bin: bin, - mainAxis: mainAxis, - crossAxis: crossAxis, - ), - ), - ); - // ); - - // Create the OverlayEntry - hoverOverlay = OverlayEntry( - builder: (context) { - return Positioned( - left: globalPosition.dx, - top: globalPosition.dy, - child: tooltip, - ); - }, - ); - - // Insert the tooltip overlay - Overlay.of(context).insert(hoverOverlay!); - } + required BinnedData bin, + }); - void _clearHover() { - hoverOverlay?.remove(); - hoverOverlay = null; - // Ensure UI updates only if the widget is still mounted - if (mounted) { - setState(() {}); - } + /// Called to clear any visible tooltip. Subclasses implement this using their tooltip manager. + void clearTooltip() { + tooltipManager.clearTooltip(); } @override @@ -607,31 +541,22 @@ abstract class BinnedChartState extends State } }, child: MouseRegion( + onExit: (PointerExitEvent event) { + // Clear hover and tooltip when cursor leaves the chart entirely + clearTooltip(); + }, onHover: (PointerHoverEvent event) { - // In Flutter web this is triggered when the mouse is moved, - // so we need to keep track of the hover timer manually. - - // Restart the hover timer - _hoverTimer?.cancel(); - _hoverTimer = Timer(const Duration(milliseconds: 1000), () { - SelectedBin? hoverBin = _getBinOnTap(event.localPosition, axisPainter); - if (hoverBin == null) { - _clearHover(); - return; - } + // Check if we're over a bin + SelectedBin? hoverBin = _getBinOnTap(event.localPosition, axisPainter); + + if (hoverBin != null) { + // Show tooltip for this bin BinnedData bin = binContainers[hoverBin.seriesIndex]!.bins[hoverBin.binIndex]; - onHoverStart(event: event, bin: bin); - _hoverTimer?.cancel(); - _hoverTimer = null; - _isHovering = true; - setState(() {}); - }); - - if (_isHovering) { - _clearHover(); - // onHoverEnd(event); + showTooltip(event: event, bin: bin); + } else { + // Not over a bin, clear tooltip + clearTooltip(); } - _isHovering = false; }, child: GestureDetector( behavior: HitTestBehavior.opaque, @@ -645,17 +570,10 @@ abstract class BinnedChartState extends State )); } - /// Handles the tap up event on the histogram chart. - /// - /// This method is called when the user taps on the histogram chart. - /// It updates the selected bin based on the tap location, - /// retrieves the data points associated with the selected bin, - /// and updates the selection controller if available. + /// Handles the tap up event on the binned chart. void _onTapUp(TapUpDetails details, AxisPainter axisPainter) { focusNode.requestFocus(); - // Always remove the tooltip first - _clearHover(); - // Get the selected bin based on the tap location + clearTooltip(); SelectedBin? selectedBin = _getBinOnTap(details.localPosition, axisPainter); _updatedBinSelection(selectedBin); } diff --git a/lib/src/ui/chart_tooltip.dart b/lib/src/ui/chart_tooltip.dart new file mode 100644 index 0000000..7f88abf --- /dev/null +++ b/lib/src/ui/chart_tooltip.dart @@ -0,0 +1,210 @@ +/// This file is part of the rubin_chart package. +/// +/// Developed for the LSST Data Management System. +/// This product includes software developed by the LSST Project +/// (https://www.lsst.org). +/// See the COPYRIGHT file at the top-level directory of this distribution +/// for details of code ownership. +/// +/// This program is free software: you can redistribute it and/or modify +/// it under the terms of the GNU General Public License as published by +/// the Free Software Foundation, either version 3 of the License, or +/// (at your option) any later version. +/// +/// This program is distributed in the hope that it will be useful, +/// but WITHOUT ANY WARRANTY; without even the implied warranty of +/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +/// GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License +/// along with this program. If not, see . + +import 'dart:developer' as developer; +import 'package:flutter/material.dart'; +import 'dart:async'; + +/// Manager for chart tooltips that handles showing/hiding with proper cleanup. +class ChartTooltipManager { + OverlayEntry? _hoverOverlay; + Timer? _hoverTimer; + bool _isHovering = false; + bool _timerPending = false; // Track if timer is waiting + final Duration hoverDelay; + final OverlayState Function() getOverlay; + + // Store the pending tooltip info + Offset? _pendingPosition; + Widget? _pendingContent; + + ChartTooltipManager({ + required this.getOverlay, + this.hoverDelay = const Duration(milliseconds: 500), + }); + + /// Show a tooltip at the given position with the provided content widget. + void showTooltip({ + required Offset position, + required Widget content, + Offset offsetFromCursor = const Offset(15, 15), + }) { + // If timer is already pending, don't restart it - just update the position + if (_timerPending) { + _pendingPosition = position + offsetFromCursor; + _pendingContent = content; + return; + } + + // Only start a new timer if one isn't pending + _hoverTimer?.cancel(); + _pendingPosition = position + offsetFromCursor; + _pendingContent = content; + _timerPending = true; + + _hoverTimer = Timer(hoverDelay, () { + _timerPending = false; + if (_pendingPosition != null && _pendingContent != null) { + _createAndShowOverlay(_pendingPosition!, _pendingContent!); + _isHovering = true; + } + }); + } + + /// Create and insert the overlay entry for the tooltip. + void _createAndShowOverlay(Offset position, Widget content) { + // Clear any existing overlay + _hoverOverlay?.remove(); + _hoverOverlay = null; + + _hoverOverlay = OverlayEntry( + builder: (context) { + return Positioned( + left: position.dx, + top: position.dy, + child: Material( + color: Colors.transparent, + child: MouseRegion( + onEnter: (_) { + // Keep tooltip visible when mouse is over it + developer.log("Mouse entered tooltip", name: "rubin_chart.chart_tooltip_manager"); + _hoverTimer?.cancel(); + _timerPending = false; + _isHovering = true; + }, + onExit: (_) { + // Hide tooltip when mouse leaves + developer.log("Mouse exited tooltip", name: "rubin_chart.chart_tooltip_manager"); + clearTooltip(); + }, + child: content, + ), + ), + ); + }, + ); + + try { + getOverlay().insert(_hoverOverlay!); + } catch (e) { + developer.log("Failed to insert tooltip overlay: $e", name: "rubin_chart.chart_tooltip_manager"); + _hoverOverlay = null; + } + } + + /// Clear the tooltip and cancel any pending timers. + void clearTooltip() { + _hoverTimer?.cancel(); + _hoverTimer = null; + _timerPending = false; + _pendingPosition = null; + _pendingContent = null; + _hoverOverlay?.remove(); + _hoverOverlay = null; + _isHovering = false; + } + + /// Restart the hover timer (called when cursor moves while hovering) + void restartHoverTimer() { + // Only clear if tooltip is already shown (not pending) + if (_isHovering && _hoverOverlay != null) { + clearTooltip(); + } + } + + /// Check if currently hovering over content + bool get isHovering => _isHovering; + + /// Handle cursor entering a hoverable element + void onHoverEnter() { + _isHovering = true; + } + + /// Handle cursor exiting a hoverable element + void onHoverExit() { + _isHovering = false; + clearTooltip(); + } + + /// Check if tooltip is currently visible. + bool get isVisible => _hoverOverlay != null && _isHovering; + + /// Dispose of the tooltip manager and clean up resources. + void dispose() { + clearTooltip(); + } +} + +/// Standard tooltip widget for charts. +class ChartTooltip extends StatelessWidget { + final String title; + final List entries; + final Color backgroundColor; + final Color borderColor; + final double borderRadius; + + const ChartTooltip({ + Key? key, + required this.title, + required this.entries, + this.backgroundColor = Colors.white, + this.borderColor = const Color(0xFFE0E0E0), + this.borderRadius = 5, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AbsorbPointer( + absorbing: false, + child: Container( + decoration: BoxDecoration( + color: backgroundColor.withAlpha(250), + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(borderRadius), + ), + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ...entries.map((entry) => Text("${entry.label}: ${entry.value}")), + ], + ), + ), + ); + } +} + +/// A single entry in a tooltip. +class TooltipEntry { + final String label; + final String value; + + TooltipEntry({required this.label, required this.value}); +} diff --git a/lib/src/ui/charts/box.dart b/lib/src/ui/charts/box.dart index 9300a83..b8ea1df 100644 --- a/lib/src/ui/charts/box.dart +++ b/lib/src/ui/charts/box.dart @@ -423,5 +423,39 @@ class BoxChartState extends BinnedChartState { } @override - void onHoverEnd(PointerHoverEvent event) {} + SeriesList get seriesList => SeriesList( + widget.info.allSeries, + widget.info.colorCycle ?? widget.info.theme.colorCycle, + ); + + @override + void showTooltip({ + required PointerHoverEvent event, + required BinnedData bin, + }) { + ChartAxis mainAxis; + ChartAxis crossAxis; + if (mainAxisAlignment == AxisOrientation.horizontal) { + mainAxis = allAxes.values.first.axes.values.first; + crossAxis = allAxes.values.first.axes.values.last; + } else { + mainAxis = allAxes.values.first.axes.values.last; + crossAxis = allAxes.values.first.axes.values.first; + } + + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final Offset globalPosition = renderBox.localToGlobal(event.localPosition); + + Widget tooltip = getTooltip( + event: event, + mainAxis: mainAxis, + crossAxis: crossAxis, + bin: bin, + ); + + tooltipManager.showTooltip( + position: globalPosition, + content: tooltip, + ); + } } diff --git a/lib/src/ui/charts/histogram.dart b/lib/src/ui/charts/histogram.dart index cfe5c28..2f7fe11 100644 --- a/lib/src/ui/charts/histogram.dart +++ b/lib/src/ui/charts/histogram.dart @@ -19,7 +19,6 @@ /// You should have received a copy of the GNU General Public License /// along with this program. If not, see . -import 'dart:developer' as developer; import 'dart:async'; import 'dart:math' as math; @@ -34,6 +33,7 @@ import 'package:rubin_chart/src/ui/chart.dart'; import 'package:rubin_chart/src/ui/charts/cartesian.dart'; import 'package:rubin_chart/src/utils/utils.dart'; import 'package:rubin_chart/src/ui/selection_controller.dart'; +import 'package:rubin_chart/src/ui/chart_tooltip.dart'; /// A single bin in a histogram. class HistogramBin extends BinnedData { @@ -435,6 +435,43 @@ class HistogramState extends BinnedChartState { } } + @override + SeriesList get seriesList => SeriesList( + widget.info.allSeries, + widget.info.colorCycle ?? widget.info.theme.colorCycle, + ); + + @override + void showTooltip({ + required PointerHoverEvent event, + required BinnedData bin, + }) { + ChartAxis mainAxis; + ChartAxis crossAxis; + if (mainAxisAlignment == AxisOrientation.horizontal) { + mainAxis = allAxes.values.first.axes.values.first; + crossAxis = allAxes.values.first.axes.values.last; + } else { + mainAxis = allAxes.values.first.axes.values.last; + crossAxis = allAxes.values.first.axes.values.first; + } + + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final Offset globalPosition = renderBox.localToGlobal(event.localPosition); + + Widget tooltip = getTooltip( + event: event, + mainAxis: mainAxis, + crossAxis: crossAxis, + bin: bin, + ); + + tooltipManager.showTooltip( + position: globalPosition, + content: tooltip, + ); + } + @override Widget getTooltip({ required PointerHoverEvent event, @@ -444,25 +481,15 @@ class HistogramState extends BinnedChartState { }) { HistogramBin histogramBin = bin as HistogramBin; - return AbsorbPointer( - absorbing: false, // Allows pointer events to pass through - child: Container( - decoration: BoxDecoration( - color: Colors.white.withAlpha(250), - border: Border.all(color: Colors.grey[200]!), - borderRadius: BorderRadius.circular(5), - ), - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("${mainAxis.info.label}: ", style: const TextStyle(fontWeight: FontWeight.bold)), - Text( - "Range: ${histogramBin.mainStart.toStringAsFixed(3)} - ${histogramBin.mainEnd.toStringAsFixed(3)}"), - Text("Count: ${histogramBin.count}"), - ], + return ChartTooltip( + title: mainAxis.info.label, + entries: [ + TooltipEntry( + label: "Range", + value: "${histogramBin.mainStart.toStringAsFixed(3)} - ${histogramBin.mainEnd.toStringAsFixed(3)}", ), - ), + TooltipEntry(label: "Count", value: "${histogramBin.count}"), + ], ); } } diff --git a/lib/src/ui/charts/scatter.dart b/lib/src/ui/charts/scatter.dart index 5590f56..85459ae 100644 --- a/lib/src/ui/charts/scatter.dart +++ b/lib/src/ui/charts/scatter.dart @@ -36,6 +36,7 @@ import 'package:rubin_chart/src/models/series.dart'; import 'package:rubin_chart/src/theme/theme.dart'; import 'package:rubin_chart/src/ui/axis_painter.dart'; import 'package:rubin_chart/src/ui/chart.dart'; +import 'package:rubin_chart/src/ui/chart_tooltip.dart'; import 'package:rubin_chart/src/ui/series_painter.dart'; import 'package:rubin_chart/src/utils/quadtree.dart'; import 'package:rubin_chart/src/utils/utils.dart'; @@ -167,14 +168,8 @@ class ScatterPlotState extends State with ChartMixin, Scrollable2DC /// Quadtree for the bottom left axes. final Map> _quadTrees = {}; - /// The tooltip overlay. - OverlayEntry? hoverOverlay; - - /// Timer to keep track of whether or not the cursor is hovering over a point. - Timer? _hoverTimer; - - /// Whether or not the cursor is hovering over a point. - bool _isHovering = false; + /// Manager for chart tooltips + late ChartTooltipManager _tooltipManager; final Map _seriesKeys = {}; final Map _seriesPainters = {}; @@ -202,69 +197,111 @@ class ScatterPlotState extends State with ChartMixin, Scrollable2DC /// during drag operations without sending updates to the selection controller. Set _dragSelectedPoints = {}; - /// Clear the timer and all other hover data. - void _clearHover() { - _hoverTimer?.cancel(); - _hoverTimer = null; - _isHovering = false; - hoverOverlay?.remove(); - hoverOverlay = null; - setState(() {}); + @override + void initState() { + super.initState(); + _tooltipManager = ChartTooltipManager( + getOverlay: () => Overlay.of(context), + hoverDelay: const Duration(milliseconds: 1000), + ); + + // Add key detector + focusNode.addListener(focusNodeListener); + + // Add the axis controllers to the list of controllers + axisControllers.addAll(widget.axisControllers.values); + + // Initialize selection controller + if (widget.selectionController != null) { + widget.selectionController!.subscribe(widget.info.id, _onSelectionUpdate); + + // Check for existing selection + final existingSelection = widget.selectionController!.selectedDataPoints; + + if (existingSelection.isNotEmpty) { + selectedDataPoints = Set.from(existingSelection); + } + } + + //Initialize drill down controller + if (widget.drillDownController != null) { + widget.drillDownController!.subscribe(widget.info.id, _onDrillDownUpdate); + } + + // Initialize the reset controller + if (widget.resetController != null) { + widget.resetController!.stream.listen((event) { + if (event.type == ChartResetTypes.full) { + _axes.clear(); + _initializeAxes(); + _initializeQuadTree(); + onAxesUpdate(); + } else if (event.type == ChartResetTypes.repaint) { + onAxesUpdate(); + } + setState(() {}); + }); + } + + // Initialize the axes + _initializeAxes(); + + // Initialize the quadtrees + _initializeQuadTree(); } - /// Notify the user that the cursor is no longer over the chart. - void onHoverEnd(PointerExitEvent event) { - if (widget.onCoordinateUpdate != null) { - widget.onCoordinateUpdate!({}); + @override + void dispose() { + _tooltipManager.dispose(); + + final chartId = widget.info.id; + // Remove the key detector + focusNode.removeListener(focusNodeListener); + + // Remove the selection controller + if (widget.selectionController != null) { + widget.selectionController!.unsubscribe(chartId); + } + + // Remove the drill down controller + if (widget.drillDownController != null) { + widget.drillDownController!.unsubscribe(chartId); + } + + // Remove the reset controller + if (widget.resetController != null) { + widget.resetController!.close(); } + + super.dispose(); } /// Create a tooltip when the cursor is hovering over a point. void onHoverStart({required PointerHoverEvent event, required Map data}) { - Widget tooltip = getTooltip( - data: data, - ); + Widget tooltip = getTooltip(data: data); final RenderBox renderBox = context.findRenderObject() as RenderBox; final Offset globalPosition = renderBox.localToGlobal(event.localPosition); - hoverOverlay = OverlayEntry( - builder: (context) { - return Positioned( - left: globalPosition.dx + 15, - top: globalPosition.dy + 15, - child: Material( - color: Colors.transparent, - child: MouseRegion( - onHover: (PointerHoverEvent event) { - _clearHover(); - }, - child: tooltip, - ), - ), - ); - }, + _tooltipManager.showTooltip( + position: globalPosition, + content: tooltip, ); - - Overlay.of(context).insert(hoverOverlay!); } /// Build the tooltip. Widget getTooltip({required Map data}) { - List tooltipData = []; + List entries = []; for (MapEntry entry in data.entries) { - tooltipData.add(Text("${entry.key}: ${entry.value.toStringAsFixed(3)}")); + entries.add(TooltipEntry( + label: entry.key.toString(), + value: entry.value.toStringAsFixed(3), + )); } - return Container( - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey[200]!), - borderRadius: BorderRadius.circular(5), - ), - child: Column( - children: tooltipData, - ), + return ChartTooltip( + title: "Data Point", + entries: entries, ); } @@ -444,78 +481,6 @@ class ScatterPlotState extends State with ChartMixin, Scrollable2DC } } - @override - void dispose() { - final chartId = widget.info.id; - // Remove the key detector - focusNode.removeListener(focusNodeListener); - - // Remove the selection controller - if (widget.selectionController != null) { - widget.selectionController!.unsubscribe(chartId); - } - - // Remove the drill down controller - if (widget.drillDownController != null) { - widget.drillDownController!.unsubscribe(chartId); - } - - // Remove the reset controller - if (widget.resetController != null) { - widget.resetController!.close(); - } - - super.dispose(); - } - - @override - void initState() { - super.initState(); - // Add key detector - focusNode.addListener(focusNodeListener); - - // Add the axis controllers to the list of controllers - axisControllers.addAll(widget.axisControllers.values); - - // Initialize selection controller - if (widget.selectionController != null) { - widget.selectionController!.subscribe(widget.info.id, _onSelectionUpdate); - - // Check for existing selection - final existingSelection = widget.selectionController!.selectedDataPoints; - - if (existingSelection.isNotEmpty) { - selectedDataPoints = Set.from(existingSelection); - } - } - - //Initialize drill down controller - if (widget.drillDownController != null) { - widget.drillDownController!.subscribe(widget.info.id, _onDrillDownUpdate); - } - - // Initialize the reset controller - if (widget.resetController != null) { - widget.resetController!.stream.listen((event) { - if (event.type == ChartResetTypes.full) { - _axes.clear(); - _initializeAxes(); - _initializeQuadTree(); - onAxesUpdate(); - } else if (event.type == ChartResetTypes.repaint) { - onAxesUpdate(); - } - setState(() {}); - }); - } - - // Initialize the axes - _initializeAxes(); - - // Initialize the quadtrees - _initializeQuadTree(); - } - @override void didUpdateWidget(ScatterPlot oldWidget) { super.didUpdateWidget(oldWidget); @@ -675,9 +640,9 @@ class ScatterPlotState extends State with ChartMixin, Scrollable2DC }, child: MouseRegion( onExit: (PointerExitEvent event) { - if (!_isHovering) { - _clearHover(); - onHoverEnd(event); + _tooltipManager.onHoverExit(); + if (widget.onCoordinateUpdate != null) { + widget.onCoordinateUpdate!({}); } }, onHover: (PointerHoverEvent event) { @@ -685,46 +650,40 @@ class ScatterPlotState extends State with ChartMixin, Scrollable2DC // so we need to keep track of the hover timer manually. // Restart the hover timer - _hoverTimer?.cancel(); - _hoverTimer = Timer(const Duration(milliseconds: 1000), () { - // See if the cursor is hovering over a point - HoverDataPoint? hoverDataPoint = _onTapUp(event.localPosition, axisPainter, true); - if (hoverDataPoint == null) { - _clearHover(); - return; - } + _tooltipManager.restartHoverTimer(); + if (_tooltipManager.isHovering) { + _tooltipManager.clearTooltip(); + } + _tooltipManager.onHoverExit(); // Reset hovering state on move - // Find the full series data for the point that was hovered over - Series? hoverSeries; - for (Series series in widget.info.allSeries) { - if (series.axesId == hoverDataPoint.chartAxesId) { - hoverSeries = series; - break; - } - } - if (hoverSeries == null) { - // This should never happen - throw Exception("No series found for axes ${hoverDataPoint.chartAxesId}"); - } - // Extract the series data for the hovered point - SeriesData seriesData = hoverSeries.data; - Map tooltipData = {}; - for (Object column in seriesData.plotColumns.values) { - tooltipData[column] = seriesData.data[column]![hoverDataPoint.dataId]; - } + // See if the cursor is hovering over a point + HoverDataPoint? hoverDataPoint = _onTapUp(event.localPosition, axisPainter, true); + if (hoverDataPoint == null) { + return; + } - // Call the hover start function. - onHoverStart(event: event, data: tooltipData); - _hoverTimer?.cancel(); - _hoverTimer = null; - _isHovering = true; - setState(() {}); - }); + // Find the full series data for the point that was hovered over + Series? hoverSeries; + for (Series series in widget.info.allSeries) { + if (series.axesId == hoverDataPoint.chartAxesId) { + hoverSeries = series; + break; + } + } + if (hoverSeries == null) { + throw Exception("No series found for axes ${hoverDataPoint.chartAxesId}"); + } - if (_isHovering) { - _clearHover(); + // Extract the series data for the hovered point + SeriesData seriesData = hoverSeries.data; + Map tooltipData = {}; + for (Object column in seriesData.plotColumns.values) { + tooltipData[column] = seriesData.data[column]![hoverDataPoint.dataId]; } - _isHovering = false; + + // Call the hover start function with the tooltip manager + onHoverStart(event: event, data: tooltipData); + _tooltipManager.onHoverEnter(); if (widget.onCoordinateUpdate != null) { Map coordinates = {};