diff --git a/example/lib/main.dart b/example/lib/main.dart index f9824b1..a955866 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:graphic_example/pages/crosshair.dart'; import 'home.dart'; import 'pages/bigdata.dart'; @@ -20,6 +21,7 @@ final routes = { '/examples/Animation': (context) => const AnimationPage(), '/examples/Bigdata': (context) => BigdataPage(), '/examples/Echarts': (context) => EchartsPage(), + '/examples/Crosshair': (context) => const CrosshairPage(), '/examples/Debug': (context) => DebugPage(), }; diff --git a/example/lib/pages/crosshair.dart b/example/lib/pages/crosshair.dart new file mode 100644 index 0000000..4128199 --- /dev/null +++ b/example/lib/pages/crosshair.dart @@ -0,0 +1,343 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:graphic/graphic.dart'; + +import '../data.dart'; + +class CrosshairPage extends StatefulWidget { + const CrosshairPage({Key? key}) : super(key: key); + + @override + CrosshairPageState createState() => CrosshairPageState(); +} + +class CrosshairPageState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + final priceVolumeStream = StreamController.broadcast(); + + static const _labelPadding = 6.0; + + /// Price parameters + + final List _showCrosshairOnPrice = [ + PaintStyle(strokeColor: Colors.black), + PaintStyle(strokeColor: Colors.black), + ]; + + final List _showLabelOnPrice = [false, false]; + + final List _followPointerOnPrice = [false, false]; + + final List _labelPaddingOnPrice = [0.0, 0.0]; + + /// Volume parameters + + final List _showCrosshairOnVolume = [ + PaintStyle(strokeColor: Colors.black), + PaintStyle(strokeColor: Colors.black), + ]; + + final List _showLabelOnVolume = [false, false]; + + final List _followPointerOnVolume = [false, false]; + + final List _labelPaddingOnVolume = [0.0, 0.0]; + + @override + Widget build(BuildContext context) { + final crosshairPaintStyle = PaintStyle(strokeColor: Colors.black); + + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + title: const Text('Crosshair'), + ), + backgroundColor: Colors.white, + body: Stack( + children: [ + SingleChildScrollView( + child: Center( + child: Column( + children: [ + const SizedBox(height: 300), + Container( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SwitchPair( + title: '• show crosshair on price', + valueX: _showCrosshairOnPrice[0] != null, + onChangedX: (isOn) => setState(() => + _showCrosshairOnPrice[0] = + isOn ? crosshairPaintStyle : null), + valueY: _showCrosshairOnPrice[1] != null, + onChangedY: (isOn) => setState(() => + _showCrosshairOnPrice[1] = + isOn ? crosshairPaintStyle : null), + ), + _SwitchPair( + title: '• show crosshair on volume', + valueX: _showCrosshairOnVolume[0] != null, + onChangedX: (isOn) => setState(() => + _showCrosshairOnVolume[0] = + isOn ? crosshairPaintStyle : null), + valueY: _showCrosshairOnVolume[1] != null, + onChangedY: (isOn) => setState(() => + _showCrosshairOnVolume[1] = + isOn ? crosshairPaintStyle : null), + ), + const Divider(), + _SwitchPair( + title: '• follow pointer on price', + valueX: _followPointerOnPrice[0], + onChangedX: (isOn) => + setState(() => _followPointerOnPrice[0] = isOn), + valueY: _followPointerOnPrice[1], + onChangedY: (isOn) => + setState(() => _followPointerOnPrice[1] = isOn), + ), + _SwitchPair( + title: '• follow pointer on volume', + valueX: _followPointerOnVolume[0], + onChangedX: (isOn) => + setState(() => _followPointerOnVolume[0] = isOn), + valueY: _followPointerOnVolume[1], + onChangedY: (isOn) => + setState(() => _followPointerOnVolume[1] = isOn), + ), + const Divider(), + _SwitchPair( + title: '• show label on price', + valueX: _showLabelOnPrice[0], + onChangedX: (isOn) => + setState(() => _showLabelOnPrice[0] = isOn), + valueY: _showLabelOnPrice[1], + onChangedY: (isOn) => + setState(() => _showLabelOnPrice[1] = isOn), + ), + _SwitchPair( + title: '• show label on volume', + valueX: _showLabelOnVolume[0], + onChangedX: (isOn) => + setState(() => _showLabelOnVolume[0] = isOn), + valueY: _showLabelOnVolume[1], + onChangedY: (isOn) => + setState(() => _showLabelOnVolume[1] = isOn), + ), + const Divider(), + _SwitchPair( + title: '• show label padding on price', + valueX: _labelPaddingOnPrice[0] != 0.0, + onChangedX: (isOn) => setState(() => + _labelPaddingOnPrice[0] = + isOn ? _labelPadding : 0.0), + valueY: _labelPaddingOnPrice[1] != 0.0, + onChangedY: (isOn) => setState(() => + _labelPaddingOnPrice[1] = + isOn ? _labelPadding : 0.0), + ), + _SwitchPair( + title: '• show label padding on volume', + valueX: _labelPaddingOnVolume[0] != 0.0, + onChangedX: (isOn) => setState(() => + _labelPaddingOnVolume[0] = + isOn ? _labelPadding : 0.0), + valueY: _labelPaddingOnVolume[1] != 0.0, + onChangedY: (isOn) => setState(() => + _labelPaddingOnVolume[1] = + isOn ? _labelPadding : 0.0), + ), + ], + ), + ), + ], + ), + ), + ), + ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(20, 40, 20, 5), + child: const Text( + 'Demo crosshair feature', + style: TextStyle(fontSize: 20), + ), + ), + Container( + padding: const EdgeInsets.only(top: 10), + color: Colors.white, + width: 350, + height: 150, + child: Chart( + padding: (_) => const EdgeInsets.fromLTRB(40, 5, 10, 0), + rebuild: false, + data: priceVolumeData, + variables: { + 'time': Variable( + accessor: (Map map) => map['time'] as String, + scale: OrdinalScale(tickCount: 3), + ), + 'end': Variable( + accessor: (Map map) => map['end'] as num, + scale: LinearScale(min: 5, tickCount: 5), + ), + }, + marks: [ + LineMark( + size: SizeEncode(value: 1), + ) + ], + axes: [ + Defaults.horizontalAxis + ..label = null + ..line = null, + Defaults.verticalAxis + ..gridMapper = (_, index, __) => + index == 0 ? null : Defaults.strokeStyle, + ], + selections: { + 'touchMove': PointSelection( + on: { + GestureType.scaleUpdate, + GestureType.tapDown, + GestureType.longPressMoveUpdate + }, + dim: Dim.x, + ) + }, + crosshair: CrosshairGuide( + labelPaddings: _labelPaddingOnPrice, + showLabel: _showLabelOnPrice, + followPointer: _followPointerOnPrice, + styles: _showCrosshairOnPrice, + ), + gestureStream: priceVolumeStream, + ), + ), + Container( + margin: const EdgeInsets.only(top: 0), + width: 350, + height: 80, + color: Colors.white, + child: Chart( + padding: (_) => const EdgeInsets.fromLTRB(40, 0, 10, 20), + rebuild: false, + data: priceVolumeData, + variables: { + 'time': Variable( + accessor: (Map map) => map['time'] as String, + scale: OrdinalScale(tickCount: 3), + ), + 'volume': Variable( + accessor: (Map map) => map['volume'] as num, + scale: LinearScale(min: 0), + ), + }, + marks: [ + IntervalMark( + size: SizeEncode(value: 1), + ) + ], + axes: [ + Defaults.horizontalAxis, + ], + selections: { + 'touchMove': PointSelection( + on: { + GestureType.scaleUpdate, + GestureType.tapDown, + GestureType.longPressMoveUpdate + }, + dim: Dim.x, + ) + }, + crosshair: CrosshairGuide( + labelPaddings: _labelPaddingOnVolume, + showLabel: _showLabelOnVolume, + followPointer: _followPointerOnVolume, + styles: _showCrosshairOnVolume, + ), + gestureStream: priceVolumeStream, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _SwitchPair extends StatelessWidget { + const _SwitchPair({ + required this.title, + required this.valueX, + required this.onChangedX, + required this.valueY, + required this.onChangedY, + }); + + final String title; + final bool valueX; + final void Function(bool) onChangedX; + final bool valueY; + final void Function(bool) onChangedY; + + @override + Widget build(BuildContext context) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + title, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _SwitchRow( + text: 'on X-axis', + value: valueX, + onChanged: onChangedX, + ), + _SwitchRow( + text: 'on Y-axis', + value: valueY, + onChanged: onChangedY, + ), + ], + ), + ]); + } +} + +class _SwitchRow extends StatelessWidget { + const _SwitchRow( + {required this.text, required this.value, required this.onChanged}); + + final String text; + final bool value; + final void Function(bool) onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + text, + ), + Transform.scale( + scale: 0.8, + child: Switch.adaptive( + value: value, + onChanged: onChanged, + ), + ), + ], + ); + } +} diff --git a/lib/src/guide/interaction/crosshair.dart b/lib/src/guide/interaction/crosshair.dart index 3469e0c..715dd0b 100644 --- a/lib/src/guide/interaction/crosshair.dart +++ b/lib/src/guide/interaction/crosshair.dart @@ -29,6 +29,7 @@ class CrosshairGuide { this.styles, this.labelStyles, this.labelBackgroundStyles, + this.labelPaddings, this.showLabel, this.formatter, this.followPointer, @@ -58,6 +59,9 @@ class CrosshairGuide { /// The labelBackground styles of crosshair lines for each dimension. List? labelBackgroundStyles; + /// The padding between label and axis. + List? labelPaddings; + /// Whether to show label on axis. /// /// If null, a default `[false, false]` is set. @@ -105,6 +109,7 @@ class CrosshairGuide { deepCollectionEquals(labelStyles, other.labelStyles) && deepCollectionEquals( labelBackgroundStyles, other.labelBackgroundStyles) && + deepCollectionEquals(labelPaddings, other.labelPaddings) && deepCollectionEquals(showLabel, other.showLabel) && deepCollectionEquals(followPointer, other.followPointer) && deepCollectionEquals(expandEdges, other.expandEdges) && @@ -132,6 +137,7 @@ class CrosshairRenderOp extends Render { final labelStyles = params['labelStyles'] as List; final labelBackgroundStyles = params['labelBackgroundStyles'] as List; + final labelPaddings = params['labelPaddings'] as List; final showLabel = params['showLabel'] as List; final formatter = params['formatter'] as List; final followPointer = params['followPointer'] as List; @@ -193,6 +199,10 @@ class CrosshairRenderOp extends Render { coord.transposed ? labelBackgroundStyles[1] : labelBackgroundStyles[0]; final labelBackgroundStyleY = coord.transposed ? labelBackgroundStyles[0] : labelBackgroundStyles[1]; + final labelPaddingX = + coord.transposed ? labelPaddings[1] : labelPaddings[0]; + final labelPaddingY = + coord.transposed ? labelPaddings[0] : labelPaddings[1]; final fields = scales.keys.toList(); final selectedTupleList = selectedTuples.values; final tuple = selectedTupleList.last; @@ -204,7 +214,7 @@ class CrosshairRenderOp extends Render { max(min(canvasCross.dx, region.right), region.left); double startY = region.top; - double endY = region.bottom; + double endY = region.bottom + labelPaddingX; if (expandEdges[1]) startY -= padding(size).top; if (expandEdges[3]) endY += padding(size).bottom; @@ -234,7 +244,7 @@ class CrosshairRenderOp extends Render { final label = LabelElement( text: text, - anchor: Offset(posX, region.bottom + rect.height / 2), + anchor: Offset(posX, endY + rect.height / 2), style: labelStyleX, ); @@ -252,7 +262,7 @@ class CrosshairRenderOp extends Render { final canvasCrossY = max(min(canvasCross.dy, region.bottom), region.top); - double startX = region.left; + double startX = region.left - labelPaddingY; double endX = region.right; if (expandEdges[0]) startX -= padding(size).left; if (expandEdges[2]) endX += padding(size).right; diff --git a/lib/src/parse/parse.dart b/lib/src/parse/parse.dart index 368ebee..88bf424 100644 --- a/lib/src/parse/parse.dart +++ b/lib/src/parse/parse.dart @@ -684,6 +684,7 @@ void parse( PaintStyle(fillColor: const Color(0xff000000)), PaintStyle(fillColor: const Color(0xff000000)), ], + 'labelPaddings': crosshairSpec.labelPaddings ?? [0.0, 0.0], 'showLabel': showLabel, 'formatter': crosshairSpec.formatter ?? [null, null], 'followPointer': crosshairSpec.followPointer ?? [false, false],