From 120a6122c6fe339b0cb31b789fd1a6f781b43619 Mon Sep 17 00:00:00 2001 From: entronad Date: Wed, 1 Dec 2021 21:41:34 +0800 Subject: [PATCH] v0.5.1 --- CHANGELOG.md | 10 + DEVLOG.md | 18 +- example/lib/data.dart | 91 +++++++ example/lib/main.dart | 2 + example/lib/pages/custom.dart | 38 ++- example/lib/pages/debug.dart | 224 ++++++++++++++++++ example/lib/pages/line_area.dart | 140 ++++++++++- example/lib/pages/polar_interval.dart | 3 +- example/lib/pages/rectangle_interval.dart | 3 +- lib/src/algebra/varset.dart | 65 ++--- lib/src/chart/view.dart | 10 - lib/src/common/operators/render.dart | 11 - lib/src/dataflow/tuple.dart | 2 +- lib/src/geom/element.dart | 8 +- lib/src/guide/interaction/crosshair.dart | 31 ++- lib/src/guide/interaction/tooltip.dart | 97 +++++--- lib/src/interaction/gesture.dart | 4 +- lib/src/interaction/selection/interval.dart | 21 +- lib/src/interaction/selection/point.dart | 6 +- lib/src/interaction/selection/selection.dart | 235 +++++++++++-------- lib/src/interaction/signal.dart | 3 +- lib/src/parse/parse.dart | 105 +++++---- lib/src/scale/scale.dart | 4 +- lib/src/util/list.dart | 17 +- lib/src/variable/transform/proportion.dart | 4 +- pubspec.yaml | 2 +- 26 files changed, 871 insertions(+), 283 deletions(-) create mode 100644 example/lib/pages/debug.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9883b55..995511d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.5.1 + +**2021-12-01** + +- Tooltip constraints. +- Now selections triggerd with same gesture is allowd. +- Device settings of selection. +- Now all z indexes are static, thus no need to resort scenes. +- Some updates above are inspired by https://github.com/entronad/graphic/issues/27. + ## 0.5.0 **2021-11-18** diff --git a/DEVLOG.md b/DEVLOG.md index d8520d7..91b088e 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -3864,9 +3864,25 @@ time就不要引入 intl库支持mask了吧,那样要引入额外的规则和 所有spec中函数类型的property,还是都按名词命名吧,因为用户关注的它是什么(出自 Effectiv Dart) +selection 同时唯一性: + +现在的逻辑是这样的:同时只有一个 gesture -> 现在规定一个gesture只能定义一个selection -> 所以自然产生对于同一个element同时只会发生一个selection + +但是对于同一个element同时只会发生一个selection应当是一个额外的规定,同一个gesture应该可以定义多个 selection,同一时刻产生多个selects(用map存储),而在update中判断onSelection不能定义有同时发生的selection + +对应 element update,最重要的目的是,同一时间只能有一个selection起效,当只触发一个selection时,OK,当触发多个selection时,必须确保只有其中的一个在defined names中 + +selection定义中添加一个仅在某些设备上运行的开关(signal中可在实际Gesture signal detail中判断设备) + +tooltip添加一个自动往里挤的功能,可开关 + +现在新的selector渲染机制也不需要动态zIndex了,决定把改机制去了,zIndex都是静态的。 + +默认值应当尽量在parse中设置,而不在op中处理 + ## TODO -整合errorlog,需处理:throw, assert, list.single +整合errorlog,需处理:throw, assert, list.single,singleIntersection group selection diff --git a/example/lib/data.dart b/example/lib/data.dart index 719d899..59a7f7e 100644 --- a/example/lib/data.dart +++ b/example/lib/data.dart @@ -6,6 +6,97 @@ const basicData = [ {'genre': 'Other', 'sold': 150}, ]; +const complexGroupData = [ + {'date': '2021-10-01', 'name': 'Liam', 'points': 1468}, + {'date': '2021-10-01', 'name': 'Oliver', 'points': 1487}, + {'date': '2021-10-01', 'name': 'Elijah', 'points': 1494}, + {'date': '2021-10-02', 'name': 'Liam', 'points': 1526}, + {'date': '2021-10-02', 'name': 'Noah', 'points': 1492}, + {'date': '2021-10-02', 'name': 'Oliver', 'points': 1470}, + {'date': '2021-10-02', 'name': 'Elijah', 'points': 1477}, + {'date': '2021-10-03', 'name': 'Liam', 'points': 1466}, + {'date': '2021-10-03', 'name': 'Noah', 'points': 1465}, + {'date': '2021-10-03', 'name': 'Oliver', 'points': 1524}, + {'date': '2021-10-03', 'name': 'Elijah', 'points': 1534}, + {'date': '2021-10-04', 'name': 'Noah', 'points': 1504}, + {'date': '2021-10-04', 'name': 'Elijah', 'points': 1524}, + {'date': '2021-10-05', 'name': 'Oliver', 'points': 1534}, + {'date': '2021-10-06', 'name': 'Noah', 'points': 1463}, + {'date': '2021-10-07', 'name': 'Liam', 'points': 1502}, + {'date': '2021-10-07', 'name': 'Noah', 'points': 1539}, + {'date': '2021-10-08', 'name': 'Liam', 'points': 1476}, + {'date': '2021-10-08', 'name': 'Noah', 'points': 1483}, + {'date': '2021-10-08', 'name': 'Oliver', 'points': 1534}, + {'date': '2021-10-08', 'name': 'Elijah', 'points': 1530}, + {'date': '2021-10-09', 'name': 'Noah', 'points': 1519}, + {'date': '2021-10-09', 'name': 'Oliver', 'points': 1497}, + {'date': '2021-10-09', 'name': 'Elijah', 'points': 1460}, + {'date': '2021-10-10', 'name': 'Liam', 'points': 1514}, + {'date': '2021-10-10', 'name': 'Noah', 'points': 1518}, + {'date': '2021-10-10', 'name': 'Oliver', 'points': 1470}, + {'date': '2021-10-10', 'name': 'Elijah', 'points': 1526}, + {'date': '2021-10-11', 'name': 'Liam', 'points': 1517}, + {'date': '2021-10-11', 'name': 'Noah', 'points': 1478}, + {'date': '2021-10-11', 'name': 'Oliver', 'points': 1468}, + {'date': '2021-10-11', 'name': 'Elijah', 'points': 1487}, + {'date': '2021-10-12', 'name': 'Liam', 'points': 1535}, + {'date': '2021-10-12', 'name': 'Noah', 'points': 1537}, + {'date': '2021-10-12', 'name': 'Oliver', 'points': 1463}, + {'date': '2021-10-12', 'name': 'Elijah', 'points': 1478}, + {'date': '2021-10-13', 'name': 'Oliver', 'points': 1524}, + {'date': '2021-10-13', 'name': 'Elijah', 'points': 1496}, + {'date': '2021-10-14', 'name': 'Liam', 'points': 1527}, + {'date': '2021-10-14', 'name': 'Oliver', 'points': 1527}, + {'date': '2021-10-14', 'name': 'Elijah', 'points': 1462}, + {'date': '2021-10-15', 'name': 'Liam', 'points': 1532}, + {'date': '2021-10-15', 'name': 'Noah', 'points': 1509}, + {'date': '2021-10-15', 'name': 'Oliver', 'points': 1540}, + {'date': '2021-10-15', 'name': 'Elijah', 'points': 1536}, + {'date': '2021-10-16', 'name': 'Liam', 'points': 1480}, + {'date': '2021-10-16', 'name': 'Elijah', 'points': 1533}, + {'date': '2021-10-17', 'name': 'Noah', 'points': 1515}, + {'date': '2021-10-17', 'name': 'Oliver', 'points': 1518}, + {'date': '2021-10-17', 'name': 'Elijah', 'points': 1515}, + {'date': '2021-10-18', 'name': 'Oliver', 'points': 1489}, + {'date': '2021-10-18', 'name': 'Elijah', 'points': 1518}, + {'date': '2021-10-19', 'name': 'Oliver', 'points': 1472}, + {'date': '2021-10-19', 'name': 'Elijah', 'points': 1473}, + {'date': '2021-10-20', 'name': 'Liam', 'points': 1513}, + {'date': '2021-10-20', 'name': 'Noah', 'points': 1533}, + {'date': '2021-10-20', 'name': 'Oliver', 'points': 1487}, + {'date': '2021-10-20', 'name': 'Elijah', 'points': 1532}, + {'date': '2021-10-21', 'name': 'Liam', 'points': 1497}, + {'date': '2021-10-21', 'name': 'Noah', 'points': 1477}, + {'date': '2021-10-21', 'name': 'Oliver', 'points': 1516}, + {'date': '2021-10-22', 'name': 'Liam', 'points': 1466}, + {'date': '2021-10-22', 'name': 'Noah', 'points': 1476}, + {'date': '2021-10-22', 'name': 'Oliver', 'points': 1536}, + {'date': '2021-10-22', 'name': 'Elijah', 'points': 1483}, + {'date': '2021-10-23', 'name': 'Liam', 'points': 1503}, + {'date': '2021-10-23', 'name': 'Oliver', 'points': 1521}, + {'date': '2021-10-23', 'name': 'Elijah', 'points': 1529}, + {'date': '2021-10-24', 'name': 'Liam', 'points': 1460}, + {'date': '2021-10-24', 'name': 'Noah', 'points': 1532}, + {'date': '2021-10-24', 'name': 'Oliver', 'points': 1477}, + {'date': '2021-10-24', 'name': 'Elijah', 'points': 1470}, + {'date': '2021-10-25', 'name': 'Noah', 'points': 1504}, + {'date': '2021-10-25', 'name': 'Oliver', 'points': 1494}, + {'date': '2021-10-25', 'name': 'Elijah', 'points': 1528}, + {'date': '2021-10-26', 'name': 'Liam', 'points': 1517}, + {'date': '2021-10-26', 'name': 'Noah', 'points': 1503}, + {'date': '2021-10-26', 'name': 'Elijah', 'points': 1507}, + {'date': '2021-10-27', 'name': 'Liam', 'points': 1538}, + {'date': '2021-10-27', 'name': 'Noah', 'points': 1530}, + {'date': '2021-10-27', 'name': 'Oliver', 'points': 1496}, + {'date': '2021-10-27', 'name': 'Elijah', 'points': 1519}, + {'date': '2021-10-28', 'name': 'Liam', 'points': 1511}, + {'date': '2021-10-28', 'name': 'Oliver', 'points': 1500}, + {'date': '2021-10-28', 'name': 'Elijah', 'points': 1519}, + {'date': '2021-10-29', 'name': 'Noah', 'points': 1499}, + {'date': '2021-10-29', 'name': 'Oliver', 'points': 1489}, + {'date': '2021-10-30', 'name': 'Noah', 'points': 1460} +]; + class TimeSeriesSales { final DateTime time; final int sales; diff --git a/example/lib/main.dart b/example/lib/main.dart index 8e5cfad..a455d9f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,6 +8,7 @@ import 'pages/point.dart'; import 'pages/polygon.dart'; import 'pages/custom.dart'; import 'pages/bigdata.dart'; +// import 'pages/debug.dart'; final routes = { '/': (context) => const HomePage(), @@ -18,6 +19,7 @@ final routes = { '/examples/Polygon Element': (context) => PolygonPage(), '/examples/Custom': (context) => CustomPage(), '/examples/Bigdata': (context) => BigdataPage(), + // '/examples/Debug': (context) => DebugPage(), }; class MyApp extends StatelessWidget { diff --git a/example/lib/pages/custom.dart b/example/lib/pages/custom.dart index 45b47c3..c5931df 100644 --- a/example/lib/pages/custom.dart +++ b/example/lib/pages/custom.dart @@ -319,7 +319,8 @@ class CustomPage extends StatelessWidget { }, elements: [ IntervalElement( - position: Varset('index') * Varset('value') / Varset('type'), + position: + Varset('index') * Varset('value') / Varset('type'), color: ColorAttr( variable: 'type', values: Defaults.colors10), size: SizeAttr(value: 2), @@ -339,62 +340,77 @@ class CustomPage extends StatelessWidget { crosshair: CrosshairGuide(), annotations: [ MarkAnnotation( - relativePath: Path()..addRect(Rect.fromCircle(center: const Offset(0, 0), radius: 5)), + relativePath: Path() + ..addRect(Rect.fromCircle( + center: const Offset(0, 0), radius: 5)), style: Paint()..color = Defaults.colors10[0], anchor: (size) => const Offset(25, 290), ), TagAnnotation( label: Label( 'Email', - LabelStyle(Defaults.textStyle, align: Alignment.centerRight), + LabelStyle(Defaults.textStyle, + align: Alignment.centerRight), ), anchor: (size) => const Offset(34, 290), ), MarkAnnotation( - relativePath: Path()..addRect(Rect.fromCircle(center: const Offset(0, 0), radius: 5)), + relativePath: Path() + ..addRect(Rect.fromCircle( + center: const Offset(0, 0), radius: 5)), style: Paint()..color = Defaults.colors10[1], anchor: (size) => Offset(25 + size.width / 5, 290), ), TagAnnotation( label: Label( 'Affiliate', - LabelStyle(Defaults.textStyle, align: Alignment.centerRight), + LabelStyle(Defaults.textStyle, + align: Alignment.centerRight), ), anchor: (size) => Offset(34 + size.width / 5, 290), ), MarkAnnotation( - relativePath: Path()..addRect(Rect.fromCircle(center: const Offset(0, 0), radius: 5)), + relativePath: Path() + ..addRect(Rect.fromCircle( + center: const Offset(0, 0), radius: 5)), style: Paint()..color = Defaults.colors10[2], anchor: (size) => Offset(25 + size.width / 5 * 2, 290), ), TagAnnotation( label: Label( 'Video', - LabelStyle(Defaults.textStyle, align: Alignment.centerRight), + LabelStyle(Defaults.textStyle, + align: Alignment.centerRight), ), anchor: (size) => Offset(34 + size.width / 5 * 2, 290), ), MarkAnnotation( - relativePath: Path()..addRect(Rect.fromCircle(center: const Offset(0, 0), radius: 5)), + relativePath: Path() + ..addRect(Rect.fromCircle( + center: const Offset(0, 0), radius: 5)), style: Paint()..color = Defaults.colors10[3], anchor: (size) => Offset(25 + size.width / 5 * 3, 290), ), TagAnnotation( label: Label( 'Direct', - LabelStyle(Defaults.textStyle, align: Alignment.centerRight), + LabelStyle(Defaults.textStyle, + align: Alignment.centerRight), ), anchor: (size) => Offset(34 + size.width / 5 * 3, 290), ), MarkAnnotation( - relativePath: Path()..addRect(Rect.fromCircle(center: const Offset(0, 0), radius: 5)), + relativePath: Path() + ..addRect(Rect.fromCircle( + center: const Offset(0, 0), radius: 5)), style: Paint()..color = Defaults.colors10[4], anchor: (size) => Offset(25 + size.width / 5 * 4, 290), ), TagAnnotation( label: Label( 'Search', - LabelStyle(Defaults.textStyle, align: Alignment.centerRight), + LabelStyle(Defaults.textStyle, + align: Alignment.centerRight), ), anchor: (size) => Offset(34 + size.width / 5 * 4, 290), ), diff --git a/example/lib/pages/debug.dart b/example/lib/pages/debug.dart new file mode 100644 index 0000000..f718297 --- /dev/null +++ b/example/lib/pages/debug.dart @@ -0,0 +1,224 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:graphic/graphic.dart'; + +class DebugPage extends StatelessWidget { + DebugPage({Key? key}) : super(key: key); + + final GlobalKey _scaffoldKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + title: const Text('Debug'), + ), + backgroundColor: Colors.white, + body: SingleChildScrollView( + child: Center( + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 10), + width: 650, + height: 300, + child: Chart( + data: data, + variables: { + 'date': Variable( + accessor: (Map map) => map['date'] as String, + scale: OrdinalScale(tickCount: 5), + ), + 'points': Variable( + accessor: (Map map) => map['points'] as num, + ), + 'name': Variable( + accessor: (Map map) => map['name'] as String, + ), + }, + elements: [ + LineElement( + position: + Varset('date') * Varset('points') / Varset('name'), + shape: ShapeAttr(value: BasicLineShape(smooth: true)), + size: SizeAttr(value: 0.5), + color: ColorAttr( + variable: 'name', + values: Defaults.colors10, + onSelection: { + 'groupMouse': { + false: (color) => color.withAlpha(100) + }, + 'groupTouch': { + false: (color) => color.withAlpha(100) + }, + }, + ), + ), + PointElement( + color: ColorAttr( + variable: 'name', + values: Defaults.colors10, + onSelection: { + 'groupMouse': { + false: (color) => color.withAlpha(100) + }, + 'groupTouch': { + false: (color) => color.withAlpha(100) + }, + }, + ), + ), + ], + axes: [ + Defaults.horizontalAxis, + Defaults.verticalAxis, + ], + selections: { + 'tooltipMouse': PointSelection(on: { + GestureType.hover, + }, devices: { + PointerDeviceKind.mouse + }), + 'groupMouse': PointSelection( + on: { + GestureType.hover, + }, + variable: 'name', + devices: {PointerDeviceKind.mouse}), + 'tooltipTouch': PointSelection(on: { + GestureType.scaleUpdate, + GestureType.tapDown, + GestureType.longPressMoveUpdate + }, devices: { + PointerDeviceKind.touch + }), + 'groupTouch': PointSelection( + on: { + GestureType.scaleUpdate, + GestureType.tapDown, + GestureType.longPressMoveUpdate + }, + variable: 'name', + devices: {PointerDeviceKind.touch}), + }, + tooltip: TooltipGuide( + selections: {'tooltipTouch', 'tooltipMouse'}, + followPointer: [true, true], + align: Alignment.topLeft, + element: 0, + variables: [ + 'date', + 'name', + 'points', + ], + ), + crosshair: CrosshairGuide( + selections: {'tooltipTouch', 'tooltipMouse'}, + styles: [ + StrokeStyle(color: const Color(0xffbfbfbf)), + StrokeStyle(color: const Color(0x00bfbfbf)), + ], + followPointer: [true, false], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +var data = [ + {'date': '2021-10-01', 'name': 'Liam', 'points': 1468}, + {'date': '2021-10-01', 'name': 'Oliver', 'points': 1487}, + {'date': '2021-10-01', 'name': 'Elijah', 'points': 1494}, + {'date': '2021-10-02', 'name': 'Liam', 'points': 1526}, + {'date': '2021-10-02', 'name': 'Noah', 'points': 1492}, + {'date': '2021-10-02', 'name': 'Oliver', 'points': 1470}, + {'date': '2021-10-02', 'name': 'Elijah', 'points': 1477}, + {'date': '2021-10-03', 'name': 'Liam', 'points': 1466}, + {'date': '2021-10-03', 'name': 'Noah', 'points': 1465}, + {'date': '2021-10-03', 'name': 'Oliver', 'points': 1524}, + {'date': '2021-10-03', 'name': 'Elijah', 'points': 1534}, + {'date': '2021-10-04', 'name': 'Noah', 'points': 1504}, + {'date': '2021-10-04', 'name': 'Elijah', 'points': 1524}, + {'date': '2021-10-05', 'name': 'Oliver', 'points': 1534}, + {'date': '2021-10-06', 'name': 'Noah', 'points': 1463}, + {'date': '2021-10-07', 'name': 'Liam', 'points': 1502}, + {'date': '2021-10-07', 'name': 'Noah', 'points': 1539}, + {'date': '2021-10-08', 'name': 'Liam', 'points': 1476}, + {'date': '2021-10-08', 'name': 'Noah', 'points': 1483}, + {'date': '2021-10-08', 'name': 'Oliver', 'points': 1534}, + {'date': '2021-10-08', 'name': 'Elijah', 'points': 1530}, + {'date': '2021-10-09', 'name': 'Noah', 'points': 1519}, + {'date': '2021-10-09', 'name': 'Oliver', 'points': 1497}, + {'date': '2021-10-09', 'name': 'Elijah', 'points': 1460}, + {'date': '2021-10-10', 'name': 'Liam', 'points': 1514}, + {'date': '2021-10-10', 'name': 'Noah', 'points': 1518}, + {'date': '2021-10-10', 'name': 'Oliver', 'points': 1470}, + {'date': '2021-10-10', 'name': 'Elijah', 'points': 1526}, + {'date': '2021-10-11', 'name': 'Liam', 'points': 1517}, + {'date': '2021-10-11', 'name': 'Noah', 'points': 1478}, + {'date': '2021-10-11', 'name': 'Oliver', 'points': 1468}, + {'date': '2021-10-11', 'name': 'Elijah', 'points': 1487}, + {'date': '2021-10-12', 'name': 'Liam', 'points': 1535}, + {'date': '2021-10-12', 'name': 'Noah', 'points': 1537}, + {'date': '2021-10-12', 'name': 'Oliver', 'points': 1463}, + {'date': '2021-10-12', 'name': 'Elijah', 'points': 1478}, + {'date': '2021-10-13', 'name': 'Oliver', 'points': 1524}, + {'date': '2021-10-13', 'name': 'Elijah', 'points': 1496}, + {'date': '2021-10-14', 'name': 'Liam', 'points': 1527}, + {'date': '2021-10-14', 'name': 'Oliver', 'points': 1527}, + {'date': '2021-10-14', 'name': 'Elijah', 'points': 1462}, + {'date': '2021-10-15', 'name': 'Liam', 'points': 1532}, + {'date': '2021-10-15', 'name': 'Noah', 'points': 1509}, + {'date': '2021-10-15', 'name': 'Oliver', 'points': 1540}, + {'date': '2021-10-15', 'name': 'Elijah', 'points': 1536}, + {'date': '2021-10-16', 'name': 'Liam', 'points': 1480}, + {'date': '2021-10-16', 'name': 'Elijah', 'points': 1533}, + {'date': '2021-10-17', 'name': 'Noah', 'points': 1515}, + {'date': '2021-10-17', 'name': 'Oliver', 'points': 1518}, + {'date': '2021-10-17', 'name': 'Elijah', 'points': 1515}, + {'date': '2021-10-18', 'name': 'Oliver', 'points': 1489}, + {'date': '2021-10-18', 'name': 'Elijah', 'points': 1518}, + {'date': '2021-10-19', 'name': 'Oliver', 'points': 1472}, + {'date': '2021-10-19', 'name': 'Elijah', 'points': 1473}, + {'date': '2021-10-20', 'name': 'Liam', 'points': 1513}, + {'date': '2021-10-20', 'name': 'Noah', 'points': 1533}, + {'date': '2021-10-20', 'name': 'Oliver', 'points': 1487}, + {'date': '2021-10-20', 'name': 'Elijah', 'points': 1532}, + {'date': '2021-10-21', 'name': 'Liam', 'points': 1497}, + {'date': '2021-10-21', 'name': 'Noah', 'points': 1477}, + {'date': '2021-10-21', 'name': 'Oliver', 'points': 1516}, + {'date': '2021-10-22', 'name': 'Liam', 'points': 1466}, + {'date': '2021-10-22', 'name': 'Noah', 'points': 1476}, + {'date': '2021-10-22', 'name': 'Oliver', 'points': 1536}, + {'date': '2021-10-22', 'name': 'Elijah', 'points': 1483}, + {'date': '2021-10-23', 'name': 'Liam', 'points': 1503}, + {'date': '2021-10-23', 'name': 'Oliver', 'points': 1521}, + {'date': '2021-10-23', 'name': 'Elijah', 'points': 1529}, + {'date': '2021-10-24', 'name': 'Liam', 'points': 1460}, + {'date': '2021-10-24', 'name': 'Noah', 'points': 1532}, + {'date': '2021-10-24', 'name': 'Oliver', 'points': 1477}, + {'date': '2021-10-24', 'name': 'Elijah', 'points': 1470}, + {'date': '2021-10-25', 'name': 'Noah', 'points': 1504}, + {'date': '2021-10-25', 'name': 'Oliver', 'points': 1494}, + {'date': '2021-10-25', 'name': 'Elijah', 'points': 1528}, + {'date': '2021-10-26', 'name': 'Liam', 'points': 1517}, + {'date': '2021-10-26', 'name': 'Noah', 'points': 1503}, + {'date': '2021-10-26', 'name': 'Elijah', 'points': 1507}, + {'date': '2021-10-27', 'name': 'Liam', 'points': 1538}, + {'date': '2021-10-27', 'name': 'Noah', 'points': 1530}, + {'date': '2021-10-27', 'name': 'Oliver', 'points': 1496}, + {'date': '2021-10-27', 'name': 'Elijah', 'points': 1519}, + {'date': '2021-10-28', 'name': 'Liam', 'points': 1511}, + {'date': '2021-10-28', 'name': 'Oliver', 'points': 1500}, + {'date': '2021-10-28', 'name': 'Elijah', 'points': 1519}, + {'date': '2021-10-29', 'name': 'Noah', 'points': 1499}, + {'date': '2021-10-29', 'name': 'Oliver', 'points': 1489}, + {'date': '2021-10-30', 'name': 'Noah', 'points': 1460} +]; diff --git a/example/lib/pages/line_area.dart b/example/lib/pages/line_area.dart index ed32446..e3d56cf 100644 --- a/example/lib/pages/line_area.dart +++ b/example/lib/pages/line_area.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:graphic/graphic.dart'; import 'package:intl/intl.dart'; @@ -154,6 +155,139 @@ class LineAreaPage extends StatelessWidget { crosshair: CrosshairGuide(followPointer: [false, true]), ), ), + Container( + child: const Text( + 'Group interactions', + style: TextStyle(fontSize: 20), + ), + padding: const EdgeInsets.fromLTRB(20, 40, 20, 5), + ), + Container( + child: const Text( + '- Select and change color of a whole group', + ), + padding: const EdgeInsets.fromLTRB(10, 5, 10, 0), + alignment: Alignment.centerLeft, + ), + Container( + child: const Text( + '- The group and tooltip selections are different but triggerd by same gesture.', + ), + padding: const EdgeInsets.fromLTRB(10, 5, 10, 0), + alignment: Alignment.centerLeft, + ), + Container( + child: const Text( + '- Different interactions for different devices', + ), + padding: const EdgeInsets.fromLTRB(10, 5, 10, 0), + alignment: Alignment.centerLeft, + ), + Container( + margin: const EdgeInsets.only(top: 10), + width: 350, + height: 300, + child: Chart( + data: complexGroupData, + variables: { + 'date': Variable( + accessor: (Map map) => map['date'] as String, + scale: OrdinalScale(tickCount: 5), + ), + 'points': Variable( + accessor: (Map map) => map['points'] as num, + ), + 'name': Variable( + accessor: (Map map) => map['name'] as String, + ), + }, + elements: [ + LineElement( + position: + Varset('date') * Varset('points') / Varset('name'), + shape: ShapeAttr(value: BasicLineShape(smooth: true)), + size: SizeAttr(value: 0.5), + color: ColorAttr( + variable: 'name', + values: Defaults.colors10, + onSelection: { + 'groupMouse': { + false: (color) => color.withAlpha(100) + }, + 'groupTouch': { + false: (color) => color.withAlpha(100) + }, + }, + ), + ), + PointElement( + color: ColorAttr( + variable: 'name', + values: Defaults.colors10, + onSelection: { + 'groupMouse': { + false: (color) => color.withAlpha(100) + }, + 'groupTouch': { + false: (color) => color.withAlpha(100) + }, + }, + ), + ), + ], + axes: [ + Defaults.horizontalAxis, + Defaults.verticalAxis, + ], + selections: { + 'tooltipMouse': PointSelection(on: { + GestureType.hover, + }, devices: { + PointerDeviceKind.mouse + }), + 'groupMouse': PointSelection( + on: { + GestureType.hover, + }, + variable: 'name', + devices: {PointerDeviceKind.mouse}), + 'tooltipTouch': PointSelection(on: { + GestureType.scaleUpdate, + GestureType.tapDown, + GestureType.longPressMoveUpdate + }, devices: { + PointerDeviceKind.touch + }), + 'groupTouch': PointSelection( + on: { + GestureType.scaleUpdate, + GestureType.tapDown, + GestureType.longPressMoveUpdate + }, + variable: 'name', + devices: {PointerDeviceKind.touch}), + }, + tooltip: TooltipGuide( + selections: {'tooltipTouch', 'tooltipMouse'}, + followPointer: [true, true], + align: Alignment.topLeft, + element: 0, + variables: [ + 'date', + 'name', + 'points', + ], + ), + crosshair: CrosshairGuide( + selections: {'tooltipTouch', 'tooltipMouse'}, + styles: [ + StrokeStyle(color: const Color(0xffbfbfbf)), + StrokeStyle(color: const Color(0x00bfbfbf)), + ], + followPointer: [true, false], + ), + ), + ), Container( child: const Text( 'River chart', @@ -182,7 +316,8 @@ class LineAreaPage extends StatelessWidget { }, elements: [ AreaElement( - position: Varset('date') * Varset('value') / Varset('type'), + position: + Varset('date') * Varset('value') / Varset('type'), shape: ShapeAttr(value: BasicAreaShape(smooth: true)), color: ColorAttr( variable: 'type', @@ -249,7 +384,8 @@ class LineAreaPage extends StatelessWidget { }, elements: [ LineElement( - position: Varset('index') * Varset('value') / Varset('type'), + position: + Varset('index') * Varset('value') / Varset('type'), shape: ShapeAttr(value: BasicLineShape(loop: true)), color: ColorAttr( variable: 'type', values: Defaults.colors10), diff --git a/example/lib/pages/polar_interval.dart b/example/lib/pages/polar_interval.dart index 9c1fa59..6fe072f 100644 --- a/example/lib/pages/polar_interval.dart +++ b/example/lib/pages/polar_interval.dart @@ -143,7 +143,8 @@ class PolarIntervalPage extends StatelessWidget { }, elements: [ IntervalElement( - position: Varset('index') * Varset('value') / Varset('type'), + position: + Varset('index') * Varset('value') / Varset('type'), color: ColorAttr( variable: 'type', values: Defaults.colors10), modifiers: [StackModifier()], diff --git a/example/lib/pages/rectangle_interval.dart b/example/lib/pages/rectangle_interval.dart index 7748c20..0a8aa1c 100644 --- a/example/lib/pages/rectangle_interval.dart +++ b/example/lib/pages/rectangle_interval.dart @@ -263,7 +263,8 @@ class RectangleIntervalPage extends StatelessWidget { }, elements: [ IntervalElement( - position: Varset('index') * Varset('value') / Varset('type'), + position: + Varset('index') * Varset('value') / Varset('type'), shape: ShapeAttr(value: RectShape(labelPosition: 0.5)), color: ColorAttr( variable: 'type', values: Defaults.colors10), diff --git a/lib/src/algebra/varset.dart b/lib/src/algebra/varset.dart index 03cdea3..d8ee359 100644 --- a/lib/src/algebra/varset.dart +++ b/lib/src/algebra/varset.dart @@ -62,10 +62,10 @@ extension AlgFormExt on AlgForm { /// /// Varset means variable set in graphics algebra. Varsets, connected with operators, /// create an algebra expression. -/// +/// /// The algebra specifies how variable sets construct the plane frame, such as how /// they are assigned to dimensions and how tuples are grouped. -/// +/// /// There are three operators in graphics algebra: /// /// - [*], called **cross**, which assigns varsets to different dimensions (Usually @@ -74,7 +74,7 @@ extension AlgFormExt on AlgForm { /// - [+], called **blend**, which assigns varsets to a same dimension in order. /// The meaning of the variables respectively in that dimension is determined by /// geometory type. -/// +/// /// - [/], called **nest**, which groups all tuples by the right varset. Grouping /// is used for faceting, collision modifiers, or seperating lines or areas. The /// nesting variables should be discrete. @@ -93,10 +93,10 @@ extension AlgFormExt on AlgForm { /// ```dart /// Varset('date') * (Varset('min') + Varset('max')) /// ``` -/// +/// /// A line chart of `sales` in every `day`, but different `category`s in different /// lines: -/// +/// /// ```dart /// Varset('day') * Varset('sales') / Varset('category') /// ``` @@ -108,12 +108,12 @@ extension AlgFormExt on AlgForm { /// Varset('x') * Varset('y') * Varset('z') == Varset('x') * (Varset('y') * Varset('z')) /// Varset('x') + Varset('y') + Varset('z') == Varset('x') + (Varset('y') + Varset('z')) /// Varset('x') / Varset('y') / Varset('z') == Varset('x') / (Varset('y') / Varset('z')) -/// +/// /// Varset('x') * (Varset('y') + Varset('z')) == Varset('x') * Varset('y') + Varset('x') * Varset('z') /// (Varset('x') + Varset('y')) * Varset('z') == Varset('x') * Varset('z') + Varset('y') * Varset('z') /// Varset('x') / (Varset('y') + Varset('z')) == Varset('x') / Varset('y') + Varset('x') / Varset('z') /// (Varset('x') + Varset('y')) / Varset('z') == Varset('x') / Varset('z') + Varset('y') / Varset('z') -/// +/// /// Varset('x') * Varset('y') != Varset('y') * Varset('x') /// Varset('x') + Varset('y') != Varset('y') + Varset('x') /// Varset('x') / Varset('y') != Varset('y') / Varset('x') @@ -142,19 +142,17 @@ class Varset { ], nested = null, nesters = []; - + /// Creates a varset with properties. Varset._create( - AlgForm form, - [AlgForm? nested, - List nesters = const [],] - ) : assert( - (nested == null && nesters.isEmpty) || - (nested != null && nesters.isNotEmpty) - ), - this.form = form, - this.nested = nested, - this.nesters = nesters; + AlgForm form, [ + AlgForm? nested, + List nesters = const [], + ]) : assert((nested == null && nesters.isEmpty) || + (nested != null && nesters.isNotEmpty)), + this.form = form, + this.nested = nested, + this.nesters = nesters; /// The numerator part of the algebra expression. /// @@ -175,7 +173,7 @@ class Varset { DeepCollectionEquality().equals(nesters, other.nesters); /// The nest operator. - /// + /// /// Nesting groups all tuples by the right varset. Grouping is used for faceting, /// collision modifiers, or seperating lines or areas. The nesting variables should /// be discrete. @@ -197,11 +195,11 @@ class Varset { } /// The cross operator. - /// + /// /// Crossing assigns varsets to different dimensions (Usually x and y) in order. Varset operator *(Varset other) { // It creates a term: - // + // // - [form], cartisian products left and right. // - [nested], if only one has, uses that one; if both has, throws an error. // - [nesters], the same as [nested]. @@ -238,12 +236,12 @@ class Varset { } /// The blend operator. - /// + /// /// Blending assigns varsets to a same dimension in order. The meaning of the /// variables respectively in that dimension is determined by geometory type. Varset operator +(Varset other) { // It creates a polynomial: - // + // // - [form], append all right form terms to the left ones, normalizes the form, // and deduplicates. // - [nested], only for distributivity, if nesteds are same, uses that nested, @@ -253,11 +251,11 @@ class Varset { // - [nesters], see in [nested]. final AlgForm formRst = ([] - ..addAll(form) - ..addAll(other.form) - .._normalize()) - .collectionItemDeduplicate(); - + ..addAll(form) + ..addAll(other.form) + .._normalize()) + .collectionItemDeduplicate(); + AlgForm? nestedRst; List nestersRst = []; if (nested == null && other.nested == null) { @@ -268,7 +266,10 @@ class Varset { nestedRst = nested; final leftNester = nesters.single; final rightNester = other.nesters.single; - nestersRst = [([...leftNester, ...rightNester].._normalize()).collectionItemDeduplicate()]; + nestersRst = [ + ([...leftNester, ...rightNester].._normalize()) + .collectionItemDeduplicate() + ]; } else { // nested != other.nested @@ -277,10 +278,12 @@ class Varset { if (DeepCollectionEquality().equals(leftNester, rightNester)) { // Left distributivity: x / z + y / z = (x + y) / z. - nestedRst = ([...nested!, ...other.nested!].._normalize()).collectionItemDeduplicate(); + nestedRst = ([...nested!, ...other.nested!].._normalize()) + .collectionItemDeduplicate(); nestersRst = nesters; } else { - throw ArgumentError('Two nested operands without distributivity can not blend'); + throw ArgumentError( + 'Two nested operands without distributivity can not blend'); } } diff --git a/lib/src/chart/view.dart b/lib/src/chart/view.dart index f20a529..450b680 100644 --- a/lib/src/chart/view.dart +++ b/lib/src/chart/view.dart @@ -48,11 +48,6 @@ class View extends Dataflow { /// The view is dirty when any [Render] operater has rendered. bool dirty = false; - /// Whether to tirgger a [Graffiti.sort] after evaluation. - /// - /// If any scene's z index is chanaged, [graffiti] will have to resort its scenes. - bool disordered = false; - /// Emits a gesture signal. Future gesture(Gesture gesture) async { await gestureSource.emit(GestureSignal(gesture)); @@ -74,11 +69,6 @@ class View extends Dataflow { await super.evaluate(); if (dirty) { - if (disordered) { - graffiti.sort(); - disordered = false; - } - repaint(); dirty = false; } diff --git a/lib/src/common/operators/render.dart b/lib/src/common/operators/render.dart index 4cabf0b..33535c6 100644 --- a/lib/src/common/operators/render.dart +++ b/lib/src/common/operators/render.dart @@ -33,15 +33,4 @@ abstract class Render extends Operator { /// Renders the [scene]. void render(); - - /// Sets the [scene]'s z index. - /// - /// It will diff and set view.disordered automatically. So always use this method - /// instead of set scene.zIndex directly. - void setZIndex(int zIndex) { - if (scene.zIndex != zIndex) { - scene.zIndex = zIndex; - view.disordered = true; - } - } } diff --git a/lib/src/dataflow/tuple.dart b/lib/src/dataflow/tuple.dart index bc40c61..5264cc4 100644 --- a/lib/src/dataflow/tuple.dart +++ b/lib/src/dataflow/tuple.dart @@ -23,7 +23,7 @@ typedef Tuple = Map; /// The key strings are variable names. /// /// See also: -/// +/// /// - [Scale], which converts original value tuples to scaled value tuples. /// - [Tuple], original value tuple. typedef Scaled = Map; diff --git a/lib/src/geom/element.dart b/lib/src/geom/element.dart index b7fb7e8..80359d7 100644 --- a/lib/src/geom/element.dart +++ b/lib/src/geom/element.dart @@ -75,7 +75,7 @@ abstract class GeomElement { GradientAttr? gradient; /// The label attribute of this element. - /// + /// /// For an element, labels are always painted above item graphics, no matter how /// their [Figure]s are rendered in [Shape]s. LabelAttr? label; @@ -143,12 +143,12 @@ abstract class GeomElement { /// /// The nesters, no matter `x * y`, `a + y`, or `a / y`, will be used in cartesian /// production. If empty, all eases will be in a same group. -/// +/// /// Empty groups will be removed after each grouping, which reflects the feature /// of nesting. It is nessasary especially in multiple nesters grouping. -/// +/// /// Groups with same value of smaller indexed nester will stay together. -/// +/// /// List is the best way to store groups. If nester values are needed for indexing, /// store them in another corresponding list. List indexes are better then map keys. class GroupOp extends Operator { diff --git a/lib/src/guide/interaction/crosshair.dart b/lib/src/guide/interaction/crosshair.dart index 38e22fa..c48315e 100644 --- a/lib/src/guide/interaction/crosshair.dart +++ b/lib/src/guide/interaction/crosshair.dart @@ -13,25 +13,29 @@ import 'package:graphic/src/dataflow/tuple.dart'; import 'package:graphic/src/graffiti/figure.dart'; import 'package:graphic/src/graffiti/scene.dart'; import 'package:graphic/src/interaction/selection/selection.dart'; +import 'package:graphic/src/util/list.dart'; import 'package:graphic/src/util/path.dart'; /// The specification of a crosshair /// -/// A corsshair indicates the position of the pointer or the selected point. +/// A corsshair indicates the position of the pointer or the selected point. If +/// no point is selected, it will not occur. class CrosshairGuide { /// Creates a crosshair. CrosshairGuide({ - this.selection, + this.selections, this.styles, this.followPointer, this.zIndex, this.element, }); - /// The selection this crosshair reacts to. + /// The selections this crosshair reacts to. /// - /// If null, the first selection is set by default. - String? selection; + /// Make sure this selections will not occur simultaneously. + /// + /// If null, it will reacts to all selections. + Set? selections; /// The stroke styles of crosshair lines for each dimension. /// @@ -62,7 +66,7 @@ class CrosshairGuide { @override bool operator ==(Object other) => other is CrosshairGuide && - selection == other.selection && + DeepCollectionEquality().equals(selections, other.selections) && DeepCollectionEquality().equals(styles, other.styles) && DeepCollectionEquality().equals(followPointer, other.followPointer) && zIndex == other.zIndex && @@ -87,15 +91,20 @@ class CrosshairRenderOp extends Render { @override void render() { - final selectorName = params['selectorName'] as String; - final selector = params['selector'] as Selector?; - final selects = params['selects'] as Set?; + final selections = params['selections'] as Set; + final selectors = params['selectors'] as Map?; + final selects = params['selects'] as Map>?; final coord = params['coord'] as CoordConv; final groups = params['groups'] as AesGroups; final styles = params['styles'] as List; final followPointer = params['followPointer'] as List; - if (selector == null || selects == null || selector.name != selectorName) { + final name = singleIntersection(selectors?.keys, selections); + + final selector = name == null ? null : selectors?[name]; + final indexes = name == null ? null : selects?[name]; + + if (selector == null || indexes == null || indexes.isEmpty) { scene.figures = null; return; } @@ -115,7 +124,7 @@ class CrosshairRenderOp extends Render { } return Offset.zero; }; - for (var index in selects) { + for (var index in indexes) { selectedPoint += findPoint(index); } selectedPoint = selectedPoint / count.toDouble(); diff --git a/lib/src/guide/interaction/tooltip.dart b/lib/src/guide/interaction/tooltip.dart index 70c936a..a80bd2c 100644 --- a/lib/src/guide/interaction/tooltip.dart +++ b/lib/src/guide/interaction/tooltip.dart @@ -16,6 +16,7 @@ import 'package:graphic/src/interaction/selection/point.dart'; import 'package:graphic/src/interaction/selection/selection.dart'; import 'package:graphic/src/scale/scale.dart'; import 'package:graphic/src/util/assert.dart'; +import 'package:graphic/src/util/list.dart'; /// Gets the figures of a tooltip. /// @@ -32,7 +33,7 @@ typedef TooltipRenderer = List
Function( class TooltipGuide { /// Creates a tooltip. TooltipGuide({ - this.selection, + this.selections, this.followPointer, this.anchor, this.zIndex, @@ -46,6 +47,7 @@ class TooltipGuide { this.textStyle, this.multiTuples, this.variables, + this.constrained, this.renderer, }) : assert(isSingle([renderer, align], allowNone: true)), assert(isSingle([renderer, offset], allowNone: true)), @@ -55,12 +57,15 @@ class TooltipGuide { assert(isSingle([renderer, elevation], allowNone: true)), assert(isSingle([renderer, textStyle], allowNone: true)), assert(isSingle([renderer, multiTuples], allowNone: true)), + assert(isSingle([renderer, constrained], allowNone: true)), assert(isSingle([renderer, variables], allowNone: true)); - /// The selection this tooltip reacts to. + /// The selections this crosshair reacts to. /// - /// If null, the first selection is set by default. - String? selection; + /// Make sure this selections will not occur simultaneously. + /// + /// If null, it will reacts to all selections. + Set? selections; /// Whether the position for each dimension follows the pointer or stick to selected /// points. @@ -129,8 +134,8 @@ class TooltipGuide { /// For single tuple, [variables] are layed in rows showing title and value. For /// multiple tuples, tuples are layed in rows showing the 2 [variables] values. /// - /// If null, A default false if [selection] is [PointSelection] and true if [IntervalSelection] - /// is set. + /// If null, it will varies according to triggering selector, and false for a + /// [PointSelection] and true for a [IntervalSelection]; bool? multiTuples; /// The variable values of tuples to show on in this tooltip. @@ -142,16 +147,25 @@ class TooltipGuide { /// except [Selection.variable] for multiple tuples. List? variables; + /// Whether the tooltip should be constrained within the chart widget border. + /// + /// If constrained, the position will be adjusted if the tooltip may overflow + /// the chart widget border. If not, the outside part will be clipped. + /// + /// If null, a default true is set. + bool? constrained; + /// Indicates a custom render funcion of this tooltip. /// /// If set, [align], [offset], [padding], [backgroundColor], [radius], [elevation], - /// [textStyle], [multiTuples], and [variables] are useless and not allowed. + /// [textStyle], [multiTuples], [variables], and [constrained] are useless and + /// not allowed. TooltipRenderer? renderer; @override bool operator ==(Object other) => other is TooltipGuide && - selection == other.selection && + DeepCollectionEquality().equals(selections, other.selections) && DeepCollectionEquality().equals(followPointer, other.followPointer) && zIndex == other.zIndex && element == other.element && @@ -162,8 +176,9 @@ class TooltipGuide { radius == other.radius && elevation == other.elevation && textStyle == other.textStyle && - multiTuples == multiTuples && - DeepCollectionEquality().equals(variables, other.variables); + multiTuples == other.multiTuples && + DeepCollectionEquality().equals(variables, other.variables) && + constrained == other.constrained; } /// The tooltip scene. @@ -184,9 +199,9 @@ class TooltipRenderOp extends Render { @override void render() { - final selectorName = params['selectorName'] as String; - final selector = params['selector'] as Selector?; - final selects = params['selects'] as Set?; + final selections = params['selections'] as Set; + final selectors = params['selectors'] as Map?; + final selects = params['selects'] as Map>?; final coord = params['coord'] as CoordConv; final groups = params['groups'] as AesGroups; final tuples = params['tuples'] as List; @@ -197,24 +212,30 @@ class TooltipRenderOp extends Render { final radius = params['radius'] as Radius?; final elevation = params['elevation'] as double?; final textStyle = params['textStyle'] as TextStyle; - final multiTuples = params['multiTuples'] as bool; + final multiTuples = params['multiTuples'] as bool?; final renderer = params['renderer'] as TooltipRenderer?; final followPointer = params['followPointer'] as List; final anchor = params['anchor'] as Offset Function(Size)?; final size = params['size'] as Size; final variables = params['variables'] as List?; + final constrained = params['constrained'] as bool; final scales = params['scales'] as Map; - if (selector == null || - selects == null || - selects.isEmpty || - selector.name != selectorName) { + final name = singleIntersection(selectors?.keys, selections); + + final selector = name == null ? null : selectors?[name]; + final indexes = name == null ? null : selects?[name]; + + if (selector == null || indexes == null || indexes.isEmpty) { scene.figures = null; return; } + final multiTuplesRst = + multiTuples ?? (selector is PointSelector ? false : true); + final selectedTuples = []; - for (var index in selects) { + for (var index in indexes) { selectedTuples.add(tuples[index]); } @@ -237,7 +258,7 @@ class TooltipRenderOp extends Render { } return Offset.zero; }; - for (var index in selects) { + for (var index in indexes) { selectedPoint += findPoint(index); } selectedPoint = selectedPoint / count.toDouble(); @@ -256,7 +277,7 @@ class TooltipRenderOp extends Render { ); } else { String textContent = ''; - if (!multiTuples) { + if (!multiTuplesRst) { final fields = variables ?? scales.keys.toList(); final tuple = selectedTuples.last; var field = fields.first; @@ -317,33 +338,53 @@ class TooltipRenderOp extends Render { align, ); - final widow = Rect.fromLTWH( + var windowRect = Rect.fromLTWH( paintPoint.dx, paintPoint.dy, width, height, ); - final widowPath = radius == null - ? (Path()..addRect(widow)) - : (Path()..addRRect(RRect.fromRectAndRadius(widow, radius))); + var textPaintPoint = paintPoint + padding.topLeft; + + if (constrained) { + final horizontalAdjust = windowRect.left < 0 + ? -windowRect.left + : (windowRect.right > size.width + ? size.width - windowRect.right + : 0.0); + final verticalAdjust = windowRect.top < 0 + ? -windowRect.top + : (windowRect.bottom > size.height + ? size.height - windowRect.bottom + : 0.0); + if (horizontalAdjust != 0 || verticalAdjust != 0) { + windowRect = windowRect.translate(horizontalAdjust, verticalAdjust); + textPaintPoint = + textPaintPoint.translate(horizontalAdjust, verticalAdjust); + } + } + + final windowPath = radius == null + ? (Path()..addRect(windowRect)) + : (Path()..addRRect(RRect.fromRectAndRadius(windowRect, radius))); figures =
[]; if (elevation != null && elevation != 0) { figures.add(ShadowFigure( - widowPath, + windowPath, backgroundColor, elevation, )); } figures.add(PathFigure( - widowPath, + windowPath, Paint()..color = backgroundColor, )); figures.add(TextFigure( painter, - paintPoint + padding.topLeft, + textPaintPoint, )); } diff --git a/lib/src/interaction/gesture.dart b/lib/src/interaction/gesture.dart index 5cf5676..4da7e07 100644 --- a/lib/src/interaction/gesture.dart +++ b/lib/src/interaction/gesture.dart @@ -466,7 +466,7 @@ class Gesture { /// Creates a gesture. Gesture( this.type, - this.kind, + this.device, this.localPosition, this.chartSize, this.details, { @@ -478,7 +478,7 @@ class Gesture { final GestureType type; /// the kind of device that triggers the pointer event. - final PointerDeviceKind kind; + final PointerDeviceKind device; /// The local position of the pointer event that triggers this gesture. final Offset localPosition; diff --git a/lib/src/interaction/selection/interval.dart b/lib/src/interaction/selection/interval.dart index 5c28b95..0101d56 100644 --- a/lib/src/interaction/selection/interval.dart +++ b/lib/src/interaction/selection/interval.dart @@ -15,14 +15,17 @@ class IntervalSelection extends Selection { /// Creates an interval selection. IntervalSelection({ this.color, - this.zIndex, int? dim, String? variable, Set? clear, + Set? devices, + int? zIndex, }) : super( dim: dim, variable: variable, clear: clear, + devices: devices, + zIndex: zIndex, ); /// The color of the interval mark. @@ -30,17 +33,9 @@ class IntervalSelection extends Selection { /// If null, a default `Color(0x10101010)` is set. Color? color; - /// The z index of the interval mark. - /// - /// If null, a default 0 is set. - int? zIndex; - @override bool operator ==(Object other) => - other is IntervalSelection && - super == other && - color == other.color && - zIndex == other.zIndex; + other is IntervalSelection && super == other && color == other.color; } /// The interval selector. @@ -49,13 +44,10 @@ class IntervalSelection extends Selection { class IntervalSelector extends Selector { IntervalSelector( this.color, - this.zIndex, - String name, int? dim, String? variable, List points, ) : super( - name, dim, variable, points, @@ -64,9 +56,6 @@ class IntervalSelector extends Selector { /// The color of the interval mark. final Color color; - /// The z index of the interval mark. - final int zIndex; - @override Set? select( AesGroups groups, diff --git a/lib/src/interaction/selection/point.dart b/lib/src/interaction/selection/point.dart index 979d2a9..70fe26d 100644 --- a/lib/src/interaction/selection/point.dart +++ b/lib/src/interaction/selection/point.dart @@ -17,11 +17,15 @@ class PointSelection extends Selection { String? variable, Set? on, Set? clear, + Set? devices, + int? zIndex, }) : super( dim: dim, variable: variable, on: on, clear: clear, + devices: devices, + zIndex: zIndex, ); /// Whether triggered tuples should be toggled (inserted or removed from) or replace @@ -58,13 +62,11 @@ class PointSelector extends Selector { this.toggle, this.nearest, this.testRadius, - String name, int? dim, String? variable, List points, ) : assert(toggle != true || variable == null), super( - name, dim, variable, points, diff --git a/lib/src/interaction/selection/selection.dart b/lib/src/interaction/selection/selection.dart index f3e005e..c902f11 100644 --- a/lib/src/interaction/selection/selection.dart +++ b/lib/src/interaction/selection/selection.dart @@ -14,6 +14,7 @@ import 'package:graphic/src/graffiti/scene.dart'; import 'package:graphic/src/interaction/gesture.dart'; import 'package:graphic/src/shape/shape.dart'; import 'package:collection/collection.dart'; +import 'package:graphic/src/util/list.dart'; import 'interval.dart'; import 'point.dart'; @@ -36,6 +37,8 @@ abstract class Selection { this.variable, this.on, this.clear, + this.devices, + this.zIndex, }); /// Which diemsion of data values will be tested. @@ -65,13 +68,25 @@ abstract class Selection { /// If null, a default `{GestureType.doubleTap}` is set. Set? clear; + /// The device kinds on which this selection is tiggered. + /// + /// If null, this selection will be triggered on all device kinds. + Set? devices; + + /// The z index of the selector mark. + /// + /// If null, a default 0 is set. + int? zIndex; + @override bool operator ==(Object other) => other is Selection && dim == other.dim && variable == other.variable && DeepCollectionEquality().equals(on, other.on) && - DeepCollectionEquality().equals(clear, other.clear); + DeepCollectionEquality().equals(clear, other.clear) && + DeepCollectionEquality().equals(devices, other.devices) && + zIndex == other.zIndex; } /// Updates an easthetic attribute value when the selection state of an element @@ -95,15 +110,11 @@ typedef SelectionUpdater = V Function(V initialValue); /// selects tuples in the select operator. abstract class Selector { Selector( - this.name, this.dim, this.variable, this.points, ); - /// The name of the selection - final String name; - /// Which diemsion of data values will be tested. final int? dim; @@ -124,13 +135,15 @@ abstract class Selector { } /// The operator to create selectors. -class SelectorOp extends Operator { +/// +/// The value list is either null or not empty. +class SelectorOp extends Operator?> { SelectorOp(Map params) : super(params); @override - Selector? evaluate() { + Map? evaluate() { final specs = params['specs'] as Map; - final onTypes = params['onTypes'] as Map; + final onTypes = params['onTypes'] as Map>; final clearTypes = params['clearTypes'] as Set; final gesture = params['gesture'] as Gesture?; @@ -138,99 +151,104 @@ class SelectorOp extends Operator { return value; } final type = gesture.type; - final name = onTypes[type]; + final names = onTypes[type]; if (clearTypes.contains(type)) { return null; } - if (name == null) { + if (names == null) { return value; } - final spec = specs[onTypes[type]]!; - if (spec is PointSelection) { - return PointSelector( - spec.toggle ?? false, - spec.nearest ?? true, - spec.testRadius ?? 10.0, - name, - spec.dim, - spec.variable, - [gesture.localPosition], - ); - } else { - spec as IntervalSelection; - List points; - if (value?.name != name) { - // If no previous selector or previous selector is not the same selection, - // creates one. + final rst = {}; - if (gesture.type == GestureType.scaleUpdate) { - final detail = gesture.details as ScaleUpdateDetails; + for (var name in names) { + final spec = specs[name]!; + if (spec.devices != null && !spec.devices!.contains(gesture.device)) { + continue; + } + if (spec is PointSelection) { + rst[name] = PointSelector( + spec.toggle ?? false, + spec.nearest ?? true, + spec.testRadius ?? 10.0, + spec.dim, + spec.variable, + [gesture.localPosition], + ); + } else { + spec as IntervalSelection; + List points; - if (detail.pointerCount == 1) { - // Only creates by panning. + if (value != null && value!.keys.contains(name)) { + // If an interval selector of the same name is in previous value. - points = [gesture.localMoveStart!, gesture.localPosition]; - } else { - return null; - } - } else { - return null; - } - } else { - // If previous selector is the same selection. + final prePoints = value![name]!.points; - final prePoints = value!.points; + if (gesture.type == GestureType.scaleUpdate) { + final detail = gesture.details as ScaleUpdateDetails; - if (gesture.type == GestureType.scaleUpdate) { - final detail = gesture.details as ScaleUpdateDetails; + if (detail.pointerCount == 1) { + if (gesture.localMoveStart == prePoints.first) { + // Still in the creating panning. - if (detail.pointerCount == 1) { - if (gesture.localMoveStart == prePoints.first) { - // Still in the creating panning. + points = [gesture.localMoveStart!, gesture.localPosition]; + } else { + // Pans to move. - points = [gesture.localMoveStart!, gesture.localPosition]; + final delta = detail.delta - gesture.preScaleDetail!.delta; + points = [prePoints.first + delta, prePoints.last + delta]; + } } else { - // Pans to move. - - final delta = detail.delta - gesture.preScaleDetail!.delta; - points = [prePoints.first + delta, prePoints.last + delta]; + // Scales to zoom. + + final preScale = gesture.preScaleDetail!.scale; + final scale = detail.scale; + final deltaRatio = (scale - preScale) / preScale / 2; + final preOffset = prePoints.last - prePoints.first; + final delta = preOffset * deltaRatio; + points = [prePoints.first - delta, prePoints.last + delta]; } } else { - // Scales to zoom. - - final preScale = gesture.preScaleDetail!.scale; - final scale = detail.scale; - final deltaRatio = (scale - preScale) / preScale / 2; + // scrolls to zoom. + + final step = 0.1; + final scrollDelta = gesture.details as Offset; + final deltaRatio = scrollDelta.dy == 0 + ? 0.0 + : scrollDelta.dy > 0 + ? (step / 2) + : (-step / 2); final preOffset = prePoints.last - prePoints.first; final delta = preOffset * deltaRatio; points = [prePoints.first - delta, prePoints.last + delta]; } } else { - // scrolls to zoom. - - final step = 0.1; - final scrollDelta = gesture.details as Offset; - final deltaRatio = scrollDelta.dy == 0 - ? 0.0 - : scrollDelta.dy > 0 - ? (step / 2) - : (-step / 2); - final preOffset = prePoints.last - prePoints.first; - final delta = preOffset * deltaRatio; - points = [prePoints.first - delta, prePoints.last + delta]; + // If the current interval selector is totally new. + + if (gesture.type == GestureType.scaleUpdate) { + final detail = gesture.details as ScaleUpdateDetails; + + if (detail.pointerCount == 1) { + // Only creates by panning. + + points = [gesture.localMoveStart!, gesture.localPosition]; + } else { + return null; + } + } else { + return null; + } } - } - return IntervalSelector( - spec.color ?? Color(0x10101010), - spec.zIndex ?? 0, - name, - spec.dim, - spec.variable, - points, - ); + rst[name] = IntervalSelector( + spec.color ?? Color(0x10101010), + spec.dim, + spec.variable, + points, + ); + } } + return rst.isEmpty ? null : rst; } } @@ -243,6 +261,9 @@ class SelectorScene extends Scene { } /// The selector render operator. +/// +/// Because the selectors may have different z indexes, each defined selection has +/// an own scene and render operator, but the untriggered will has no figures. class SelectorRenderOp extends Render { SelectorRenderOp( Map params, @@ -252,7 +273,10 @@ class SelectorRenderOp extends Render { @override void render() { - final selector = params['selector'] as Selector?; + final selectors = params['selectors'] as Map?; + final name = params['name'] as String?; + + final selector = selectors?[name]; if (selector is IntervalSelector) { scene @@ -261,34 +285,43 @@ class SelectorRenderOp extends Render { selector.points.last, selector.color, ); - setZIndex(selector.zIndex); } else { + // The point selector has no mark for now. + scene.figures = null; } } } /// The operator to select tuples by selectors. -class SelectOp extends Operator?> { - SelectOp(Map params, Set? value) : super(params, value); +class SelectOp extends Operator>?> { + SelectOp(Map params, Map>? value) + : super(params, value); @override - Set? evaluate() { - final selector = params['selector'] as Selector?; + Map>? evaluate() { + final selectors = params['selectors'] as Map?; final groups = params['groups'] as AesGroups; final tuples = params['tuples'] as List; final coord = params['coord'] as CoordConv; - if (selector == null) { + if (selectors == null) { return null; - } else { - return selector.select( + } + + final rst = >{}; + for (var name in selectors.keys) { + final indexes = selectors[name]!.select( groups, tuples, - value, + value?[name], coord, ); + if (indexes != null) { + rst[name] = indexes; + } } + return rst.isEmpty ? null : rst; } } @@ -314,9 +347,7 @@ class SelectionUpdateOp extends Operator { @override AesGroups evaluate() { final groups = params['groups'] as AesGroups; - final selector = params['selector'] as Selector?; - final initialSelector = params['initialSelector'] as String?; - final selects = params['selects'] as Set?; + final selects = params['selects'] as Map>?; final shapeUpdaters = params['shapeUpdaters'] as Map>>?; final colorUpdaters = params['colorUpdaters'] @@ -329,21 +360,23 @@ class SelectionUpdateOp extends Operator { as Map>>?; final sizeUpdaters = params['sizeUpdaters'] as Map>>?; + final updaterNames = params['updaterNames'] as Set; - // For initially selected tuples of Element.selected, use the indecated selecor - // name. - final selectorName = selector?.name ?? initialSelector; + // Makes sure only one selects result works. + final name = singleIntersection(selects?.keys, updaterNames); - if (selectorName == null || selects == null) { + if (name == null) { return groups.map((group) => [...group]).toList(); } - final shapeUpdater = shapeUpdaters?[selectorName]; - final colorUpdater = colorUpdaters?[selectorName]; - final gradientUpdater = gradientUpdaters?[selectorName]; - final elevationUpdater = elevationUpdaters?[selectorName]; - final labelUpdater = labelUpdaters?[selectorName]; - final sizeUpdater = sizeUpdaters?[selectorName]; + final indexes = selects![name]!; + + final shapeUpdater = shapeUpdaters?[name]; + final colorUpdater = colorUpdaters?[name]; + final gradientUpdater = gradientUpdaters?[name]; + final elevationUpdater = elevationUpdaters?[name]; + final labelUpdater = labelUpdaters?[name]; + final sizeUpdater = sizeUpdaters?[name]; if (shapeUpdater == null && colorUpdater == null && @@ -359,7 +392,7 @@ class SelectionUpdateOp extends Operator { final groupRst = []; for (var i = 0; i < group.length; i++) { final aes = group[i]; - final selected = selects.contains(aes.index); + final selected = indexes.contains(aes.index); groupRst.add(Aes( index: aes.index, position: [...aes.position], diff --git a/lib/src/interaction/signal.dart b/lib/src/interaction/signal.dart index 01456a4..798d9a5 100644 --- a/lib/src/interaction/signal.dart +++ b/lib/src/interaction/signal.dart @@ -39,7 +39,8 @@ abstract class Signal { /// previous value before this update. /// /// Make sure the return value is a different instance from initialValue or preValue. -typedef SignalUpdater = V Function(V initialValue, V preValue, Signal signal); +typedef SignalUpdater = V Function( + V initialValue, V preValue, Signal signal); /// The souce to generate signals. class SignalSource { diff --git a/lib/src/parse/parse.dart b/lib/src/parse/parse.dart index b6744dd..5414700 100644 --- a/lib/src/parse/parse.dart +++ b/lib/src/parse/parse.dart @@ -108,7 +108,7 @@ void parse(Chart spec, View view) { signal, (signal) => signal, ); - + // Coord. final region = view.add(RegionOp({ @@ -230,8 +230,7 @@ void parse(Chart spec, View view) { } else if (transformSpec is Proportion) { final as = transformSpec.as; assert(scaleSpecs[as] == null); - scaleSpecs[as] = - transformSpec.scale ?? LinearScale(min: 0, max: 1); + scaleSpecs[as] = transformSpec.scale ?? LinearScale(min: 0, max: 1); var nesters = []; if (transformSpec.nest != null) { @@ -272,10 +271,10 @@ void parse(Chart spec, View view) { // Selection. - SelectorOp? selector; + SelectorOp? selectors; if (spec.selections != null) { final selectSpecs = spec.selections!; - final onTypes = {}; + final onTypes = >{}; final clearTypes = {}; for (var name in selectSpecs.keys) { final selectSpec = selectSpecs[name]!; @@ -288,23 +287,30 @@ void parse(Chart spec, View view) { : {GestureType.scaleUpdate, GestureType.scroll}); final clear = selectSpec.clear ?? {GestureType.doubleTap}; for (var type in on) { - assert(!onTypes.keys.contains(type)); - onTypes[type] = name; + if (onTypes[type] == null) { + onTypes[type] = [name]; + } else { + onTypes[type]!.add(name); + } } clearTypes.addAll(clear); } - selector = view.add(SelectorOp({ + selectors = view.add(SelectorOp({ 'specs': selectSpecs, 'onTypes': onTypes, 'clearTypes': clearTypes, 'gesture': gesture, })); - final selectorScene = view.graffiti.add(SelectorScene(0)); - view.add(SelectorRenderOp({ - 'selector': selector, - }, selectorScene, view)); + for (var name in selectSpecs.keys) { + final selectorScene = + view.graffiti.add(SelectorScene(selectSpecs[name]!.zIndex ?? 0)); + view.add(SelectorRenderOp({ + 'selectors': selectors, + 'name': name, + }, selectorScene, view)); + } } // Element. @@ -431,35 +437,52 @@ void parse(Chart spec, View view) { } } - if (selector != null) { - String? initialSelector; - - Set? initialSelected; - - if (elementSpec.selected != null) { - initialSelector = elementSpec.selected!.keys.single; - initialSelected = elementSpec.selected![initialSelector]; - } - + if (selectors != null) { final selects = view.add(SelectOp({ - 'selector': selector, + 'selectors': selectors, 'groups': groups, 'tuples': tuples, 'coord': coord, - }, initialSelected)); + }, elementSpec.selected)); selectsList.add(selects); + final shapeUpdaters = elementSpec.shape?.onSelection; + final colorUpdaters = elementSpec.color?.onSelection; + final gradientUpdaters = elementSpec.gradient?.onSelection; + final elevationUpdaters = elementSpec.elevation?.onSelection; + final labelUpdaters = elementSpec.label?.onSelection; + final sizeUpdaters = elementSpec.size?.onSelection; + + final updaterNames = {}; + if (shapeUpdaters != null) { + updaterNames.addAll(shapeUpdaters.keys); + } + if (colorUpdaters != null) { + updaterNames.addAll(colorUpdaters.keys); + } + if (gradientUpdaters != null) { + updaterNames.addAll(gradientUpdaters.keys); + } + if (elevationUpdaters != null) { + updaterNames.addAll(elevationUpdaters.keys); + } + if (labelUpdaters != null) { + updaterNames.addAll(labelUpdaters.keys); + } + if (sizeUpdaters != null) { + updaterNames.addAll(sizeUpdaters.keys); + } + final update = view.add(SelectionUpdateOp({ 'groups': groups, - 'selector': selector, - 'initialSelector': initialSelector, 'selects': selects, - 'shapeUpdaters': elementSpec.shape?.onSelection, - 'colorUpdaters': elementSpec.color?.onSelection, - 'gradientUpdaters': elementSpec.gradient?.onSelection, - 'elevationUpdaters': elementSpec.elevation?.onSelection, - 'labelUpdaters': elementSpec.label?.onSelection, - 'sizeUpdaters': elementSpec.size?.onSelection, + 'shapeUpdaters': shapeUpdaters, + 'colorUpdaters': colorUpdaters, + 'gradientUpdaters': gradientUpdaters, + 'elevationUpdaters': elevationUpdaters, + 'labelUpdaters': labelUpdaters, + 'sizeUpdaters': sizeUpdaters, + 'updaterNames': updaterNames, })); groups = update; } @@ -596,7 +619,7 @@ void parse(Chart spec, View view) { } if (spec.crosshair != null) { - assert(selector != null); + assert(selectors != null); final crosshairSpec = spec.crosshair!; final elementIndex = crosshairSpec.element ?? 0; @@ -604,8 +627,8 @@ void parse(Chart spec, View view) { final crosshairScene = view.graffiti.add(CrosshairScene(crosshairSpec.zIndex ?? 0)); view.add(CrosshairRenderOp({ - 'selectorName': crosshairSpec.selection ?? spec.selections!.keys.first, - 'selector': selector!, + 'selections': crosshairSpec.selections ?? spec.selections!.keys.toSet(), + 'selectors': selectors!, 'selects': selectsList[elementIndex], 'coord': coord, 'groups': groupsList[elementIndex], @@ -619,19 +642,16 @@ void parse(Chart spec, View view) { } if (spec.tooltip != null) { - assert(selector != null); + assert(selectors != null); final tooltipSpec = spec.tooltip!; final elementIndex = tooltipSpec.element ?? 0; final tooltipScene = view.graffiti.add(TooltipScene(tooltipSpec.zIndex ?? 0)); - final selectorName = tooltipSpec.selection ?? spec.selections!.keys.first; - final multiTuples = tooltipSpec.multiTuples ?? - ((spec.selections![selectorName] is PointSelection) ? false : true); view.add(TooltipRenderOp({ - 'selectorName': selectorName, - 'selector': selector!, + 'selections': tooltipSpec.selections ?? spec.selections!.keys.toSet(), + 'selectors': selectors!, 'selects': selectsList[elementIndex], 'coord': coord, 'groups': groupsList[elementIndex], @@ -647,12 +667,13 @@ void parse(Chart spec, View view) { color: Color(0xff595959), fontSize: 12, ), - 'multiTuples': multiTuples, + 'multiTuples': tooltipSpec.multiTuples, 'renderer': tooltipSpec.renderer, 'followPointer': tooltipSpec.followPointer ?? [false, false], 'anchor': tooltipSpec.anchor, 'size': size, 'variables': tooltipSpec.variables, + 'constrained': tooltipSpec.constrained ?? true, 'scales': scales, }, tooltipScene, view)); } diff --git a/lib/src/scale/scale.dart b/lib/src/scale/scale.dart index 3c0d906..dcbbd73 100644 --- a/lib/src/scale/scale.dart +++ b/lib/src/scale/scale.dart @@ -86,7 +86,7 @@ abstract class ScaleConv extends Converter { late String title; /// The scale formatter - /// + /// /// This should not be directly used. Use method [format] insead to avoid generic /// problems. late String Function(V) formatter; @@ -117,7 +117,7 @@ abstract class ScaleConv extends Converter { V get zero; /// Formats a value to string. - /// + /// /// This is a method wrapper of [formatter] to avoid generic problems. String format(V value) => formatter(value); diff --git a/lib/src/util/list.dart b/lib/src/util/list.dart index 8fd0baa..9e001e7 100644 --- a/lib/src/util/list.dart +++ b/lib/src/util/list.dart @@ -2,11 +2,24 @@ import 'dart:math'; import 'package:collection/collection.dart'; +E? singleIntersection(Iterable? a, Iterable? b) { + if (a == null || b == null || a.isEmpty || b.isEmpty) { + return null; + } + + final rst = a.where((element) => b.contains(element)); + if (rst.isEmpty) { + return null; + } else { + return rst.single; + } +} + extension ListExt on List { /// Gets a sublist safely avoiding [end] overflow. List safeSublist(int start, [int? end]) => - sublist(start, min(end ?? length, length)); - + sublist(start, min(end ?? length, length)); + /// Gets a deduplicated list when list items are collections. List collectionItemDeduplicate() { final rst = []; diff --git a/lib/src/variable/transform/proportion.dart b/lib/src/variable/transform/proportion.dart index c6315f0..19437f3 100644 --- a/lib/src/variable/transform/proportion.dart +++ b/lib/src/variable/transform/proportion.dart @@ -29,7 +29,7 @@ class Proportion extends VariableTransform { /// If set, the tuples will temporarily grouped by it, and the denominator of /// proportion will be sum of a single group. If null, the denominator will be /// sum of all. - /// + /// /// See details about nesting rules in [Varset]. Note this property is only the /// right oprand of nesting. Varset? nest; @@ -53,7 +53,7 @@ class Proportion extends VariableTransform { } /// The proportion transform operator. -/// +/// /// The evaluation of nesting is like the [GroupOp]. class ProportionOp extends TransformOp { ProportionOp(Map params) : super(params); diff --git a/pubspec.yaml b/pubspec.yaml index 80484c6..2f75324 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: graphic description: A declarative, interactive grammar of data visualization. It provides a Flutter charting library. -version: 0.5.0 +version: 0.5.1 homepage: https://github.com/entronad/graphic environment: