Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/rubin_chart.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
45 changes: 35 additions & 10 deletions lib/src/models/axes/axis.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,44 @@ class AxisId {
String toString() => "AxisId($location, $axesId)";

/// Convert the [AxisId] to a JSON object.
Map<String, dynamic> 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<String, dynamic> 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<String> 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<String, dynamic>) {
// 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<String, dynamic>, got ${json.runtimeType}");
}
}
}

Expand Down
144 changes: 31 additions & 113 deletions lib/src/models/binned.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -333,14 +334,8 @@ abstract class BinnedChartState<T extends BinnedChart> extends State<T>
/// 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
Expand All @@ -365,12 +360,6 @@ abstract class BinnedChartState<T extends BinnedChart> extends State<T>

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;
Expand Down Expand Up @@ -425,15 +414,7 @@ abstract class BinnedChartState<T extends BinnedChart> extends State<T>

@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);
Expand All @@ -449,6 +430,11 @@ abstract class BinnedChartState<T extends BinnedChart> extends State<T>
void initState() {
super.initState();

// Initialize tooltip manager
tooltipManager = ChartTooltipManager(
getOverlay: () => Overlay.of(context),
);

// Add key detector
focusNode.addListener(focusNodeListener);

Expand Down Expand Up @@ -480,74 +466,22 @@ abstract class BinnedChartState<T extends BinnedChart> extends State<T>
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,
required ChartAxis crossAxis,
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
Expand Down Expand Up @@ -607,31 +541,22 @@ abstract class BinnedChartState<T extends BinnedChart> extends State<T>
}
},
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,
Expand All @@ -645,17 +570,10 @@ abstract class BinnedChartState<T extends BinnedChart> extends State<T>
));
}

/// 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);
}
Expand Down
Loading