QCustomPlot Discussion and Comments

QCircularBuffer support for graphingReturn to overview

I have a project where I want to be able to display a variety of time-varying data. It is expected that the amount of time that will be displayed is constant and new values will arrive roughly periodically, but they will have corresponding timestamps. It seems that calling a function of the type

QCPGraph::setData(cost QCircularBuffer<double>& keys, const QCircularBuffer<double>& values, bool alreadySorted = false)

would be a useful use case for plotting this type of data. Unfortunately, QCPDataContainer doesn't seem to be written in a way that supports this type of work. Is there an alternative recommended way to handle this use case that avoids frequent reshuffling of memory?

Since QCP maintains not only a post-allocation buffer, but also a pre-allocation buffer in its default data storage, removing a slice from the front of the data and adding data there is very fast. In fact, the case where the incoming data is already sorted and has smaller keys than the existing data is detected and handled as efficient as possible: it just copies the keys/values to the existing pre-allocation space and avoids any unnecessary sorting or merging.

Also removing and inserting slices in the middle is usually efficient. It is true though that in this case, the preceding or succeeding data is moved in memory. For usual data densities (let's say below 100k points), this has a negligible impact on performance compared with the rendering, again due to pre-/post-allocation buffers and memory layout. QCP doesn't need to re-allocate memory as you re-add new data into the previously removed slice, just move between allocated and continuous/adjacent memory locations.

If your keys are always increasing (as is the case for a timestamp for example), a circular buffer doesn't really make sense as default. The fastest data structure for QCP's purpose has the data sorted by key, which for a circular structure would require extra work in terms of sorting across memory boundaries with an artificial boundary somewhere in the middle. I thus recommend simply adding the new data to the container using the pre-sorted argument if possible (it will recognize that it is always only appending data in terms of key). The unused data can be removed by the key that scrolls out of view, if you don't wish to keep it. In this situation, post-allocation will make memory de-/re-allocations a rare occasion.

Thank you. That was a very thorough explanation. It seems that the method you described should work for my purposes.

It appears as though this technique cannot be applied to QCPColorMapData containers? In my use case, I have a realtime spectogram showing radio data across a given band. So, you'll have new slices of data coming in, one at a time, at the top (or bottom) of the map, and everything else shifting down (or up) by one row. To avoid the constant shifting of memory data, I was tossing around the idea of subclassing QCPColorMapData and reimplementing it using boost::circular_buffer.

Am I in for a world of hurt here?

I've put together a hack that works for me to create a QCPColorMap with a circular buffer. Note there may be some typos in the below code, and I have not tested it in all modes (forward vs. backward, horizontal vs. vertical), but the general insanity is there.

diff -Nrub a/qcustomplot.cpp b/qcustomplot.cpp
--- a/qcustomplot.cpp	2022-11-06 10:54:46.000000000 -0500
+++ b/qcustomplot.cpp	2024-04-15 11:01:30.331952823 -0400
@@ -25845,12 +25845,15 @@
   
   \see setSize, setKeySize, setValueSize, setRange, setKeyRange, setValueRange
 */
-QCPColorMapData::QCPColorMapData(int keySize, int valueSize, const QCPRange &keyRange, const QCPRange &valueRange) :
+QCPColorMapData::QCPColorMapData(int keySize, int valueSize, const QCPRange &keyRange, const QCPRange &valueRange, bool circular, bool invert) :
   mKeySize(0),
   mValueSize(0),
   mKeyRange(keyRange),
   mValueRange(valueRange),
   mIsEmpty(true),
+  mCircular(circular),
+  mInvertDirection(invert),
+  mCircBufStartIndex(0),
   mData(nullptr),
   mAlpha(nullptr),
   mDataModified(true)
@@ -25902,6 +25905,9 @@
         memcpy(mAlpha, other.mAlpha, sizeof(mAlpha[0])*size_t(keySize*valueSize));
     }
     mDataBounds = other.mDataBounds;
+    mCircular = other.mCircular;
+    mInvertDirection = other.mInvertDirection;
+    mCircBufStartIndex = other.mCircBufStartIndex;
     mDataModified = true;
   }
   return *this;
@@ -25927,6 +25933,22 @@
     return 0;
 }
 
+double *QCPColorMapData::cellRow(int valueInex) {
+  if (mCircular && valueIndex >= 0 && valueIndex < mValueSize)
+  {
+    int offset;
+    if (mInvertDirection) {
+      offset = (mCircBufStartIndex + valueIndex*mKeySize) % (mKeySize*mValueSize);	// keyIndex == 0 means get the oldest data, then next-oldest, etc.
+    } else {
+      offset = (mCircBufStartIndex-mKeySize) - valueIndex*mKeySize;			// keyIndex == 0 means get the newest data, then next-newest, etc.
+      if (offset < 0) offset += (mKeySize*mValueSize);
+    }
+    return &mData[offset];
+  }
+  else
+    return nullptr;
+}
+
 /*!
   Returns the alpha map value of the cell with the indices \a keyIndex and \a valueIndex.
 
@@ -25943,6 +25965,16 @@
     return 255;
 }
 
+unsigned char *QCPColorMapData::alphaRow(int valueIndex) {
+  if (mCircular && mAlpha && valueIndex >= 0 && valueIndex < mValueSize)
+  {
+    const int offset = (mCircBufStartIndex + valueIndex*mKeySize) % (mKeySize*mValueSize);
+    return &mAlpha[offset];
+  }
+  else
+    return nullptr;
+}
+
 /*!
   Resizes the data array to have \a keySize cells in the key dimension and \a valueSize cells in
   the value dimension.
@@ -25983,6 +26015,7 @@
       createAlpha();
     
     mDataModified = true;
+    mCircBufStartIndex = 0;
   }
 }
 
@@ -26114,6 +26147,22 @@
     qDebug() << Q_FUNC_INFO << "index out of bounds:" << keyIndex << valueIndex;
 }
 
+void QCPColorMapData::setRow(int valueIndex, int nPts, const double *z)
+{
+  if (valueIndex >= 0 && valueIndex < mValueSize && nPts == mKeySize)
+  {
+    memcpy( &mData[valueIndex*mKeySize], z, nPts * sizeof(double) );
+    const double *minVal = std::min_element(z, z+nPts);
+    if (*minVal < mDataBounds.lower)
+      mDataBounds.lower = *minVal;	// This does not account for the possibility that we may have replaced the existing lower or upper bound with this setRow
+    const double *maxVal = std::max_element(z, z+nPts);
+    if (*maxVal > mDataBounds.upper)
+      mDataBounds.upper = *maxVal;
+    mDataModified = true;
+  } else
+    qDebug() << Q_FUNC_INFO << "index out of bounds:" << nPts << valueIndex;
+}
+
 /*!
   Sets the alpha of the color map cell given by \a keyIndex and \a valueIndex to \a alpha. A value
   of 0 for \a alpha results in a fully transparent cell, and a value of 255 results in a fully
@@ -26313,6 +26362,29 @@
   }
 }
 
+void QCPColorMapData::pushRow(int nPts, const double *z) {
+  if (!mCircular) {
+    qDebug() << Q_FUNC_INFO << "storage buffer is not circular";
+    return;
+  }
+  if ( nPts + mCircBufStartIndex >= mKeySize*mValueSize )
+  {
+    const int nPtsPart = (mKeySize*mValueSize) - (nPts+mCircBufStartIndex);	// TODO: Might be an off-by-one here
+    memcpy( &mData[mCircBufStartIndex], z, nPtsPart * sizeof(double) );
+    memcpy( &mData[0], z+nPtsPart, (nPts - nPtsPart) * sizeof(double) );
+  } else {
+    memcpy( &mData[mCircBufStartIndex], z, nPts * sizeof(double) );
+  }
+  mCircBufStartIndex += nPts;
+  mCircBufStartIndex %= mKeySize*mValueSize;
+  const double *minVal = std::min_element(z, z+nPts);
+  if (*minVal < mDataBounds.lower)
+    mDataBounds.lower = *minVal;	// // This does not account for the possibility that we may have replaced the existing lower or upper bound with this pushRow
+  const double *maxVal = std::max_element(z, z+nPts);
+  if (*maxVal > mDataBounds.upper)
+    mDataBounds.upper = *maxVal;
+  mDataModified = true;
+}
 
 ////////////////////////////////////////////////////////////////////////////////////////////////////
 //////////////////// QCPColorMap
@@ -26788,22 +26860,24 @@
     } else if (!mUndersampledMapImage.isNull())
       mUndersampledMapImage = QImage(); // don't need oversampling mechanism anymore (map size has changed) but mUndersampledMapImage still has nonzero size, free it
     
-    const double *rawData = mMapData->mData;
-    const unsigned char *rawAlpha = mMapData->mAlpha;
     if (keyAxis->orientation() == Qt::Horizontal)
     {
       const int lineCount = valueSize;
       const int rowCount = keySize;
       for (int line=0; line<lineCount; ++line)
       {
+       const double *rawData = mMapData->cellRow(line);
+       const unsigned char *rawAlpha = mMapData->alphaRow(line);
         QRgb* pixels = reinterpret_cast<QRgb*>(localMapImage->scanLine(lineCount-1-line)); // invert scanline index because QImage counts scanlines from top, but our vertical index counts from bottom (mathematical coordinate system)
         if (rawAlpha)
-          mGradient.colorize(rawData+line*rowCount, rawAlpha+line*rowCount, mDataRange, pixels, rowCount, 1, mDataScaleType==QCPAxis::stLogarithmic);
+          mGradient.colorize(rawData, rawAlpha, mDataRange, pixels, rowCount, 1, mDataScaleType==QCPAxis::stLogarithmic);
         else
-          mGradient.colorize(rawData+line*rowCount, mDataRange, pixels, rowCount, 1, mDataScaleType==QCPAxis::stLogarithmic);
+          mGradient.colorize(rawData, mDataRange, pixels, rowCount, 1, mDataScaleType==QCPAxis::stLogarithmic);
       }
     } else // keyAxis->orientation() == Qt::Vertical
     {
+      const double *rawData = mMapData->mData;
+      const unsigned char *rawAlpha = mMapData->mAlpha;
       const int lineCount = keySize;
       const int rowCount = valueSize;
       for (int line=0; line<lineCount; ++line)
diff -Nrub a/qcustomplot.h b/qcustomplot.h
--- a/qcustomplot.h	2022-11-06 10:54:46.000000000 -0500
+++ b/qcustomplot.h	2024-04-15 11:01:30.335952847 -0400
@@ -6022,7 +6022,7 @@
 class QCP_LIB_DECL QCPColorMapData
 {
 public:
-  QCPColorMapData(int keySize, int valueSize, const QCPRange &keyRange, const QCPRange &valueRange);
+  QCPColorMapData(int keySize, int valueSize, const QCPRange &keyRange, const QCPRange &valueRange, bool circular = false, bool invert = false);
   ~QCPColorMapData();
   QCPColorMapData(const QCPColorMapData &other);
   QCPColorMapData &operator=(const QCPColorMapData &other);
@@ -6035,6 +6035,8 @@
   QCPRange dataBounds() const { return mDataBounds; }
   double data(double key, double value);
   double cell(int keyIndex, int valueIndex);
+  double *cellRow(int valueIndex);
+  unsigned char *alphaRow(int valueIndex);
   unsigned char alpha(int keyIndex, int valueIndex);
   
   // setters:
@@ -6046,7 +6048,9 @@
   void setValueRange(const QCPRange &valueRange);
   void setData(double key, double value, double z);
   void setCell(int keyIndex, int valueIndex, double z);
+  void setRow(int valueIndex, int nPts, const double *z);
   void setAlpha(int keyIndex, int valueIndex, unsigned char alpha);
+  void pushRow(int nPts, const double *z);
   
   // non-property methods:
   void recalculateDataBounds();
@@ -6063,6 +6067,9 @@
   int mKeySize, mValueSize;
   QCPRange mKeyRange, mValueRange;
   bool mIsEmpty;
+  bool mCircular;
+  bool mInvertDirection;
+  int mCircBufStartIndex;
   
   // non-property members:
   double *mData;