renderer
graphic中tooltip的TooltipGuide类提供了renderer方法,接收三个参数Size类型,Offset类型,Map<int, Tuple>类型。可查到的文档是真的少,所以只能在源码中扒拉例子,做符合需求的修改。
官方github示例
官方示例
这个例子感觉像是tooltip和提供的那些属性的源码实现,然后改变了背景颜色等,但如果实现想echarts那样对每条线的数据前增加颜色块区分,还是要自己摸索。先看一下这个例子都做了什么吧
List<MarkElement> simpleTooltip(
Size size,
Offset anchor,
Map<int, Tuple> selectedTuples,
) {
// 返回的元素列表
List<MarkElement> elements;
// 生成tooltip内容
String textContent = '';
// 选中的数据 ({date: xxx, name: 线条1, points: xxx}, {date: xx, name: 线条2, points: xx}...)
final selectedTupleList = selectedTuples.values;
// 选中的数据的字段名列表
// 单条线:[date, points]
// 多条线:会通过name区分不同线的数值[date, name, points]
final fields = selectedTupleList.first.keys.toList();
// 如果只有一条线
if (selectedTuples.length == 1) {
// 取出选中的数据
final original = selectedTupleList.single;
// 取出第一个字段的值
var field = fields.first;
// 将第一个字段的值放入到tooltip的第一行
/**
* 此时的数据结构是:
* date: 2023-11-24
*/
textContent += '$field: ${original[field]}';
// 遍历字段名列表
for (var i = 1; i < fields.length; i++) {
// 取出第i个字段
field = fields[i];
// 将第i个字段的值放入到tooltip的第二行
/**
* 遍历后的数据结构是:
* date: 2023-11-24
* points: 123
*/
textContent += '\n$field: ${original[field]}';
}
} else {
// 如果有多条线
// 遍历选中的数据(几条线几条数据),将每个数据的第二个字段和第三个字段的值放入到tooltip的第二行和第三行
for (var original in selectedTupleList) {
// 取出第一个字段
final domainField = fields.first;
// 取出最后一个字段
final measureField = fields.last;
/**
* 遍历结束后的数据结构是:
* 2023-11-24:线条1
* 2023-11-24:线条2
* ....
*/
textContent += '\n${original[domainField]}: ${original[measureField]}';
}
}
// 提出一些变量
const textStyle = TextStyle(fontSize: 12, color: Colors.white);
const padding = EdgeInsets.all(5);
const align = Alignment.topRight;
const offset = Offset(5, -5);
const elevation = 1.0;
const backgroundColor = Colors.black;
final painter = TextPainter(
text: TextSpan(text: textContent, style: textStyle),
textDirection: TextDirection.ltr,
);
painter.layout();
// 计算tooltip的宽高
final width = padding.left + painter.width + padding.right;
final height = padding.top + painter.height + padding.bottom;
// 调整tooltip弹框(包含内容)的位置
final paintPoint = getBlockPaintPoint(
anchor + offset,
width,
height,
align,
);
// 调整tooltip弹框(不包含内容)的位置
final window = Rect.fromLTWH(
paintPoint.dx,
paintPoint.dy,
width,
height,
);
// 计算tooltip文本的位置
var textPaintPoint = paintPoint + padding.topLeft;
elements = <MarkElement>[
RectElement(
rect: window,
style: PaintStyle(fillColor: backgroundColor, elevation: elevation)),
LabelElement(
text: textContent,
anchor: textPaintPoint,
style:
LabelStyle(textStyle: textStyle, align: Alignment.bottomRight)),
];
return elements;
}
效果
根据需求调整
改动后代码
List<MarkElement> simpleTooltip(
Size size,
Offset anchor,
Map<int, Tuple> selectedTuples,
) {
// 返回的元素列表
List<MarkElement> elements;
// 标识元素列表
List<MarkElement> tagElements = [];
// 生成tooltip内容
String textContent = '';
// 选中的数据 ({date: xxx, name: 线条1, points: xxx}, {date: xx, name: 线条2, points: xx}...)
final selectedTupleList = selectedTuples.values;
// 选中的数据的字段名列表 [date, name, points]
final fields = selectedTupleList.first.keys.toList();
// 选中的数据的第一个数据的第一个字段的值,放入到tooltip的第一行
/**
* 目前的数据结构是:
* 2023-11-24
*/
textContent = '${selectedTupleList.first[fields.first]}';
// 遍历选中的数据(几条线几条数据),将每个数据的第二个字段和第三个字段的值放入到tooltip的第二行和第三行
for (var original in selectedTupleList) {
final domainField = fields[1];
final measureField = fields.last;
/**
* 遍历结束后的数据结构是:
* 2023-11-24
* 线条1: 123
* 线条2: 456
* ....
*/
textContent += '\n ${original[domainField]}: ${original[measureField]}';
}
// 提出一些变量
const textStyle = TextStyle(fontSize: 12, color: Colors.black, height: 2);
const padding = EdgeInsets.all(5);
const align = Alignment.topRight;
const offset = Offset(5, -5);
const elevation = 1.0;
const backgroundColor = Colors.white;
final painter = TextPainter(
text: TextSpan(text: textContent, style: textStyle),
textDirection: ui.TextDirection.ltr,
);
painter.layout();
// tooltip的宽高
final width = padding.left + painter.width + padding.right;
final height = padding.top + painter.height + padding.bottom;
// tooltip的位置
// 大概根据中间的数据判断算了下位置,避免一直在左或右,边界超出屏幕
final move = anchor < const Offset(250, 90)
? anchor + offset - const Offset(-10, -40)
: anchor + Offset(-width - 20, 40);
final paintPoint = getBlockPaintPoint(
move,
width,
height,
align,
);
final window = Rect.fromLTWH(
paintPoint.dx - 10, //横向位置
paintPoint.dy,
width + 20,
height,
);
var textPaintPoint = paintPoint + padding.topLeft;
// 生成tooltip线条前的标识元素
for (int i = 0; i < selectedTupleList.length; i++) {
tagElements.add(
LabelElement(
text: '●',
anchor: textPaintPoint + padding.topLeft + Offset(-15, 26 + i * 23),
style: LabelStyle(
textStyle: TextStyle(
color: Defaults.colors10[i],
fontWeight: FontWeight.w900,
fontSize: 12),
align: Alignment.bottomRight)),
);
}
elements = <MarkElement>[
RectElement(
rect: window,
style: PaintStyle(fillColor: backgroundColor, elevation: elevation)),
...tagElements,
LabelElement(
text: textContent,
anchor: textPaintPoint,
style:
LabelStyle(textStyle: textStyle, align: Alignment.bottomRight)),
];
return elements;
}
效果
整体代码
// linePage.dart
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:graphic/graphic.dart';
import 'dart:ui' as ui;
import './components/static/data.dart';
class linePage extends StatelessWidget {
linePage({
super.key});
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
List<MarkElement> simpleTooltip(
Size size,
Offset anchor,
Map<int, Tuple> selectedTuples,
) {
// 返回的元素列表
List<MarkElement> elements;
// 标识元素列表
List<MarkElement> tagElements = [];
// 生成tooltip内容
String textContent = '';
// 选中的数据 ({date: xxx, name: 线条1, points: xxx}, {date: xx, name: 线条2, points: xx}...)
final selectedTupleList = selectedTuples.values;
// 选中的数据的字段名列表 [date, name, points]
final fields = selectedTupleList.first.keys.toList();
// 选中的数据的第一个数据的第一个字段的值,放入到tooltip的第一行
/**
* 目前的数据结构是:
* 2023-11-24
*/
textContent = '${selectedTupleList.first[fields.first]}';
// 遍历选中的数据(几条线几条数据),将每个数据的第二个字段和第三个字段的值放入到tooltip的第二行和第三行
for (var original in selectedTupleList) {
final domainField = fields[1];
final measureField = fields.last;
/**
* 遍历结束后的数据结构是:
* 2023-11-24
* 线条1: 123
* 线条2: 456
* ....
*/
textContent += '\n ${original[domainField]}: ${original[measureField]}';
}
// 提出一些变量
const textStyle = TextStyle(fontSize: 12, color: Colors.black, height: 2);
const padding = EdgeInsets.all(5);
const align = Alignment.topRight;
const offset = Offset(5, -5);
const elevation = 1.0;
const backgroundColor = Colors.white;
final painter = TextPainter(
text: TextSpan(text: textContent, style: textStyle),
textDirection: ui.TextDirection.ltr,
);
painter.layout();
// tooltip的宽高
final width = padding.left + painter.width + padding.right;
final height = padding.top + painter.height + padding.bottom;
// tooltip的位置
// 大概根据中间的数据判断算了下位置,避免一直在左或右,边界超出屏幕
final move = anchor < const Offset(250, 90)
? anchor + offset - const Offset(-10, -40)
: anchor + Offset(-width - 20, 40);
final paintPoint = getBlockPaintPoint(
move,
width,
height,
align,
);
final window = Rect.fromLTWH(
paintPoint.dx - 10, //横向位置
paintPoint.dy,
width + 20,
height,
);
var textPaintPoint = paintPoint + padding.topLeft;
// 生成tooltip线条前的标识元素
for (int i = 0; i < selectedTupleList.length; i++) {
tagElements.add(
LabelElement(
text: '●',
anchor: textPaintPoint + padding.topLeft + Offset(-15, 26 + i * 23),
style: LabelStyle(
textStyle: TextStyle(
color: Defaults.colors10[i],
fontWeight: FontWeight.w900,
fontSize: 12),
align: Alignment.bottomRight)),
);
}
elements = <MarkElement>[
RectElement(
rect: window,
style: PaintStyle(fillColor: backgroundColor, elevation: elevation)),
...tagElements,
LabelElement(
text: textContent,
anchor: textPaintPoint,
style:
LabelStyle(textStyle: textStyle, align: Alignment.bottomRight)),
];
return elements;
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Center(
child: Column(
children: <Widget>[
Container(
padding: const EdgeInsets.fromLTRB(20, 40, 20, 5),
child: const Text(
'Smooth Line and Area chart',
style: TextStyle(fontSize: 20),
),
),
Container(
margin: const EdgeInsets.only(top: 10),
width: 350,
height: 300,
child: Chart(
// 数据源
data: invalidData,
// 变量配置
variables: {
'date': Variable(
accessor: (Map map) => map['date'] as String,
scale: OrdinalScale(
tickCount: 5, // x轴刻度数量
),
),
'name': Variable(
accessor: (Map map) => map['name'] as String,
),
'points': Variable(
accessor: (Map map) => (map['points'] ?? double.nan) as num,
),
},
marks: [
// 线条
LineMark(
// 如果单线条加name则必须有position属性配置,否则是一条直线
position:
Varset('date') * Varset('points') / Varset('name'),
shape: ShapeEncode(
value: BasicLineShape(smooth: true),
),
// 粗细
size: SizeEncode(value: 1.5),
),
// 线条与X轴之间区域填充
AreaMark(
// 如果单线条加name则必须有position属性配置,否则不显示
position:
Varset('date') * Varset('points') / Varset('name'),
shape: ShapeEncode(
value:
BasicAreaShape(smooth: true), // smooth: true 使线条变得平滑
),
color: ColorEncode(
value: Colors.pink.withAlpha(80),
),
),
],
// 坐标轴配置
axes: [
Defaults.horizontalAxis,
Defaults.verticalAxis,
],
selections: {
'touchMove': PointSelection(
on: {
GestureType.scaleUpdate,
GestureType.tapDown,
GestureType.hover,
GestureType.longPressMoveUpdate
},
dim: Dim.x,
)
},
// 触摸弹框提示
tooltip: TooltipGuide(
// 跟随鼠标位置
// followPointer: [false, true],
// align: Alignment.topLeft, // 弹框对于点击位置的对齐方式
// offset: const Offset(-20, -20), // 偏移量
// 使用自定义需要注释上面的一些配置
renderer: simpleTooltip,
),
// 十字准线
crosshair: CrosshairGuide(followPointer: [false, true]),
),
),
Container(
padding: const EdgeInsets.fromLTRB(20, 40, 20, 5),
child: const Text(
'Group interactions',
style: TextStyle(fontSize: 20),
),
),
Container(
margin: const EdgeInsets.only(top: 10),
width: 350,
height: 300,
child: Chart(
data: invalidData1,
variables