├── .gitignore ├── Qt-LineChart.pro ├── README.md ├── line_chart ├── linechart.cpp └── linechart.h ├── main.cpp ├── mainwindow.cpp ├── mainwindow.h ├── mainwindow.ui └── screenshot.gif /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used to ignore files which are generated 2 | # ---------------------------------------------------------------------------- 3 | 4 | *~ 5 | *.autosave 6 | *.a 7 | *.core 8 | *.moc 9 | *.o 10 | *.obj 11 | *.orig 12 | *.rej 13 | *.so 14 | *.so.* 15 | *_pch.h.cpp 16 | *_resource.rc 17 | *.qm 18 | .#* 19 | *.*# 20 | core 21 | !core/ 22 | tags 23 | .DS_Store 24 | .directory 25 | *.debug 26 | Makefile* 27 | *.prl 28 | *.app 29 | moc_*.cpp 30 | ui_*.h 31 | qrc_*.cpp 32 | Thumbs.db 33 | *.res 34 | *.rc 35 | /.qmake.cache 36 | /.qmake.stash 37 | 38 | # qtcreator generated files 39 | *.pro.user* 40 | 41 | # xemacs temporary files 42 | *.flc 43 | 44 | # Vim temporary files 45 | .*.swp 46 | 47 | # Visual Studio generated files 48 | *.ib_pdb_index 49 | *.idb 50 | *.ilk 51 | *.pdb 52 | *.sln 53 | *.suo 54 | *.vcproj 55 | *vcproj.*.*.user 56 | *.ncb 57 | *.sdf 58 | *.opensdf 59 | *.vcxproj 60 | *vcxproj.* 61 | 62 | # MinGW generated files 63 | *.Debug 64 | *.Release 65 | 66 | # Python byte code 67 | *.pyc 68 | 69 | # Binaries 70 | # -------- 71 | *.dll 72 | *.exe 73 | 74 | -------------------------------------------------------------------------------- /Qt-LineChart.pro: -------------------------------------------------------------------------------- 1 | QT += core gui 2 | 3 | greaterThan(QT_MAJOR_VERSION, 4): QT += widgets 4 | 5 | CONFIG += c++11 6 | 7 | # The following define makes your compiler emit warnings if you use 8 | # any Qt feature that has been marked deprecated (the exact warnings 9 | # depend on your compiler). Please consult the documentation of the 10 | # deprecated API in order to know how to port your code away from it. 11 | DEFINES += QT_DEPRECATED_WARNINGS 12 | 13 | # You can also make your code fail to compile if it uses deprecated APIs. 14 | # In order to do so, uncomment the following line. 15 | # You can also select to disable deprecated APIs only up to a certain version of Qt. 16 | #DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0 17 | 18 | SOURCES += \ 19 | line_chart/linechart.cpp \ 20 | main.cpp \ 21 | mainwindow.cpp 22 | 23 | HEADERS += \ 24 | line_chart/linechart.h \ 25 | mainwindow.h 26 | 27 | INCLUDEPATH += \ 28 | line_chart/ 29 | 30 | FORMS += \ 31 | mainwindow.ui 32 | 33 | # Default rules for deployment. 34 | qnx: target.path = /tmp/$${TARGET}/bin 35 | else: unix:!android: target.path = /opt/$${TARGET}/bin 36 | !isEmpty(target.path): INSTALLS += target 37 | 38 | DISTFILES += \ 39 | README.md \ 40 | screenshot.gif 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Qt折线图 2 | === 3 | 4 | 带动画、带交互的折线图 5 | 6 | ## 特点 7 | 8 | 1. 动态增删数值 9 | 2. 自适应显示坐标轴数值 10 | 3. 鼠标悬浮显示十字对准线 11 | 4. 鼠标靠近点自动贴附 12 | 5. 支持直线与平滑曲线效果 13 | 6. 自定义点的显示类型与大小 14 | 7. 自适应点的数值显示位置 15 | 8. 根据指定锚点缩放 16 | 9. 平滑的横向移动 17 | 10. 选中的纵向渐变效果 18 | 19 | 20 | 21 | ## 缺点 22 | 23 | - 目前只支持 int 类型的 x、y 24 | 25 | 26 | ![折线图](screenshot.gif) 27 | -------------------------------------------------------------------------------- /line_chart/linechart.cpp: -------------------------------------------------------------------------------- 1 | #include "linechart.h" 2 | #include 3 | #include 4 | #include 5 | 6 | LineChart::LineChart(QWidget *parent) : QWidget(parent) 7 | { 8 | setMouseTracking(true); 9 | } 10 | 11 | int LineChart::lineCount() const 12 | { 13 | return datas.size(); 14 | } 15 | 16 | void LineChart::setPointLineType(int t) 17 | { 18 | this->pointLineType = t; 19 | update(); 20 | } 21 | 22 | void LineChart::setPointValueType(int t) 23 | { 24 | this->pointValueType = t; 25 | update(); 26 | } 27 | 28 | void LineChart::setPointDotType(int t) 29 | { 30 | this->pointDotType = t; 31 | update(); 32 | } 33 | 34 | void LineChart::setPointDotRadius(int r) 35 | { 36 | this->pointDotRadius = r; 37 | update(); 38 | } 39 | 40 | void LineChart::setLabelSpacing(int s) 41 | { 42 | this->labelSpacing = s; 43 | } 44 | 45 | void LineChart::addLine(ChartData data) 46 | { 47 | saveRange(); 48 | // 检查数据有效性 49 | if (!data.points.empty()) 50 | { 51 | QPoint first = data.points.first(); 52 | if (data.xMin == data.xMax) 53 | { 54 | data.xMin = data.xMax = first.x(); 55 | for (QPoint p: data.points) 56 | { 57 | if (data.xMin > p.x()) 58 | data.xMin = p.x(); 59 | else if (data.xMax < p.x()) 60 | data.xMax = p.x(); 61 | } 62 | } 63 | if (data.yMin == data.yMax) 64 | { 65 | data.yMin = data.yMax = first.y(); 66 | for (QPoint p: data.points) 67 | { 68 | if (data.yMin > p.y()) 69 | data.yMin = p.y(); 70 | else if (data.yMax < p.y()) 71 | data.yMax = p.y(); 72 | } 73 | } 74 | } 75 | Q_ASSERT(data.xLabels.empty() || data.xLabels.size() == data.points.size()); 76 | 77 | // 新增的数据对当前视图的影响 78 | if (datas.empty()) // 第一次传入数据 79 | { 80 | displayXMin = data.xMin; 81 | displayXMax = data.xMax; 82 | displayYMin = data.yMin; 83 | displayYMax = data.yMax; 84 | } 85 | else 86 | { 87 | // 第二次及之后传入数据 88 | displayXMin = qMin(displayXMin, data.xMin); 89 | displayXMax = qMax(displayXMax, data.xMax); 90 | displayYMin = qMin(displayYMin, data.yMin); 91 | displayYMax = qMax(displayYMax, data.yMax); 92 | } 93 | 94 | // 新的label集合插入到现有X轴label中(多条数据融合) 95 | int index = 0; 96 | for (int i = 0; i < data.xLabels.size(); i++) 97 | { 98 | int x = data.points.at(i).x(); 99 | const QString& label = data.xLabels.at(i); 100 | while (index < xLabels.size() && xLabelPoss.at(index) < x) 101 | index++; 102 | if (index >= xLabels.size()) // x超出范围了 103 | { 104 | xLabels.append(label); 105 | xLabelPoss.append(x); 106 | } 107 | else if (xLabelPoss.at(index) == x) // 一样的x,新的label,跳过 108 | { 109 | continue; 110 | } 111 | else if (xLabelPoss.at(index) > x) 112 | { 113 | xLabels.insert(index, label); 114 | xLabelPoss.insert(index, x); 115 | } 116 | else 117 | { 118 | qWarning() << data.points; 119 | qWarning() << data.xLabels; 120 | qWarning() << this->xLabels; 121 | qWarning() << this->xLabelPoss; 122 | qWarning() << index << x; 123 | Q_ASSERT(false); 124 | } 125 | } 126 | 127 | datas.append(data); 128 | 129 | startRangeAnimation(); 130 | } 131 | 132 | void LineChart::removeLine(int index) 133 | { 134 | Q_ASSERT(index < datas.size()); 135 | datas.removeAt(index); 136 | update(); 137 | } 138 | 139 | void LineChart::addPoint(int index, int x, int y) 140 | { 141 | saveRange(); 142 | Q_ASSERT(index < datas.size()); 143 | displayXMin = qMin(displayXMin, x); 144 | displayXMax = qMax(displayXMax, x); 145 | displayYMin = qMin(displayYMin, y); 146 | displayYMax = qMax(displayYMax, y); 147 | 148 | datas[index].points.append(QPoint(x, y)); 149 | startRangeAnimation(); 150 | } 151 | 152 | void LineChart::addPoint(int index, int x, int y, const QString &label) 153 | { 154 | bool inserted = false; 155 | for (int i = xLabels.size() - 1; i >= 0; i--) 156 | { 157 | if (xLabelPoss.at(i) == x) 158 | break; 159 | if (xLabelPoss.at(i) < x) 160 | { 161 | xLabels.insert(i + 1, label); 162 | xLabelPoss.insert(i + 1, x); 163 | inserted = true; 164 | break; 165 | } 166 | } 167 | if (!inserted) 168 | { 169 | xLabels.insert(0, label); 170 | xLabelPoss.insert(0, x); 171 | } 172 | addPoint(index, x, y); 173 | } 174 | 175 | void LineChart::removeFirst(int index) 176 | { 177 | Q_ASSERT(index <= datas.size()); 178 | if (datas.at(index).points.empty()) 179 | return ; 180 | bool equal = (displayXMin == datas.at(index).points.first().x()); 181 | datas[index].points.removeFirst(); 182 | 183 | // 调整最小值 184 | if (equal) 185 | { 186 | saveRange(); 187 | int newXMin = displayXMax; 188 | for (int i = 0; i < datas.size(); i++) 189 | if (!datas.at(i).points.empty()) 190 | { 191 | int x = datas.at(i).points.first().x(); 192 | if (x < newXMin) 193 | newXMin = x; 194 | } 195 | displayXMin = newXMin; 196 | startRangeAnimation(); 197 | } 198 | } 199 | 200 | /// 更新各个锚点 201 | void LineChart::updateAnchors() 202 | { 203 | selectXStart = getValueByCursorPos(pressPos); 204 | if (selecting) 205 | { 206 | selectXEnd = getValueByCursorPos(hoverPos); 207 | } 208 | else 209 | { 210 | selectXEnd = selectXStart; 211 | } 212 | emit signalSelectRangeChanged(selectXStart, selectXEnd); 213 | } 214 | 215 | void LineChart::zoom(double prop) 216 | { 217 | if ((displayXMax - displayXMin) * prop < 2) 218 | return ; 219 | 220 | saveRange(); 221 | displayXMin = selectXStart - int((selectXStart - displayXMin) * prop); 222 | displayXMax = selectXStart + int((displayXMax - selectXStart) * prop); 223 | startRangeAnimation(); 224 | updateAnchors(); 225 | } 226 | 227 | void LineChart::moveHorizontal(int x) 228 | { 229 | saveRange(); 230 | displayXMin += x; 231 | displayXMax += x; 232 | startRangeAnimation(); 233 | updateAnchors(); 234 | } 235 | 236 | void LineChart::zoomIn() 237 | { 238 | zoom(0.5); 239 | } 240 | 241 | void LineChart::zoomOut() 242 | { 243 | zoom(2); 244 | } 245 | 246 | void LineChart::paintEvent(QPaintEvent *event) 247 | { 248 | QWidget::paintEvent(event); 249 | 250 | QPainter painter(this); 251 | painter.setRenderHint(QPainter::Antialiasing, true); 252 | 253 | /// 边界 254 | contentRect = QRect(paddings.left(), paddings.top(), 255 | width() - paddings.left() - paddings.width(), 256 | height() - paddings.top() - paddings.height()); 257 | painter.setPen(QPen(borderColor, 0.5)); 258 | painter.drawRect(contentRect); 259 | 260 | const int xMin = animatingXMin ? _animatedXMin : displayXMin, 261 | xMax = animatingXMax ? _animatedXMax : displayXMax; 262 | const int yMin = animatingYMin ? _animatedYMin : displayYMin, 263 | yMax = animatingYMax ? _animatedYMax : displayYMax; 264 | 265 | if (datas.empty() || xMin >= xMax || yMin >= yMax) 266 | return ; 267 | 268 | QFontMetrics fm(painter.font()); 269 | int lineSpacing = fm.height(); 270 | QPoint accessNearestPos = hoverPos; 271 | int accessMinDis = 0x3f3f3f3f; 272 | 273 | /// 画线条与数值 274 | painter.save(); 275 | QPainterPath contentPath; 276 | painter.setClipRect(contentRect); 277 | for (int i = 0; i < datas.size(); i++) 278 | { 279 | // 计算点要绘制的所有坐标 280 | QList displayPoints; 281 | const ChartData& line = datas.at(i); 282 | for (int j = 0; j < line.points.size(); j++) 283 | { 284 | const QPoint& pt = line.points.at(j); 285 | const QPoint contentPt( 286 | contentRect.width() * (pt.x() - xMin) / (xMax - xMin), 287 | contentRect.height() * (pt.y() - yMin) / (yMax - yMin) 288 | ); 289 | const QPoint dispt( 290 | contentRect.left() + contentPt.x(), 291 | contentRect.bottom() - contentPt.y() 292 | ); 293 | displayPoints.append(dispt); 294 | } 295 | 296 | // 连线 297 | if (pointLineType && displayPoints.size() > 1) 298 | { 299 | // 源码参考:https://github.com/AlloyTeam/curvejs/blob/master/src/smooth-curve.js 300 | const auto& points = displayPoints; 301 | QPainterPath path; 302 | if (pointLineType == 1) // 直线 303 | { 304 | path.moveTo(points.first()); 305 | for (int i = 1; i < points.size(); i++) 306 | path.lineTo(points.at(i)); 307 | } 308 | else if (pointLineType == 2) // 二次贝塞尔曲线 309 | { 310 | path.moveTo(points.at(0)); 311 | for (int i = 1; i < points.size() - 1; i++) 312 | { 313 | if (i == points.size() - 2) 314 | { 315 | path.quadTo(points.at(i), points.at(i + 1)); 316 | } 317 | else 318 | { 319 | path.quadTo(points.at(i), 320 | QPoint((points.at(i).x() + points.at(i+1).x())/2, 321 | (points.at(i).y() + points.at(i+1).y())/2)); 322 | } 323 | } 324 | } 325 | else if (pointLineType == 3) // 三次贝塞尔曲线 326 | { 327 | // 算法参考:https://juejin.cn/post/6844903477273952270 328 | // 源码参考:https://github.com/AlloyTeam/curvejs/blob/master/asset/smooth.html 329 | double rt = 0.2; // 平滑度 330 | QList controlPoints; 331 | int count = points.size() - 2; 332 | for (int i = 0; i < count; i++) 333 | { 334 | QPoint a = points.at(i), b = points.at(i+1), c = points.at(i+2); 335 | Vector2D v1(a - b); 336 | Vector2D v2(c - b); 337 | double v1Len = v1.length(), v2Len = v2.length(); 338 | Vector2D centerV = (v1.normalize() + v2.normalize()).normalize(); 339 | 340 | Vector2D ncp1(centerV.y(), centerV.x() * - 1); 341 | Vector2D ncp2(centerV.y() * -1, centerV.x()); 342 | if (ncp1.angle(v1) < 90) 343 | { 344 | Vector2D p1 = ncp1 * (v1Len * rt) + b; 345 | Vector2D p2 = ncp2 * (v2Len * rt) + b; 346 | controlPoints.append(p1); 347 | controlPoints.append(p2); 348 | } 349 | else 350 | { 351 | Vector2D p1 = ncp1 * (v2Len * rt) + b; 352 | Vector2D p2 = ncp2 * (v1Len * rt) + b; 353 | controlPoints.append(p2); 354 | controlPoints.append(p1); 355 | } 356 | } 357 | 358 | path.moveTo(points.at(0)); 359 | path.cubicTo(points.at(0), controlPoints.at(0), points.at(1)); 360 | for (int i = 1; i < count; i++) 361 | { 362 | path.cubicTo(controlPoints.at(i * 2 - 1), controlPoints.at(i * 2), QPointF(points.at(i+1))); 363 | } 364 | path.cubicTo(controlPoints.last(), points.last(), points.last()); 365 | } 366 | painter.setPen(line.color); 367 | painter.drawPath(path); 368 | 369 | // 画选区效果 370 | if (selecting) 371 | { 372 | int startX = pressPos.x(), endX = hoverPos.x(); 373 | QPainterPath downPath = path; 374 | downPath.lineTo(points.last().x(), contentRect.bottom()); 375 | downPath.lineTo(points.first().x(), contentRect.bottom()); 376 | downPath.lineTo(points.first()); 377 | QRect clipRect(startX, contentRect.top(), endX - startX, contentRect.height()); 378 | painter.save(); 379 | painter.setClipRect(clipRect); 380 | QLinearGradient lg = QLinearGradient(QPointF(0, 0), QPointF(0, contentRect.height())); 381 | QColor c = line.color; 382 | c.setAlpha(line.color.alpha() / 3); 383 | lg.setColorAt(0.0, c); 384 | c.setAlpha(4); 385 | lg.setColorAt(1.0, c); 386 | painter.fillPath(downPath, lg); 387 | painter.restore(); 388 | } 389 | } 390 | 391 | // 绘制点的小圆点 392 | if (pointDotType) 393 | { 394 | for (int i = 0; i < displayPoints.size(); i++) 395 | { 396 | const QPoint& pt = displayPoints.at(i); 397 | QRect pointRect(pt.x() - pointDotRadius, pt.y() - pointDotRadius, pointDotRadius * 2, pointDotRadius * 2); 398 | if (pointDotType == 1) // 空心圆 399 | { 400 | painter.drawEllipse(pointRect); 401 | } 402 | else if (pointDotType == 2) // 实心圆 403 | { 404 | QPainterPath path; 405 | path.addEllipse(pointRect); 406 | painter.fillPath(path, line.color); 407 | } 408 | else if (pointDotType == 3) // 小方块 409 | { 410 | painter.fillRect(pointRect, line.color); 411 | } 412 | } 413 | } 414 | 415 | // 绘制所有点的数值 416 | if (pointValueType) 417 | { 418 | const QList& points = line.points; 419 | for (int i = 0; i < points.size(); i++) 420 | { 421 | QString text = QString::number(points.at(i).y()); 422 | QPoint pos = displayPoints.at(i); 423 | if (pos.x() < contentRect.left() || pos.x() > contentRect.right()) 424 | continue; 425 | int w = fm.horizontalAdvance(text); 426 | int x = pos.x() - w / 2; 427 | int y = pos.y() - pointDotRadius - fm.leading(); // 默认是强制正上方位置 428 | if (pointValueType == 2) // 所有,自动选取合适的 429 | { 430 | if (i == 0 && i < points.size() - 1) 431 | { 432 | if (points.at(i + 1).y() > points.at(i).y()) // 显示在下方 433 | y = pos.y() + lineSpacing + pointDotRadius; 434 | } 435 | else if (i > 0 && i < points.size() - 1) 436 | { 437 | int v = points.at(i).y(); 438 | int vl = points.at(i-1).y(); 439 | int vr = points.at(i+1).y(); 440 | if (vl > v && vr > v) // V型,显示在下面 441 | y = pos.y() + lineSpacing + pointDotRadius; 442 | else if (vl < v && vr > v) // 显示偏左 443 | x -= w / 2; 444 | else if (vl > v && vr < v) // 显示偏右 445 | x += w / 2; 446 | // TODO: 还可以根据两侧斜率来进一步优化 447 | } 448 | else if (i == points.size() - 1 && points.size() > 1) 449 | { 450 | if (points.at(i-1).y() > points.at(i).y()) // 显示在下方 451 | y = pos.y() + lineSpacing + pointDotRadius; 452 | } 453 | } 454 | else if (pointValueType == 3) // 选合适的进行显示 455 | { 456 | // TODO: 判断密集程度 457 | continue; 458 | } 459 | 460 | // 判断超出边界 461 | if (x - w / 2 < contentRect.left()) 462 | x = contentRect.left(); 463 | else if (x + w > contentRect.right()) 464 | x = contentRect.right() - w; 465 | if (y - lineSpacing < contentRect.top()) 466 | y = pos.y() + lineSpacing + pointDotRadius; 467 | else if (y > contentRect.bottom()) 468 | y = pos.y() - pointDotRadius - fm.leading(); 469 | 470 | // 绘制文字 471 | painter.drawText(QPoint(x, y), text); 472 | } 473 | 474 | } 475 | 476 | // 计算距离最近的点 477 | if (hovering && contentRect.contains(hoverPos)) 478 | { 479 | for (int i = 0; i < displayPoints.size(); i++) 480 | { 481 | const QPoint& pt = displayPoints.at(i); 482 | if (qAbs(hoverPos.x() - pt.x()) > nearDis || qAbs(hoverPos.y() - pt.y()) > nearDis) 483 | continue; 484 | int distance = (hoverPos - displayPoints.at(i)).manhattanLength(); 485 | if (distance < accessMinDis) 486 | { 487 | accessNearestPos = displayPoints.at(i); 488 | accessMinDis = distance; 489 | } 490 | } 491 | } 492 | } 493 | painter.restore(); 494 | 495 | if (accessMinDis != 0x3f3f3f3f) 496 | { 497 | painter.save(); 498 | painter.setPen(hightlightColor); 499 | QRect pointRect(accessNearestPos.x() - pointDotRadius, accessNearestPos.y() - pointDotRadius, pointDotRadius * 2, pointDotRadius * 2); 500 | if (pointDotType == 0 || pointDotType == 1) // 空心圆 501 | { 502 | painter.drawEllipse(pointRect); 503 | } 504 | else if (pointDotType == 2) // 实心圆 505 | { 506 | QPainterPath path; 507 | path.addEllipse(pointRect); 508 | painter.fillPath(path, hightlightColor); 509 | } 510 | else if (pointDotType == 3) // 小方块 511 | { 512 | painter.fillRect(pointRect, hightlightColor); 513 | } 514 | painter.restore(); 515 | } 516 | 517 | 518 | /// 画坐标轴 519 | // 画X轴数值 520 | int lastRight = 0; // 上一次绘图的位置 521 | if (usePointXLabels && xLabels.size()) // 使用传入的label,可以是和数据对应的任意字符串 522 | { 523 | for (int i = 0; i < xLabels.size(); i++) 524 | { 525 | int val = xLabelPoss.at(i); // 数据x 526 | int x = contentRect.width() * (val - xMin) / (xMax - xMin); // 视图x 527 | int w = fm.horizontalAdvance(xLabels.at(i)); // 文字宽度 528 | int l = x - w / 2, r = x + w / 2; // 绘制的文字x范围 529 | if (!i || l > lastRight + labelSpacing || (i == xLabels.size() - 1 && (l = lastRight + labelSpacing))) 530 | { 531 | painter.drawText(QPoint(l + contentRect.left(), contentRect.bottom() + lineSpacing), xLabels.at(i)); 532 | lastRight = r; 533 | } 534 | } 535 | } 536 | else // 使用 xMin ~ xMax 的 int 537 | { 538 | bool highlighted = false; 539 | int maxTextWidth = qMax(fm.horizontalAdvance(QString::number(xMin)), fm.horizontalAdvance(QString::number(xMax))); 540 | int displayCount = qMax((contentRect.width() + labelSpacing) / (maxTextWidth + labelSpacing), 1); // 最多显示多少个标签 541 | int step = qMax((xMax - xMin + displayCount) / displayCount, 1); 542 | for (int i = xMin; i <= xMax; i += step) 543 | { 544 | int val = i; 545 | if (val > xMax - step) 546 | val = xMax; // 确保最大值一直显示 547 | int x = contentRect.width() * (val - xMin) / (xMax - xMin); // 视图x 548 | int w = fm.horizontalAdvance(QString::number(val)); // 文字宽度 549 | if (hovering && contentRect.contains(accessNearestPos) && (x + contentRect.left() >= accessNearestPos.x() - w && x + contentRect.left() <= accessNearestPos.x() + w)) 550 | { 551 | x = accessNearestPos.x() - contentRect.left(); 552 | val = (xMax - xMin) * x / contentRect.width() + xMin; 553 | w = fm.horizontalAdvance(QString::number(val)); 554 | highlighted = true; 555 | } 556 | int l = x - w / 2, r = x + w / 2; 557 | 558 | if (highlighted) 559 | { 560 | painter.save(); 561 | painter.setPen(hightlightColor); 562 | } 563 | painter.drawText(QPoint(l + contentRect.left(), contentRect.bottom() + lineSpacing), QString::number(val)); 564 | if (highlighted) 565 | { 566 | painter.restore(); 567 | highlighted = false; 568 | } 569 | lastRight = r; 570 | } 571 | } 572 | 573 | // 画Y轴数值 574 | for (int k = 0; k < datas.size() && k < 1; k++) 575 | { 576 | bool highlighted = false; 577 | int displayCount = qMax((contentRect.height() + labelSpacing) / (lineSpacing + labelSpacing), 1); 578 | int step = qMax((yMax - yMin + displayCount) / displayCount, 1); 579 | for (int i = yMin; i <= yMax; i += step) 580 | { 581 | if (i == yMin && yMin == 0 && xMin == 0) // X轴有0了,Y轴不重复显示 582 | continue; 583 | int val = i; 584 | if (val > yMax - step) 585 | { 586 | if (val < yMax - step / 2) 587 | i = yMax - step; 588 | else 589 | val = yMax; // 确保最大值一直显示 590 | } 591 | int y = contentRect.height() * (val - yMin) / (yMax - yMin); 592 | y = contentRect.bottom() - y; 593 | if (hovering && contentRect.contains(accessNearestPos) && (y >= accessNearestPos.y() - lineSpacing && y <= accessNearestPos.y() + lineSpacing)) 594 | { 595 | y = accessNearestPos.y(); 596 | val = (yMax - yMin) * (contentRect.bottom() - y) / contentRect.height() + yMin; 597 | highlighted = true; 598 | } 599 | int w = fm.horizontalAdvance(QString::number(val)); 600 | 601 | if (highlighted) 602 | { 603 | painter.save(); 604 | painter.setPen(hightlightColor); 605 | } 606 | if (k == 0) // 左边 607 | { 608 | painter.drawText(QPoint(contentRect.left() - labelSpacing - w, y + lineSpacing / 2), QString::number(val)); 609 | } 610 | else // 右边 611 | { 612 | painter.drawText(QPoint(contentRect.right() + labelSpacing, y + lineSpacing / 2), QString::number(val)); 613 | } 614 | if (highlighted) 615 | { 616 | painter.restore(); 617 | highlighted = false; 618 | } 619 | } 620 | } 621 | 622 | /// 交互 623 | // 画悬浮的十字对准线 624 | if (showCrossOnPressing && hovering && contentRect.contains(accessNearestPos)) 625 | { 626 | painter.setPen(QPen(hightlightColor, 0.5, Qt::DashLine)); 627 | painter.drawLine(contentRect.left(), accessNearestPos.y(), contentRect.right(), accessNearestPos.y()); 628 | painter.drawLine(accessNearestPos.x(), contentRect.top(), accessNearestPos.x(), contentRect.bottom()); 629 | } 630 | 631 | // 画鼠标点击的垂直线 632 | painter.setPen(selectColor); 633 | if (contentRect.contains(pressPos)) 634 | { 635 | painter.drawLine(selectPos, contentRect.top(), selectPos, contentRect.bottom()); 636 | } 637 | } 638 | 639 | void LineChart::enterEvent(QEvent *event) 640 | { 641 | QWidget::enterEvent(event); 642 | 643 | hovering = true; 644 | } 645 | 646 | void LineChart::leaveEvent(QEvent *event) 647 | { 648 | QWidget::leaveEvent(event); 649 | 650 | hovering = false; 651 | } 652 | 653 | void LineChart::mouseMoveEvent(QMouseEvent *event) 654 | { 655 | QWidget::mouseMoveEvent(event); 656 | 657 | hoverPos = event->pos(); 658 | 659 | if (pressing) 660 | { 661 | selecting = pressing && hovering && contentRect.contains(pressPos) && contentRect.contains(hoverPos) 662 | && (pressPos - hoverPos).manhattanLength() > QApplication::startDragDistance(); 663 | updateAnchors(); 664 | } 665 | update(); 666 | } 667 | 668 | void LineChart::mousePressEvent(QMouseEvent *event) 669 | { 670 | QWidget::mousePressEvent(event); 671 | 672 | if (event->button() == Qt::LeftButton) 673 | { 674 | pressing = true; 675 | selecting = false; 676 | pressPos = hoverPos = event->pos(); 677 | if (enableSelect && contentRect.contains(pressPos) && displayXMax > displayXMin) 678 | { 679 | selectPos = event->x(); 680 | updateAnchors(); 681 | } 682 | else 683 | { 684 | selectPos = 0; 685 | pressPos = hoverPos = releasePos = QPoint(); 686 | } 687 | } 688 | 689 | update(); 690 | } 691 | 692 | void LineChart::mouseReleaseEvent(QMouseEvent *event) 693 | { 694 | QWidget::mouseReleaseEvent(event); 695 | 696 | if (event->button() == Qt::LeftButton) 697 | { 698 | pressing = false; 699 | selecting = false; 700 | updateAnchors(); 701 | 702 | releasePos = hoverPos; 703 | if (contentRect.contains(pressPos) && contentRect.contains(releasePos)) 704 | { 705 | if ((releasePos - pressPos).manhattanLength() > QApplication::startDragDistance()) // 选区松开 706 | { 707 | // TODO: 放大选区位置 708 | } 709 | } 710 | } 711 | update(); 712 | } 713 | 714 | void LineChart::wheelEvent(QWheelEvent *event) 715 | { 716 | QWidget::wheelEvent(event); 717 | 718 | auto modifiers = QApplication::keyboardModifiers(); 719 | int delta = event->delta(); 720 | if (modifiers & Qt::ControlModifier) // 缩放 721 | { 722 | if (delta > 0) 723 | zoomIn(); 724 | else if (delta < 0) 725 | zoomOut(); 726 | } 727 | else 728 | { 729 | moveHorizontal(-(displayXMax - displayXMin) * delta / contentRect.width()); 730 | } 731 | } 732 | 733 | void LineChart::setDisplayXMin(int v) 734 | { 735 | this->_animatedXMin = v; 736 | update(); 737 | } 738 | 739 | int LineChart::getDisplayXMin() const 740 | { 741 | return this->_animatedXMin; 742 | } 743 | 744 | void LineChart::setDisplayXMax(int v) 745 | { 746 | this->_animatedXMax = v; 747 | update(); 748 | } 749 | 750 | int LineChart::getDisplayXMax() const 751 | { 752 | return this->_animatedXMax; 753 | } 754 | 755 | void LineChart::setDisplayYMin(int v) 756 | { 757 | this->_animatedYMin = v; 758 | update(); 759 | } 760 | 761 | int LineChart::getDisplayYMin() const 762 | { 763 | return this->_animatedYMin; 764 | } 765 | 766 | void LineChart::setDisplayYMax(int v) 767 | { 768 | this->_animatedYMax = v; 769 | update(); 770 | } 771 | 772 | int LineChart::getDisplayYMax() const 773 | { 774 | return this->_animatedYMax; 775 | } 776 | 777 | void LineChart::saveRange() 778 | { 779 | _savedXMin = displayXMin; 780 | _savedXMax = displayXMax; 781 | _savedYMin = displayYMin; 782 | _savedYMax = displayYMax; 783 | } 784 | 785 | void LineChart::startRangeAnimation() 786 | { 787 | if (!enableAnimation) 788 | { 789 | update(); 790 | return ; 791 | } 792 | if (_savedXMin != displayXMin) 793 | startAnimation("display_x_min", _savedXMin, displayXMin, &animatingXMin); 794 | if (_savedXMax != displayXMax) 795 | startAnimation("display_x_max", _savedXMax, displayXMax, &animatingXMax); 796 | if (_savedYMin != displayYMin) 797 | startAnimation("display_y_min", _savedYMin, displayYMin, &animatingYMin); 798 | if (_savedYMax != displayYMax) 799 | startAnimation("display_y_max", _savedYMax, displayYMax, &animatingYMax); 800 | update(); 801 | } 802 | 803 | QPropertyAnimation *LineChart::startAnimation(const QByteArray &property, int start, int end, bool *flag, int duration, QEasingCurve curve) 804 | { 805 | *flag = true; 806 | QPropertyAnimation* ani = new QPropertyAnimation(this, property); 807 | ani->setStartValue(start); 808 | ani->setEndValue(end); 809 | ani->setDuration(duration); 810 | ani->setEasingCurve(curve); 811 | connect(ani, &QPropertyAnimation::stateChanged, this, [=](QPropertyAnimation::State state) { 812 | // 避免动画过程中的内存泄漏 813 | if (state == QPropertyAnimation::State::Stopped) 814 | ani->deleteLater(); 815 | }); 816 | connect(ani, &QPropertyAnimation::finished, this, [=]{ 817 | *flag = false; 818 | update(); 819 | }); 820 | ani->start(); 821 | return ani; 822 | } 823 | 824 | int LineChart::getValueByCursorPos(QPoint pos) 825 | { 826 | return (displayXMax - displayXMin) * (pos.x() - contentRect.left()) / contentRect.width() + displayXMin; 827 | } 828 | -------------------------------------------------------------------------------- /line_chart/linechart.h: -------------------------------------------------------------------------------- 1 | #ifndef LINECHART_H 2 | #define LINECHART_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | struct ChartData 13 | { 14 | QString title; 15 | QColor color = Qt::black; 16 | int xMin = 0; 17 | int xMax = 0; 18 | int yMin = 0; 19 | int yMax = 0; 20 | QList points; 21 | QList xLabels; // X显示的名字,可空,比如日期 22 | }; 23 | 24 | struct Vector2D : public QPointF 25 | { 26 | Vector2D(double x, double y) : QPointF(x, y) 27 | { 28 | } 29 | 30 | Vector2D(QPointF p) : QPointF(p) 31 | { 32 | } 33 | 34 | /// 向量长度 35 | double length() 36 | { 37 | return sqrt(x() * x() + y() * y()); 38 | } 39 | 40 | /// 转单位向量 41 | Vector2D normalize() 42 | { 43 | double len = length(); 44 | double inv; 45 | if (len < 1e-4) 46 | inv = 0; 47 | else 48 | inv = 1 / length(); 49 | return Vector2D(x() * inv, y() * inv); 50 | } 51 | 52 | /// 向量相加 53 | Vector2D operator+ (Vector2D v) 54 | { 55 | return Vector2D(x() + v.x(), y() + v.y()); 56 | } 57 | 58 | /// 向量翻倍 59 | Vector2D operator* (double f) 60 | { 61 | return Vector2D(x() * f, y() * f); 62 | } 63 | 64 | /// 内积 65 | double dot(Vector2D v) 66 | { 67 | return x() * v.x() + y() * v.y(); 68 | } 69 | 70 | /// 两个向量夹角 71 | double angle(Vector2D v) 72 | { 73 | return acos(dot(v) / (length() * v.length())) * 180 / M_PI; 74 | } 75 | }; 76 | 77 | class LineChart : public QWidget 78 | { 79 | Q_OBJECT 80 | Q_PROPERTY(int display_x_min READ getDisplayXMin WRITE setDisplayXMin) 81 | Q_PROPERTY(int display_x_max READ getDisplayXMax WRITE setDisplayXMax) 82 | Q_PROPERTY(int display_y_min READ getDisplayYMin WRITE setDisplayYMin) 83 | Q_PROPERTY(int display_y_max READ getDisplayYMax WRITE setDisplayYMax) 84 | 85 | public: 86 | LineChart(QWidget *parent = nullptr); 87 | 88 | int lineCount() const; 89 | void setPointLineType(int t); 90 | void setPointValueType(int t); 91 | void setPointDotType(int t); 92 | void setPointDotRadius(int r); 93 | void setLabelSpacing(int s); 94 | 95 | void addLine(ChartData data); 96 | void removeLine(int index); 97 | void addPoint(int index, int x, int y); 98 | void addPoint(int index, int x, int y, const QString& label); 99 | void removeFirst(int index); 100 | 101 | void updateAnchors(); 102 | void zoom(double prop); 103 | void moveHorizontal(int x); 104 | 105 | signals: 106 | void signalSelectRangeChanged(int start, int end); 107 | 108 | public slots: 109 | void zoomIn(); 110 | void zoomOut(); 111 | 112 | protected: 113 | void paintEvent(QPaintEvent *event) override; 114 | void enterEvent(QEvent *event) override; 115 | void leaveEvent(QEvent *event) override; 116 | void mouseMoveEvent(QMouseEvent *event) override; 117 | void mousePressEvent(QMouseEvent *event) override; 118 | void mouseReleaseEvent(QMouseEvent *event) override; 119 | void wheelEvent(QWheelEvent *event) override; 120 | 121 | private: 122 | void setDisplayXMin(int v); 123 | int getDisplayXMin() const; 124 | void setDisplayXMax(int v); 125 | int getDisplayXMax() const; 126 | void setDisplayYMin(int v); 127 | int getDisplayYMin() const; 128 | void setDisplayYMax(int v); 129 | int getDisplayYMax() const; 130 | 131 | void saveRange(); 132 | void startRangeAnimation(); 133 | QPropertyAnimation* startAnimation(const QByteArray &property, int start, int end, bool* flag, int duration = 300, QEasingCurve curve = QEasingCurve::OutQuad); 134 | 135 | int getValueByCursorPos(QPoint pos); 136 | 137 | private: 138 | // 数据 139 | QList datas; // 所有折线的数据 140 | 141 | // 界面 142 | QRect contentRect; // 显示的范围,实时刷新 143 | QRect paddings = QRect(32, 32, 32, 32); // 四周留白(width=right,height=bottom) 144 | QColor borderColor = Qt::gray; // 边界线颜色 145 | int labelSpacing = 2; // 标签间距 146 | 147 | // 信息显示 148 | bool autoResize = true; // 自动调整大小 149 | int displayXMin = 0, displayXMax = 0; // 显示的X轴范围 150 | int displayYMin = 0, displayYMax = 0; // 显示的Y轴范围 151 | bool usePointXLabels = true; // 优先使用点对应的label,还是相同间距的数值 152 | QList xLabels; // 显示的文字(可能少于值数量) 153 | QList xLabelPoss; 154 | int pointLineType = 3; // 连线类型:1直线,2二次贝塞尔曲线,3三次贝塞尔曲线(更精确但吃性能) 155 | int pointValueType = 2; // 数值显示位置:0无,1强制上方,2自动附近 156 | int pointDotType = 1; // 圆点类型:0无,1空心圆,2实心圆,3小方块 157 | int pointDotRadius = 2; // 圆点半径 158 | 159 | // 动画效果 160 | bool enableAnimation = true; 161 | int _savedXMin, _savedXMax; // 修改前的数值 162 | int _savedYMin, _savedYMax; 163 | bool animatingXMin = false, animatingXMax = false; // 是否正在动画中 164 | bool animatingYMin = false, animatingYMax = false; 165 | int _animatedXMin, _animatedXMax; // 动画中的数值(仅影响显示) 166 | int _animatedYMin, _animatedYMax; 167 | 168 | // 交互数据 169 | bool pressing = false; 170 | QPoint pressPos, releasePos; 171 | bool hovering = false; 172 | QPoint hoverPos; 173 | int nearDis = 8; // 四周这些距离内算是“附近” 174 | 175 | // 悬浮提示 176 | bool showCrossOnPressing = true; // 按下显示十字对准线 177 | QColor hightlightColor = QColor("#FF7300"); // 高亮颜色 178 | 179 | // 鼠标选择 180 | bool enableSelect = true; 181 | bool selecting = false; 182 | int selectPos = 0; // 最后一次鼠标点击的X像素(相对显示矩形) 183 | int selectXStart = 0, selectXEnd = 0; // 鼠标按下/松开的对应X值位置 184 | QColor selectColor = QColor("#F08080"); // 选择区域颜色 185 | 186 | // 缩放(仅针对X轴) 187 | bool enableScale = true; 188 | int displayXStart = 0, displayXEnd = 0; 189 | }; 190 | 191 | #endif // LINECHART_H 192 | -------------------------------------------------------------------------------- /main.cpp: -------------------------------------------------------------------------------- 1 | #include "mainwindow.h" 2 | 3 | #include 4 | 5 | int main(int argc, char *argv[]) 6 | { 7 | QApplication a(argc, argv); 8 | MainWindow w; 9 | w.show(); 10 | return a.exec(); 11 | } 12 | -------------------------------------------------------------------------------- /mainwindow.cpp: -------------------------------------------------------------------------------- 1 | #include "mainwindow.h" 2 | #include "ui_mainwindow.h" 3 | 4 | MainWindow::MainWindow(QWidget *parent) 5 | : QMainWindow(parent) 6 | , ui(new Ui::MainWindow) 7 | { 8 | ui->setupUi(this); 9 | 10 | connect(ui->widget, &LineChart::signalSelectRangeChanged, this, [=](int start, int end) { 11 | if (start != end) 12 | ui->label->setText("选中:" + QString::number(start) + " ~ " + QString::number(end)); 13 | else 14 | ui->label->setText("位置:" + QString::number(start)); 15 | }); 16 | 17 | 18 | { 19 | ChartData data; 20 | data.title = "一条线"; 21 | data.color = QColor("#6495ED"); 22 | data.xMin = data.yMin = 0; 23 | data.xMax = 100; 24 | data.yMax = 100; 25 | for (int i = 0; i <= 20; i++) 26 | { 27 | data.points.append(QPoint(i * 5, qrand() % 80 + 20)); 28 | // data.xLabels.append(QString::number(i * 5)); 29 | } 30 | 31 | ui->widget->addLine(data); 32 | } 33 | 34 | { 35 | ChartData data; 36 | data.title = "一条线"; 37 | data.color = QColor("#0DBF8C"); 38 | data.xMin = data.yMin = 0; 39 | data.xMax = 100; 40 | data.yMax = 100; 41 | for (int i = 0; i <= 20; i++) 42 | { 43 | data.points.append(QPoint(i * 5, qrand() % 30)); 44 | } 45 | ui->widget->addLine(data); 46 | } 47 | } 48 | 49 | MainWindow::~MainWindow() 50 | { 51 | delete ui; 52 | } 53 | 54 | void MainWindow::on_pushButton_clicked() 55 | { 56 | x += 5; 57 | ui->widget->addPoint(0, x, qrand() % 60 + 20); 58 | ui->widget->addPoint(1, x, qrand() % 30); 59 | } 60 | 61 | void MainWindow::on_pushButton_2_clicked() 62 | { 63 | for (int i = 0; i < ui->widget->lineCount(); i++) 64 | ui->widget->removeFirst(i); 65 | } 66 | 67 | void MainWindow::on_pushButton_3_clicked() 68 | { 69 | x += 5; 70 | ui->widget->addPoint(0, x, qrand() % 60 + 20); 71 | ui->widget->addPoint(1, x, qrand() % 30); 72 | 73 | for (int i = 0; i < ui->widget->lineCount(); i++) 74 | ui->widget->removeFirst(i); 75 | } 76 | -------------------------------------------------------------------------------- /mainwindow.h: -------------------------------------------------------------------------------- 1 | #ifndef MAINWINDOW_H 2 | #define MAINWINDOW_H 3 | 4 | #include 5 | 6 | QT_BEGIN_NAMESPACE 7 | namespace Ui { class MainWindow; } 8 | QT_END_NAMESPACE 9 | 10 | class MainWindow : public QMainWindow 11 | { 12 | Q_OBJECT 13 | 14 | public: 15 | MainWindow(QWidget *parent = nullptr); 16 | ~MainWindow(); 17 | 18 | private slots: 19 | void on_pushButton_clicked(); 20 | 21 | void on_pushButton_2_clicked(); 22 | 23 | void on_pushButton_3_clicked(); 24 | 25 | private: 26 | Ui::MainWindow *ui; 27 | int x = 100; // 用于动态插入的计数 28 | }; 29 | #endif // MAINWINDOW_H 30 | -------------------------------------------------------------------------------- /mainwindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 571 10 | 410 11 | 12 | 13 | 14 | MainWindow 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | add 34 | 35 | 36 | 37 | 38 | 39 | 40 | remove 41 | 42 | 43 | 44 | 45 | 46 | 47 | add+remove 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 0 59 | 0 60 | 571 61 | 23 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | LineChart 70 | QWidget 71 |
linechart.h
72 | 1 73 |
74 |
75 | 76 | 77 |
78 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwxyi/Qt-LineChart/6fe936230facb8d050df9b181ba81d6abab727b6/screenshot.gif --------------------------------------------------------------------------------