v2.0.0
Loading...
Searching...
No Matches
channelrhiview.cpp
Go to the documentation of this file.
1//=============================================================================================================
34
35//=============================================================================================================
36// INCLUDES
37//=============================================================================================================
38
39#include "channelrhiview.h"
40
41#include <rhi/qrhi.h>
42#include <rhi/qshader.h>
43#include <QFile>
44
45//=============================================================================================================
46// QT INCLUDES
47//=============================================================================================================
48
49#include <QApplication>
50#include <QPropertyAnimation>
51#include <QWheelEvent>
52#include <QMouseEvent>
53#include <QResizeEvent>
54#include <QPainter>
55#include <QPolygonF>
56#include <QtMath>
57
58#include <utility>
59
60//=============================================================================================================
61// USED NAMESPACES
62//=============================================================================================================
63
64using namespace DISPLIB;
65
66//=============================================================================================================
67// Uniform block layout — must match channeldata.vert / channeldata.frag
68//=============================================================================================================
69
70namespace {
71// Byte offsets inside one aligned UBO slot
72constexpr int kUboOffsetColor = 0; // vec4 (16 bytes)
73constexpr int kUboOffsetFirstSample = 16; // float
74constexpr int kUboOffsetScrollSample = 20; // float
75constexpr int kUboOffsetSampPerPixel = 24; // float
76constexpr int kUboOffsetViewWidth = 28; // float
77constexpr int kUboOffsetViewHeight = 32; // float
78constexpr int kUboOffsetChannelYCenter = 36; // float
79constexpr int kUboOffsetChannelYRange = 40; // float
80constexpr int kUboOffsetAmplitudeMax = 44; // float
81constexpr int kUboOffsetShowClipping = 48; // float
82// Total used: 52 bytes — padded to m_uboStride (≥ 256) per dynamic offset rules.
83
84constexpr int kMaxChannels = 1024; // Upper hard limit for UBO pre-allocation
85
86// Prefetch: VBO covers (1 + 2*prefetch) × visible window.
87// A scroll of up to prefetch×visible in either direction needs no VBO rebuild.
88constexpr float kDefaultPrefetch = 1.0f;
89} // namespace
90
91//=============================================================================================================
92// HELPERS
93//=============================================================================================================
94
95static QShader loadShader(const QString &filename)
96{
97 QFile f(filename);
98 if (!f.open(QIODevice::ReadOnly)) {
99 qWarning() << "ChannelRhiView: cannot open shader" << filename;
100 return {};
101 }
102 return QShader::fromSerialized(f.readAll());
103}
104
105static void writeFloat(quint8 *base, int byteOffset, float v)
106{
107 memcpy(base + byteOffset, &v, sizeof(float));
108}
109
110static void writeFloats(quint8 *base, int byteOffset, const float *data, int count)
111{
112 memcpy(base + byteOffset, data, count * sizeof(float));
113}
114
115//=============================================================================================================
116// DEFINE MEMBER METHODS
117//=============================================================================================================
118
119//=============================================================================================================
120// CrosshairOverlay — lightweight transparent child widget for crosshair/scalebar
121// painting. Sits on top of the QRhiWidget and repaints independently so that
122// mouse-tracking updates do NOT trigger the expensive GPU render pipeline.
123//=============================================================================================================
124
125class CrosshairOverlay : public QWidget
126{
127public:
129 : QWidget(parent), m_view(parent)
130 {
131 setAttribute(Qt::WA_TransparentForMouseEvents);
132 setAttribute(Qt::WA_NoSystemBackground);
133 setAttribute(Qt::WA_TranslucentBackground);
134 setMouseTracking(false);
135 }
136
137 void syncSize() { setGeometry(0, 0, parentWidget()->width(), parentWidget()->height()); }
138
139protected:
140 void paintEvent(QPaintEvent *) override
141 {
142 if (!m_view) return;
143 QPainter p(this);
144 p.setRenderHint(QPainter::Antialiasing, false);
145
146 if (m_view->crosshairEnabled())
147 m_view->drawCrosshair(p);
148 if (m_view->scalebarsVisible())
149 m_view->drawScalebars(p);
150 if (m_view->rulerActive())
151 m_view->drawRulerOverlay(p);
152 if (m_view->annotationSelecting())
153 m_view->drawAnnotationSelectionOverlay(p);
154 }
155
156private:
157 ChannelRhiView *m_view;
158};
159
161 : QRhiWidget(parent)
162{
163 setFocusPolicy(Qt::StrongFocus);
164 setMouseTracking(true);
165 setContextMenuPolicy(Qt::PreventContextMenu); // prevent right-click context menu
166
167 m_overlay = new CrosshairOverlay(this);
168 m_overlay->raise();
169 m_overlay->show();
170
171 // ── Async tile rebuild: swap in finished tile without blocking paintEvent ──
172 connect(&m_tileWatcher, &QFutureWatcher<TileResult>::finished, this, [this]() {
173 m_tileRebuildPending = false;
174 // Check BEFORE we clear the flag: was data dirtied while we were building?
175 bool dirtiedDuringBuild = m_tileDirty;
176 bool tileAccepted = false;
177
178 if (!m_tileWatcher.isCanceled()) {
179 TileResult r = m_tileWatcher.result();
180 if (!r.image.isNull()) {
181 m_tileImage = std::move(r.image);
182 m_tileSampleFirst = r.sampleFirst;
183 m_tileSamplesPerPixel = r.samplesPerPixel;
184 m_tileFirstChannel = r.firstChannel;
185 m_tileVisibleCount = r.visibleCount;
186 m_tileDirty = false;
187 tileAccepted = true;
188 }
189 }
190
191 // Only repaint when we have something new to show: a freshly accepted tile,
192 // or new data that arrived while the build was in-flight (needs a fresh build).
193 // When the build returned null (no channels/data yet) and nothing changed,
194 // skipping update() avoids a CPU-burning infinite repaint loop.
195 if (tileAccepted || dirtiedDuringBuild) {
196 if (dirtiedDuringBuild)
197 m_tileDirty = true;
198 update();
199 }
200 });
201
202 // Platform-specific backend selection
203# if defined(WASMBUILD) || defined(__EMSCRIPTEN__)
204 setApi(QRhiWidget::Api::OpenGL); // WebGL 2
205# elif defined(Q_OS_MACOS) || defined(Q_OS_IOS)
206 setApi(QRhiWidget::Api::Metal);
207# elif defined(Q_OS_WIN)
208 setApi(QRhiWidget::Api::Direct3D11);
209# else
210 setApi(QRhiWidget::Api::OpenGL);
211# endif
212 setSampleCount(1);
213 // Force a native window so Metal/OpenGL can create their backing surface.
214 // Without this, QRhiWidget may fail to obtain an NSView handle on macOS.
215 setAttribute(Qt::WA_NativeWindow);
216
217 // Repaint overlays (bands + event lines) when the app regains focus.
218 // The ruler overlay still uses QPainter and needs an explicit refresh.
219 connect(qApp, &QApplication::applicationStateChanged,
220 this, [this](Qt::ApplicationState s) {
221 if (s == Qt::ApplicationActive)
222 update();
223 });
224}
225
226//=============================================================================================================
227
229
230//=============================================================================================================
231
233{
234 if (m_model == model)
235 return;
236 if (m_model) {
237 disconnect(m_model, &ChannelDataModel::dataChanged, this, nullptr);
238 disconnect(m_model, &ChannelDataModel::metaChanged, this, nullptr);
239 }
240 m_model = model;
241 if (m_model) {
242 connect(m_model, &ChannelDataModel::dataChanged, this, [this] {
243 m_vboDirty = true;
244 m_tileDirty = true;
245 update();
246 });
247 connect(m_model, &ChannelDataModel::metaChanged, this, [this] {
248 m_vboDirty = true;
249 m_pipelineDirty = true;
250 m_tileDirty = true;
251 update();
252 });
253 }
254 m_vboDirty = true;
255 m_pipelineDirty = true;
256 update();
257}
258
259//=============================================================================================================
260
262{
263 // Never scroll before the first available sample
264 if (m_model && m_model->totalSamples() > 0)
265 sample = qMax(sample, static_cast<float>(m_model->firstSample()));
266 else
267 sample = qMax(sample, 0.f);
268
269 // Never scroll past the file end (clamp upper bound when file bounds are known)
270 if (m_lastFileSample >= 0) {
271 float maxScroll = static_cast<float>(m_lastFileSample - visibleSampleCount() + 1);
272 maxScroll = qMax(maxScroll, static_cast<float>(m_firstFileSample));
273 sample = qMin(sample, maxScroll);
274 }
275
276 if (qFuzzyCompare(m_scrollSample, sample))
277 return;
278
279 m_scrollSample = sample;
280
281 // Mark tile dirty when the new scroll position falls outside the tile's
282 // comfortable range. This ensures a rebuild is queued even if another
283 // build is currently in-flight (the finished handler will see dirtiedDuringBuild
284 // and let the next paintEvent restart for the new position).
285 if (!m_tileImage.isNull() && m_tileSamplesPerPixel > 0.f) {
286 float vis = width() * m_samplesPerPixel;
287 float tileEnd = m_tileSampleFirst + m_tileImage.width() * m_tileSamplesPerPixel;
288 if (m_scrollSample < m_tileSampleFirst + vis ||
289 m_scrollSample + vis > tileEnd - vis)
290 m_tileDirty = true;
291 }
292
293 // Check whether the prefetch window is still valid
294 float visible = width() * m_samplesPerPixel;
295 float margin = m_prefetchFactor * visible;
296 if (sample < m_vboWindowFirst + margin ||
297 sample + visible > m_vboWindowLast - margin) {
298 m_vboDirty = true;
299 }
300
301 // Overlay prefetch: only rebuild when scroll exceeds the cached sample range.
302 // The shader handles bands via uniforms, so we only need to rebuild when
303 // annotations/events would be outside the cached texture.
304 if (m_overlayTotalSamples <= 0.f ||
305 sample < m_overlayFirstSample ||
306 sample + visible > m_overlayFirstSample + m_overlayTotalSamples) {
307 m_overlayDirty = true;
308 }
309
310 emit scrollSampleChanged(m_scrollSample);
311 update();
312}
313
314//=============================================================================================================
315
317{
318 spp = qMax(spp, 1e-4f);
319 if (qFuzzyCompare(m_samplesPerPixel, spp))
320 return;
321 m_samplesPerPixel = spp;
322 m_vboDirty = true; // zoom change → decimation changes
323 m_overlayDirty = true;
324 m_tileDirty = true;
325 emit samplesPerPixelChanged(m_samplesPerPixel);
326 update();
327}
328
329//=============================================================================================================
330
331void ChannelRhiView::scrollTo(float targetSample, int durationMs)
332{
333 if (durationMs <= 0) {
334 setScrollSample(targetSample);
335 return;
336 }
337 auto *anim = new QPropertyAnimation(this, "scrollSample", this);
338 anim->setDuration(durationMs);
339 anim->setEasingCurve(QEasingCurve::OutCubic);
340 anim->setStartValue(m_scrollSample);
341 anim->setEndValue(targetSample);
342 anim->start(QAbstractAnimation::DeleteWhenStopped);
343}
344
345//=============================================================================================================
346
347void ChannelRhiView::zoomTo(float targetSpp, int durationMs)
348{
349 targetSpp = qMax(targetSpp, 1e-4f);
350 if (durationMs <= 0) {
351 setSamplesPerPixel(targetSpp);
352 return;
353 }
354 auto *anim = new QPropertyAnimation(this, "samplesPerPixel", this);
355 anim->setDuration(durationMs);
356 anim->setEasingCurve(QEasingCurve::OutCubic);
357 anim->setStartValue(m_samplesPerPixel);
358 anim->setEndValue(targetSpp);
359 anim->start(QAbstractAnimation::DeleteWhenStopped);
360}
361
362//=============================================================================================================
363
364void ChannelRhiView::setBackgroundColor(const QColor &color)
365{
366 m_bgColor = color;
367 m_tileDirty = true;
368 m_overlayDirty = true;
369 update();
370}
371
372//=============================================================================================================
373
375{
376 m_prefetchFactor = qMax(factor, 0.1f);
377}
378
379//=============================================================================================================
380
382{
383 return static_cast<int>(m_scrollSample);
384}
385
386//=============================================================================================================
387
389{
390 return static_cast<int>(width() * m_samplesPerPixel);
391}
392
393//=============================================================================================================
394
396{
397 int maxFirst = qMax(0, totalLogicalChannels() - m_visibleChannelCount);
398 ch = qBound(0, ch, maxFirst);
399 if (ch == m_firstVisibleChannel)
400 return;
401 m_firstVisibleChannel = ch;
402 m_tileDirty = true;
403 m_vboDirty = true;
404 m_pipelineDirty = true;
405 emit channelOffsetChanged(m_firstVisibleChannel);
406 update();
407}
408
409//=============================================================================================================
410
412{
413 count = qMax(1, count);
414 if (count == m_visibleChannelCount)
415 return;
416 m_visibleChannelCount = count;
417 m_tileDirty = true;
418 m_vboDirty = true;
419 m_pipelineDirty = true;
420 update();
421}
422
423//=============================================================================================================
424
426{
427 m_frozen = frozen;
428 if (m_frozen && m_pInertialAnim) {
429 m_pInertialAnim->stop();
430 m_pInertialAnim = nullptr;
431 }
432}
433
434//=============================================================================================================
435
437{
438 if (visible == m_gridVisible)
439 return;
440 m_gridVisible = visible;
441 m_tileDirty = true;
442 m_overlayDirty = true;
443 update();
444}
445
446//=============================================================================================================
447
449{
450 m_sfreq = qMax(sfreq, 0.f);
451 m_tileDirty = true;
452 m_overlayDirty = true;
453 update();
454}
455
456//=============================================================================================================
457
459{
460 if (first == m_firstFileSample)
461 return;
462 m_firstFileSample = first;
463 m_tileDirty = true;
464 m_overlayDirty = true;
465 update();
466}
467
468//=============================================================================================================
469
471{
472 m_lastFileSample = last;
473}
474
475//=============================================================================================================
476
478{
479 if (m_hideBadChannels == hide)
480 return;
481
482 const int previousFirstVisibleChannel = m_firstVisibleChannel;
483 m_hideBadChannels = hide;
484 const int maxFirst = qMax(0, totalLogicalChannels() - m_visibleChannelCount);
485 m_firstVisibleChannel = qBound(0, m_firstVisibleChannel, maxFirst);
486 if (m_firstVisibleChannel != previousFirstVisibleChannel) {
487 emit channelOffsetChanged(m_firstVisibleChannel);
488 }
489 m_vboDirty = true;
490 m_pipelineDirty = true;
491 m_tileDirty = true;
492 update();
493}
494
495//=============================================================================================================
496
498{
499 m_wheelScrollsChannels = channelsMode;
500}
501
502//=============================================================================================================
503
505{
506 m_scrollSpeedFactor = qBound(0.25f, factor, 4.0f);
507}
508
509//=============================================================================================================
510
512{
513 if (m_crosshairEnabled == enabled)
514 return;
515 m_crosshairEnabled = enabled;
516 if (enabled) {
517 setMouseTracking(true);
518 } else {
519 setMouseTracking(false);
520 m_crosshairX = m_crosshairY = -1;
521 }
522 update();
523}
524
525//=============================================================================================================
526
528{
529 if (m_scalebarsVisible == visible)
530 return;
531 m_scalebarsVisible = visible;
532 update();
533}
534
535//=============================================================================================================
536
538{
539 if (m_butterflyMode == enabled)
540 return;
541 m_butterflyMode = enabled;
542 m_vboDirty = true;
543 m_pipelineDirty = true;
544 m_tileDirty = true;
545 m_overlayDirty = true;
546 update();
547}
548
549//=============================================================================================================
550
551QVector<ChannelRhiView::ButterflyTypeGroup> ChannelRhiView::butterflyTypeGroups() const
552{
553 QVector<ButterflyTypeGroup> groups;
554 if (!m_model)
555 return groups;
556
557 const QVector<int> allCh = effectiveChannelIndices();
558 QMap<QString, int> typeToGroup; // typeLabel → index in groups
559
560 for (int ch : allCh) {
561 auto info = m_model->channelInfo(ch);
562 if (m_hideBadChannels && info.bad)
563 continue;
564 int gIdx;
565 if (typeToGroup.contains(info.typeLabel)) {
566 gIdx = typeToGroup[info.typeLabel];
567 } else {
568 gIdx = groups.size();
569 typeToGroup[info.typeLabel] = gIdx;
570 ButterflyTypeGroup g;
571 g.typeLabel = info.typeLabel;
572 g.color = info.color;
573 g.amplitudeMax = info.amplitudeMax;
574 groups.append(g);
575 }
576 groups[gIdx].channelIndices.append(ch);
577 }
578 return groups;
579}
580
581//=============================================================================================================
582
583int ChannelRhiView::butterflyLaneCount() const
584{
585 if (!m_model)
586 return 0;
587 const QVector<int> allCh = effectiveChannelIndices();
588 QSet<QString> types;
589 for (int ch : allCh) {
590 auto info = m_model->channelInfo(ch);
591 if (m_hideBadChannels && info.bad)
592 continue;
593 types.insert(info.typeLabel);
594 }
595 return types.size();
596}
597
598//=============================================================================================================
599
600void ChannelRhiView::setChannelIndices(const QVector<int> &indices)
601{
602 const int previousFirstVisibleChannel = m_firstVisibleChannel;
603 m_filteredChannels = indices;
604 // Clamp scroll to new range
605 int maxFirst = qMax(0, totalLogicalChannels() - m_visibleChannelCount);
606 m_firstVisibleChannel = qBound(0, m_firstVisibleChannel, maxFirst);
607 if (m_firstVisibleChannel != previousFirstVisibleChannel) {
608 emit channelOffsetChanged(m_firstVisibleChannel);
609 }
610 m_vboDirty = true;
611 m_pipelineDirty = true;
612 m_tileDirty = true;
613 update();
614}
615
616//=============================================================================================================
617
619{
620 return effectiveChannelIndices().size();
621}
622
623//=============================================================================================================
624
625int ChannelRhiView::actualChannelAt(int logicalIdx) const
626{
627 const QVector<int> indices = effectiveChannelIndices();
628 if (logicalIdx < 0 || logicalIdx >= indices.size())
629 return -1;
630 return indices.at(logicalIdx);
631}
632
633//=============================================================================================================
634
635QVector<int> ChannelRhiView::effectiveChannelIndices() const
636{
637 QVector<int> indices;
638
639 if (!m_model) {
640 return indices;
641 }
642
643 if (m_filteredChannels.isEmpty()) {
644 indices.reserve(m_model->channelCount());
645 for (int channelIndex = 0; channelIndex < m_model->channelCount(); ++channelIndex) {
646 indices.append(channelIndex);
647 }
648 } else {
649 indices = m_filteredChannels;
650 }
651
652 if (!m_hideBadChannels) {
653 return indices;
654 }
655
656 QVector<int> visibleIndices;
657 visibleIndices.reserve(indices.size());
658 for (int channelIndex : std::as_const(indices)) {
659 if (channelIndex < 0) {
660 continue;
661 }
662
663 const ChannelDisplayInfo info = m_model->channelInfo(channelIndex);
664 if (!info.bad) {
665 visibleIndices.append(channelIndex);
666 }
667 }
668
669 return visibleIndices;
670}
671
672//=============================================================================================================
673
674void ChannelRhiView::setEvents(const QVector<EventMarker> &events)
675{
676 m_events = events;
677 m_tileDirty = true;
678 m_overlayDirty = true;
679 update();
680}
681
682//=============================================================================================================
683
684void ChannelRhiView::setEpochMarkers(const QVector<int> &triggerSamples)
685{
686 m_epochTriggerSamples = triggerSamples;
687 m_tileDirty = true;
688 m_overlayDirty = true;
689 update();
690}
691
692//=============================================================================================================
693
695{
696 if (m_bShowEpochMarkers == visible)
697 return;
698 m_bShowEpochMarkers = visible;
699 m_tileDirty = true;
700 m_overlayDirty = true;
701 update();
702}
703
704//=============================================================================================================
705
707{
708 if (m_bShowClipping == visible)
709 return;
710 m_bShowClipping = visible;
711 m_tileDirty = true;
712 update();
713}
714
715//=============================================================================================================
716
718{
719 if (m_bZScoreMode == enabled)
720 return;
721 m_bZScoreMode = enabled;
722 m_vboDirty = true; // VBO data changes (z-score normalization)
723 m_tileDirty = true;
724 update();
725}
726
727//=============================================================================================================
728
729void ChannelRhiView::setAnnotations(const QVector<AnnotationSpan> &annotations)
730{
731 m_annotations = annotations;
732 m_tileDirty = true;
733 m_overlayDirty = true;
734 update();
735}
736
737//=============================================================================================================
738
740{
741 m_annotationSelectionEnabled = enabled;
742}
743
744//=============================================================================================================
745
747{
748 if (m_bShowEvents == visible) return;
749 m_bShowEvents = visible;
750 m_tileDirty = true;
751 m_overlayDirty = true;
752 update();
753}
754
755bool ChannelRhiView::eventsVisible() const { return m_bShowEvents; }
756
757//=============================================================================================================
758
760{
761 if (m_bShowAnnotations == visible) return;
762 m_bShowAnnotations = visible;
763 m_tileDirty = true;
764 m_overlayDirty = true;
765 update();
766}
767
768bool ChannelRhiView::annotationsVisible() const { return m_bShowAnnotations; }
769
770//=============================================================================================================
771
772int ChannelRhiView::hitTestAnnotationBoundary(int px, bool &isStart) const
773{
774 for (int i = 0; i < m_annotations.size(); ++i) {
775 const float xStart = (static_cast<float>(m_annotations[i].startSample) - m_scrollSample) / m_samplesPerPixel;
776 const float xEnd = (static_cast<float>(m_annotations[i].endSample + 1) - m_scrollSample) / m_samplesPerPixel;
777
778 if (qAbs(px - static_cast<int>(xStart)) <= kAnnBoundaryHitPx) {
779 isStart = true;
780 return i;
781 }
782 if (qAbs(px - static_cast<int>(xEnd)) <= kAnnBoundaryHitPx) {
783 isStart = false;
784 return i;
785 }
786 }
787 return -1;
788}
789
790//=============================================================================================================
791void ChannelRhiView::initialize(QRhiCommandBuffer *cb)
792{
793 Q_UNUSED(cb);
794 m_pipelineDirty = true;
795 m_vboDirty = true;
796}
797
798//=============================================================================================================
799
801{
802 m_pipeline.reset();
803 m_srb.reset();
804 m_ubo.reset();
805 m_gpuChannels.clear();
806 m_pipelineDirty = true;
807 m_vboDirty = true;
808
809 m_overlayPipeline.reset();
810 m_overlaySrb.reset();
811 m_overlaySampler.reset();
812 m_overlayTex.reset();
813 m_overlayVbo.reset();
814 m_overlayDirty = true;
815}
816
817//=============================================================================================================
818
819void ChannelRhiView::ensurePipeline()
820{
821 if (!m_pipelineDirty)
822 return;
823
824 QRhi *rhi = this->rhi();
825 if (!rhi)
826 return;
827
828 m_uboStride = static_cast<int>(
829 (52 + rhi->ubufAlignment() - 1) & ~(rhi->ubufAlignment() - 1));
830
831 // UBO has one slot per *visible* channel row, not all channels
832 // In butterfly mode, we need a slot for EVERY channel (all overlaid)
833 int totalCh = totalLogicalChannels();
834 int nCh;
835 if (m_butterflyMode) {
836 nCh = qMin(totalCh, kMaxChannels);
837 } else {
838 nCh = qMin(m_visibleChannelCount, totalCh - m_firstVisibleChannel);
839 }
840 nCh = qMax(nCh, 1);
841 nCh = qMin(nCh, kMaxChannels);
842
843 // ── Uniform buffer ──────────────────────────────────────────────────
844 bool uboRecreated = false;
845 if (!m_ubo || m_ubo->size() < nCh * m_uboStride) {
846 m_ubo.reset(rhi->newBuffer(QRhiBuffer::Dynamic,
847 QRhiBuffer::UniformBuffer,
848 nCh * m_uboStride));
849 m_ubo->create();
850 uboRecreated = true;
851 }
852
853 // ── Shader resource bindings ────────────────────────────────────────
854 // Recreate if the UBO pointer changed — SRB holds a raw pointer to the UBO.
855 if (!m_srb || uboRecreated) {
856 m_srb.reset(rhi->newShaderResourceBindings());
857 m_srb->setBindings({
858 QRhiShaderResourceBinding::uniformBufferWithDynamicOffset(
859 0,
860 QRhiShaderResourceBinding::VertexStage |
861 QRhiShaderResourceBinding::FragmentStage,
862 m_ubo.get(),
863 52 // visible block size for the shader
864 )
865 });
866 m_srb->create();
867 }
868
869 // ── Shaders ─────────────────────────────────────────────────────────
870 // Resource path matches qt_add_shaders PREFIX + file path (including subdirectory).
871 QShader vs = loadShader(QStringLiteral(":/disp/shaders/viewers/helpers/shaders/channeldata.vert.qsb"));
872 QShader fs = loadShader(QStringLiteral(":/disp/shaders/viewers/helpers/shaders/channeldata.frag.qsb"));
873
874 if (!vs.isValid() || !fs.isValid()) {
875 qWarning() << "ChannelRhiView: shaders not found. "
876 "Ensure qt_add_shaders is configured in CMakeLists.";
877 return;
878 }
879
880 // ── Graphics pipeline ───────────────────────────────────────────────
881 // Destroy any existing pipeline before creating a new one.
882 m_pipeline.reset();
883 m_pipeline.reset(rhi->newGraphicsPipeline());
884 m_pipeline->setShaderStages({
885 { QRhiShaderStage::Vertex, vs },
886 { QRhiShaderStage::Fragment, fs }
887 });
888
889 QRhiVertexInputLayout il;
890 il.setBindings({{ 2 * sizeof(float) }}); // stride = vec2
891 il.setAttributes({{ 0, 0, QRhiVertexInputAttribute::Float2, 0 }}); // location 0 = vec2
892
893 m_pipeline->setVertexInputLayout(il);
894 m_pipeline->setShaderResourceBindings(m_srb.get());
895 m_pipeline->setRenderPassDescriptor(renderTarget()->renderPassDescriptor());
896 m_pipeline->setTopology(QRhiGraphicsPipeline::LineStrip);
897 m_pipeline->setDepthTest(false);
898 m_pipeline->setDepthWrite(false);
899
900 // Alpha blending for anti-aliased lines (if multisampling is disabled)
901 QRhiGraphicsPipeline::TargetBlend blend;
902 blend.enable = true;
903 blend.srcColor = QRhiGraphicsPipeline::SrcAlpha;
904 blend.dstColor = QRhiGraphicsPipeline::OneMinusSrcAlpha;
905 blend.srcAlpha = QRhiGraphicsPipeline::One;
906 blend.dstAlpha = QRhiGraphicsPipeline::OneMinusSrcAlpha;
907 m_pipeline->setTargetBlends({ blend });
908
909 if (!m_pipeline->create()) {
910 qWarning() << "ChannelRhiView: failed to create graphics pipeline";
911 m_pipeline.reset();
912 return;
913 }
914
915 m_pipelineDirty = false;
916}
917
918//=============================================================================================================
919
920bool ChannelRhiView::isVboDirty() const
921{
922 if (!m_model)
923 return false;
924 float visible = width() * m_samplesPerPixel;
925 float margin = m_prefetchFactor * visible;
926 return m_vboDirty
927 || m_scrollSample < m_vboWindowFirst + margin
928 || (m_scrollSample + visible) > m_vboWindowLast - margin;
929}
930
931//=============================================================================================================
932
933void ChannelRhiView::rebuildVBOs(QRhiResourceUpdateBatch *batch)
934{
935 if (!m_model)
936 return;
937
938 QRhi *rhi = this->rhi();
939 if (!rhi)
940 return;
941
942 int nCh = totalLogicalChannels();
943 int px = width();
944 float visible = px * m_samplesPerPixel;
945
946 // Prefetch window: [scroll - prefetch*visible, scroll + (1+prefetch)*visible]
947 float windowFirst = m_scrollSample - m_prefetchFactor * visible;
948 float windowLast = m_scrollSample + (1.f + m_prefetchFactor) * visible;
949
950 int iFirst = qMax(static_cast<int>(windowFirst), m_model->firstSample());
951 int iLast = qMin(static_cast<int>(windowLast),
952 m_model->firstSample() + m_model->totalSamples());
953 if (iFirst >= iLast) {
954 m_vboDirty = false;
955 return;
956 }
957
958 m_vboWindowFirst = iFirst;
959 m_vboWindowLast = iLast;
960
961 // VBOs are indexed by logical (filtered) channel index, not model channel index
962 m_gpuChannels.resize(nCh);
963
964 // Compute the max vertex count across channels to right-size allocations
965 int prefetchedSamples = iLast - iFirst;
966 // With decimation, vertices ≤ 2 * px * prefetchFactor * (1 + prefetchFactor)
967 // Use a conservative upper bound
968 int maxVertices = qMax(prefetchedSamples * 2, 2 * px * 4);
969 Q_UNUSED(maxVertices)
970
971 for (int logCh = 0; logCh < nCh; ++logCh) {
972 int ch = actualChannelAt(logCh); // actual model channel index
973 if (ch < 0) {
974 m_gpuChannels[logCh].vertexCount = 0;
975 continue;
976 }
977 int vboFirst = 0;
978 QVector<float> verts = m_model->decimatedVertices(
979 ch, iFirst, iLast, static_cast<int>(prefetchedSamples / m_samplesPerPixel), vboFirst);
980
981 if (verts.isEmpty()) {
982 m_gpuChannels[logCh].vertexCount = 0;
983 continue;
984 }
985
986 // Z-score normalization: replace raw amplitudes with (y - mean) / std
987 if (m_bZScoreMode) {
988 int nv = verts.size() / 2;
989 if (nv > 1) {
990 double sum = 0.0, sumSq = 0.0;
991 for (int v = 0; v < nv; ++v) {
992 double a = static_cast<double>(verts[v * 2 + 1]);
993 sum += a;
994 sumSq += a * a;
995 }
996 float mean = static_cast<float>(sum / nv);
997 double var = sumSq / nv - static_cast<double>(mean) * mean;
998 float sd = var > 0.0 ? static_cast<float>(qSqrt(var)) : 1.f;
999 for (int v = 0; v < nv; ++v)
1000 verts[v * 2 + 1] = (verts[v * 2 + 1] - mean) / sd;
1001 }
1002 }
1003
1004 int vertexCount = verts.size() / 2; // each vertex is (x, y) = 2 floats
1005 quint32 byteSize = static_cast<quint32>(verts.size() * sizeof(float));
1006
1007 auto &gd = m_gpuChannels[logCh];
1008
1009 // Re-create buffer if size changed significantly
1010 if (!gd.vbo || static_cast<quint32>(gd.vbo->size()) < byteSize) {
1011 gd.vbo.reset(rhi->newBuffer(QRhiBuffer::Dynamic,
1012 QRhiBuffer::VertexBuffer,
1013 byteSize));
1014 if (!gd.vbo->create()) {
1015 qWarning() << "ChannelRhiView: VBO create failed for channel" << logCh;
1016 gd.vertexCount = 0;
1017 continue;
1018 }
1019 }
1020
1021 batch->updateDynamicBuffer(gd.vbo.get(), 0, byteSize,
1022 verts.constData());
1023 gd.vertexCount = vertexCount;
1024 gd.vboFirstSample = vboFirst;
1025 }
1026
1027 m_vboDirty = false;
1028}
1029
1030//=============================================================================================================
1031
1032void ChannelRhiView::updateUBO(QRhiResourceUpdateBatch *batch)
1033{
1034 if (!m_model || !m_ubo)
1035 return;
1036
1037 int totalCh = totalLogicalChannels();
1038
1039 // ── Butterfly mode: one UBO slot per channel, type-based lane positions ──
1040 if (m_butterflyMode) {
1041 const auto groups = butterflyTypeGroups();
1042 int nLanes = groups.size();
1043 if (nLanes <= 0)
1044 return;
1045
1046 // Build channel→lane map
1047 QHash<int, int> chToLane; // model channel idx → lane index
1048 for (int g = 0; g < groups.size(); ++g)
1049 for (int ch : groups[g].channelIndices)
1050 chToLane[ch] = g;
1051
1052 float vw = static_cast<float>(width());
1053 float vh = static_cast<float>(height());
1054 float laneRange = 2.f / nLanes; // NDC height per lane
1055
1056 QVarLengthArray<quint8> buf(m_uboStride, 0);
1057 int nToUpload = qMin(totalCh, kMaxChannels);
1058
1059 for (int logCh = 0; logCh < nToUpload; ++logCh) {
1060 int ch = actualChannelAt(logCh);
1061 memset(buf.data(), 0, m_uboStride);
1062
1063 auto info = (ch >= 0) ? m_model->channelInfo(ch) : ChannelDisplayInfo{};
1064 bool hideThis = (ch < 0) || (m_hideBadChannels && info.bad);
1065
1066 int lane = chToLane.value(ch, -1);
1067 if (lane < 0)
1068 hideThis = true;
1069
1070 QColor col = hideThis ? m_bgColor
1071 : (info.bad ? QColor(200, 60, 60, 180) : info.color);
1072 float yRng = hideThis ? 0.f : laneRange;
1073
1074 float yCenter = (lane >= 0)
1075 ? (1.f - laneRange * (lane + 0.5f))
1076 : 0.f;
1077
1078 float rgba[4] = {
1079 static_cast<float>(col.redF()),
1080 static_cast<float>(col.greenF()),
1081 static_cast<float>(col.blueF()),
1082 static_cast<float>(col.alphaF())
1083 };
1084
1085 auto *d = buf.data();
1086 writeFloats(d, kUboOffsetColor, rgba, 4);
1087 writeFloat (d, kUboOffsetFirstSample, static_cast<float>(logCh < static_cast<int>(m_gpuChannels.size())
1088 ? m_gpuChannels[logCh].vboFirstSample : 0));
1089 writeFloat (d, kUboOffsetScrollSample, m_scrollSample);
1090 writeFloat (d, kUboOffsetSampPerPixel, m_samplesPerPixel);
1091 writeFloat (d, kUboOffsetViewWidth, vw);
1092 writeFloat (d, kUboOffsetViewHeight, vh);
1093 writeFloat (d, kUboOffsetChannelYCenter, yCenter);
1094 writeFloat (d, kUboOffsetChannelYRange, yRng);
1095 writeFloat (d, kUboOffsetAmplitudeMax, m_bZScoreMode ? 4.f : info.amplitudeMax);
1096 writeFloat (d, kUboOffsetShowClipping, (m_bShowClipping && !info.bad && !m_bZScoreMode) ? 1.f : 0.f);
1097
1098 batch->updateDynamicBuffer(m_ubo.get(),
1099 logCh * m_uboStride,
1100 m_uboStride,
1101 buf.constData());
1102 }
1103 return;
1104 }
1105
1106 // ── Normal mode: one UBO slot per visible row ──
1107 int firstCh = qBound(0, m_firstVisibleChannel, totalCh);
1108 int visCnt = qMin(m_visibleChannelCount, totalCh - firstCh);
1109 int nCh = qMin(visCnt, kMaxChannels);
1110 if (nCh <= 0)
1111 return;
1112
1113 float vw = static_cast<float>(width());
1114 float vh = static_cast<float>(height());
1115 float laneRange = 2.f / nCh; // NDC height of one visible channel row
1116
1117 QVarLengthArray<quint8> buf(m_uboStride, 0);
1118
1119 for (int i = 0; i < nCh; ++i) {
1120 int logCh = firstCh + i; // logical (filtered) index
1121 int ch = actualChannelAt(logCh); // actual model channel index
1122 memset(buf.data(), 0, m_uboStride);
1123
1124 auto info = (ch >= 0) ? m_model->channelInfo(ch) : ChannelDisplayInfo{};
1125 bool hideThis = (ch < 0) || (m_hideBadChannels && info.bad);
1126 // When hiding: use background colour so no trace is painted
1127 QColor col = hideThis ? m_bgColor
1128 : (info.bad ? QColor(200, 60, 60, 180) : info.color);
1129 // When hiding bad channel: zero amplitude range → flat invisible line
1130 float yRng = hideThis ? 0.f : laneRange;
1131
1132 float rgba[4] = {
1133 static_cast<float>(col.redF()),
1134 static_cast<float>(col.greenF()),
1135 static_cast<float>(col.blueF()),
1136 static_cast<float>(col.alphaF())
1137 };
1138
1139 // Visible row i: top at NDC +1, bottom at NDC -1
1140 float yCenter = 1.f - laneRange * (i + 0.5f);
1141
1142 auto *d = buf.data();
1143 writeFloats(d, kUboOffsetColor, rgba, 4);
1144 // VBO indexed by logical channel (logCh), not model channel
1145 writeFloat (d, kUboOffsetFirstSample, static_cast<float>(logCh < static_cast<int>(m_gpuChannels.size())
1146 ? m_gpuChannels[logCh].vboFirstSample : 0));
1147 writeFloat (d, kUboOffsetScrollSample, m_scrollSample);
1148 writeFloat (d, kUboOffsetSampPerPixel, m_samplesPerPixel);
1149 writeFloat (d, kUboOffsetViewWidth, vw);
1150 writeFloat (d, kUboOffsetViewHeight, vh);
1151 writeFloat (d, kUboOffsetChannelYCenter, yCenter);
1152 writeFloat (d, kUboOffsetChannelYRange, yRng);
1153 writeFloat (d, kUboOffsetAmplitudeMax, m_bZScoreMode ? 4.f : info.amplitudeMax);
1154 writeFloat (d, kUboOffsetShowClipping, (m_bShowClipping && !info.bad && !m_bZScoreMode) ? 1.f : 0.f);
1155
1156 // UBO slot i corresponds to visible row i
1157 batch->updateDynamicBuffer(m_ubo.get(),
1158 i * m_uboStride,
1159 m_uboStride,
1160 buf.constData());
1161 }
1162}
1163
1164//=============================================================================================================
1165// Overlay texture — annotations, events, and epoch markers baked into a QImage.
1166// Alternating per-second bands are now computed in the fragment shader, so this
1167// image only needs rebuilding when annotations/events change or when the scroll
1168// exceeds the prefetch window.
1169//
1170// The overlay covers a wider sample range than the viewport (controlled by
1171// kOverlayPrefetchFactor). The fragment shader maps screen UVs into this
1172// wider texture via the OverlayParams UBO.
1173//=============================================================================================================
1174
1175void ChannelRhiView::rebuildOverlayImage(int logicalWidth, int logicalHeight, qreal devicePixelRatio)
1176{
1177 const qreal dpr = qMax(devicePixelRatio, 1.0);
1178 const int pixelWidth = qMax(1, qRound(logicalWidth * dpr));
1179 const int pixelHeight = qMax(1, qRound(logicalHeight * dpr));
1180
1181 m_overlayImage = QImage(pixelWidth, pixelHeight, QImage::Format_RGBA8888);
1182 m_overlayImage.setDevicePixelRatio(dpr);
1183 m_overlayImage.fill(Qt::transparent);
1184
1185 if (logicalWidth <= 0 || logicalHeight <= 0 || m_sfreq <= 0.f || m_samplesPerPixel <= 0.f) {
1186 m_overlayDirty = false;
1187 return;
1188 }
1189
1190 // The overlay covers m_overlayFirstSample .. m_overlayFirstSample + m_overlayTotalSamples.
1191 // Map sample positions to pixel X using the overlay's own coordinate system.
1192 const float overlayFirst = m_overlayFirstSample;
1193 const float overlayTotal = m_overlayTotalSamples;
1194 const float overlayPixelsPerSample = (overlayTotal > 0.f)
1195 ? static_cast<float>(logicalWidth) / overlayTotal
1196 : 0.f;
1197
1198 QPainter p(&m_overlayImage);
1199 p.setCompositionMode(QPainter::CompositionMode_SourceOver);
1200
1201 // Note: alternating per-second bands are now computed per-pixel in the
1202 // fragment shader — no QPainter band rendering here.
1203
1204 // ── Annotation spans ────────────────────────────────────────────
1205 if (m_bShowAnnotations && !m_annotations.isEmpty()) {
1206 QFont font = p.font();
1207 font.setPointSizeF(8.0);
1208 font.setBold(true);
1209 p.setFont(font);
1210
1211 for (const AnnotationSpan &annotation : m_annotations) {
1212 const float xStart = (static_cast<float>(annotation.startSample) - overlayFirst) * overlayPixelsPerSample;
1213 const float xEnd = (static_cast<float>(annotation.endSample + 1) - overlayFirst) * overlayPixelsPerSample;
1214 if (xEnd < -2.f || xStart > logicalWidth + 2.f) {
1215 continue;
1216 }
1217
1218 const float clippedStart = qBound(0.f, xStart, static_cast<float>(logicalWidth));
1219 const float clippedEnd = qBound(0.f, xEnd, static_cast<float>(logicalWidth));
1220 if (clippedEnd <= clippedStart) {
1221 continue;
1222 }
1223
1224 QColor fillColor = annotation.color;
1225 fillColor.setAlpha(48);
1226 p.fillRect(QRectF(clippedStart, 0.f, clippedEnd - clippedStart, static_cast<float>(logicalHeight)),
1227 fillColor);
1228
1229 QColor borderColor = annotation.color;
1230 borderColor.setAlpha(165);
1231 p.setPen(QPen(borderColor, 1));
1232 p.drawLine(QPointF(clippedStart, 0.f), QPointF(clippedStart, static_cast<float>(logicalHeight)));
1233 p.drawLine(QPointF(clippedEnd, 0.f), QPointF(clippedEnd, static_cast<float>(logicalHeight)));
1234
1235 if (!annotation.label.trimmed().isEmpty()) {
1236 QString label = annotation.label.trimmed();
1237 QFontMetrics metrics(font);
1238 QRect labelRect = metrics.boundingRect(label);
1239 labelRect.adjust(-6, -2, 6, 2);
1240 const int labelX = qBound(4,
1241 static_cast<int>(clippedStart) + 4,
1242 qMax(4, logicalWidth - labelRect.width() - 4));
1243 labelRect.moveTopLeft(QPoint(labelX, 4));
1244 QColor pillColor = annotation.color;
1245 pillColor.setAlpha(215);
1246 p.fillRect(labelRect, pillColor);
1247 p.setPen(Qt::white);
1248 p.drawText(labelRect, Qt::AlignCenter, label);
1249 }
1250 }
1251 }
1252
1253 // ── Event / stimulus marker lines ───────────────────────────────
1254 if (m_bShowEvents && !m_events.isEmpty()) {
1255 for (const EventMarker &ev : m_events) {
1256 float xF = (static_cast<float>(ev.sample) - overlayFirst) * overlayPixelsPerSample;
1257 if (xF < -2.f || xF > logicalWidth + 2.f)
1258 continue;
1259 QColor lineColor = ev.color;
1260 lineColor.setAlpha(180);
1261 p.setPen(QPen(lineColor, 1));
1262 p.drawLine(QPointF(xF, 0.f), QPointF(xF, static_cast<float>(logicalHeight)));
1263 }
1264 }
1265
1266 // ── Epoch trigger marker lines ──────────────────────────────────
1267 if (m_bShowEpochMarkers && !m_epochTriggerSamples.isEmpty()) {
1268 QPen epochPen(QColor(100, 100, 100, 140), 1, Qt::DashLine);
1269 p.setPen(epochPen);
1270 for (int trigSample : m_epochTriggerSamples) {
1271 float xF = (static_cast<float>(trigSample) - overlayFirst) * overlayPixelsPerSample;
1272 if (xF < -2.f || xF > logicalWidth + 2.f)
1273 continue;
1274 p.drawLine(QPointF(xF, 0.f), QPointF(xF, static_cast<float>(logicalHeight)));
1275 }
1276 }
1277
1278 m_overlayDirty = false;
1279}
1280
1281//=============================================================================================================
1282
1283void ChannelRhiView::ensureOverlayPipeline()
1284{
1285 QRhi *rhi = this->rhi();
1286 if (!rhi || !renderTarget())
1287 return;
1288 if (m_overlayPipeline)
1289 return;
1290
1291 // Full-screen quad VBO: (pos.x, pos.y, uv.x, uv.y) per vertex, TriangleStrip.
1292 // Static — the quad vertices never change (screen-space NDC coordinates).
1293 // NDC Y+ = top. Image UV Y=0 = top.
1294 // NDC(-1,-1)=bottom-left → UV(0,1)
1295 // NDC(-1, 1)=top-left → UV(0,0)
1296 // NDC( 1,-1)=bottom-right→ UV(1,1)
1297 // NDC( 1, 1)=top-right → UV(1,0)
1298 static constexpr float kQuadVerts[] = {
1299 -1.f, -1.f, 0.f, 1.f,
1300 -1.f, 1.f, 0.f, 0.f,
1301 1.f, -1.f, 1.f, 1.f,
1302 1.f, 1.f, 1.f, 0.f,
1303 };
1304 static constexpr int kQuadBytes = sizeof(kQuadVerts);
1305 m_overlayVbo.reset(rhi->newBuffer(QRhiBuffer::Immutable,
1306 QRhiBuffer::VertexBuffer,
1307 kQuadBytes));
1308 if (!m_overlayVbo->create()) {
1309 m_overlayVbo.reset();
1310 return;
1311 }
1312
1313 // Texture — placeholder 1×1; resized lazily in render() when pw/ph are known.
1314 m_overlayTex.reset(rhi->newTexture(QRhiTexture::RGBA8, QSize(1, 1)));
1315 m_overlayTex->create();
1316
1317 m_overlaySampler.reset(rhi->newSampler(
1318 QRhiSampler::Linear, QRhiSampler::Linear,
1319 QRhiSampler::None,
1320 QRhiSampler::ClampToEdge, QRhiSampler::ClampToEdge));
1321 m_overlaySampler->create();
1322
1323 // UBO for per-frame overlay parameters (binding 2, 8 floats = 32 bytes,
1324 // aligned to 256 for std140 on all backends).
1325 static constexpr int kOverlayUboSize = 256;
1326 m_overlayUbo.reset(rhi->newBuffer(QRhiBuffer::Dynamic,
1327 QRhiBuffer::UniformBuffer,
1328 kOverlayUboSize));
1329 if (!m_overlayUbo->create()) {
1330 m_overlayVbo.reset();
1331 m_overlayUbo.reset();
1332 return;
1333 }
1334
1335 // SRB: binding 1 = combined image sampler, binding 2 = overlay UBO
1336 m_overlaySrb.reset(rhi->newShaderResourceBindings());
1337 m_overlaySrb->setBindings({
1338 QRhiShaderResourceBinding::sampledTexture(
1339 1, QRhiShaderResourceBinding::FragmentStage,
1340 m_overlayTex.get(), m_overlaySampler.get()),
1341 QRhiShaderResourceBinding::uniformBuffer(
1342 2, QRhiShaderResourceBinding::FragmentStage,
1343 m_overlayUbo.get())
1344 });
1345 m_overlaySrb->create();
1346
1347 auto loadShader = [](const QString &path) -> QShader {
1348 QFile f(path);
1349 if (!f.open(QIODevice::ReadOnly)) {
1350 qWarning() << "ChannelRhiView: cannot open shader" << path;
1351 return {};
1352 }
1353 return QShader::fromSerialized(f.readAll());
1354 };
1355
1356 QShader vs = loadShader(QStringLiteral(":/disp/shaders/viewers/helpers/shaders/overlay.vert.qsb"));
1357 QShader fs = loadShader(QStringLiteral(":/disp/shaders/viewers/helpers/shaders/overlay.frag.qsb"));
1358 if (!vs.isValid() || !fs.isValid()) {
1359 m_overlayVbo.reset();
1360 m_overlayUbo.reset();
1361 m_overlaySrb.reset();
1362 m_overlaySampler.reset();
1363 m_overlayTex.reset();
1364 return;
1365 }
1366
1367 m_overlayPipeline.reset(rhi->newGraphicsPipeline());
1368 QRhiGraphicsPipeline::TargetBlend blend;
1369 blend.enable = true;
1370 blend.srcColor = QRhiGraphicsPipeline::SrcAlpha;
1371 blend.dstColor = QRhiGraphicsPipeline::OneMinusSrcAlpha;
1372 blend.srcAlpha = QRhiGraphicsPipeline::One;
1373 blend.dstAlpha = QRhiGraphicsPipeline::OneMinusSrcAlpha;
1374 m_overlayPipeline->setTargetBlends({ blend });
1375 m_overlayPipeline->setTopology(QRhiGraphicsPipeline::TriangleStrip);
1376 m_overlayPipeline->setDepthTest(false);
1377 m_overlayPipeline->setDepthWrite(false);
1378 m_overlayPipeline->setShaderStages({
1379 { QRhiShaderStage::Vertex, vs },
1380 { QRhiShaderStage::Fragment, fs },
1381 });
1382
1383 QRhiVertexInputLayout inputLayout;
1384 inputLayout.setBindings({ QRhiVertexInputBinding(4 * sizeof(float)) });
1385 inputLayout.setAttributes({
1386 QRhiVertexInputAttribute(0, 0, QRhiVertexInputAttribute::Float2, 0),
1387 QRhiVertexInputAttribute(0, 1, QRhiVertexInputAttribute::Float2, 2 * sizeof(float))
1388 });
1389 m_overlayPipeline->setVertexInputLayout(inputLayout);
1390 m_overlayPipeline->setShaderResourceBindings(m_overlaySrb.get());
1391 m_overlayPipeline->setRenderPassDescriptor(renderTarget()->renderPassDescriptor());
1392
1393 if (!m_overlayPipeline->create()) {
1394 qWarning() << "ChannelRhiView: overlay pipeline create failed";
1395 m_overlayPipeline.reset();
1396 return;
1397 }
1398
1399 // The static VBO needs an initial upload. Since ensureOverlayPipeline()
1400 // is called from render(), we set a flag and do the upload in the render
1401 // batch instead.
1402 m_overlayVboNeedsUpload = true;
1403}
1404
1405//=============================================================================================================
1406
1407void ChannelRhiView::render(QRhiCommandBuffer *cb)
1408{
1409 if (!m_model || totalLogicalChannels() == 0) {
1410 // Clear to background colour only
1411 QRhiResourceUpdateBatch *u = rhi()->nextResourceUpdateBatch();
1412 QColor bg = m_bgColor;
1413 cb->beginPass(renderTarget(), bg, {1.f, 0}, u);
1414 cb->endPass();
1415 return;
1416 }
1417
1418 // ── Ensure GPU resources ─────────────────────────────────────────────
1419 ensurePipeline();
1420 if (!m_pipeline) {
1421 // Pipeline not ready: show RED background so the failure is visible
1422 QRhiResourceUpdateBatch *u = rhi()->nextResourceUpdateBatch();
1423 cb->beginPass(renderTarget(), QColor(220, 0, 0), {1.f, 0}, u);
1424 cb->endPass();
1425 return;
1426 }
1427
1428 QSize ps = renderTarget()->pixelSize();
1429 const int pw = ps.width();
1430 const int ph = ps.height();
1431 const int logicalW = width();
1432 const int logicalH = height();
1433 const qreal overlayDpr = (logicalW > 0) ? (static_cast<qreal>(pw) / static_cast<qreal>(logicalW)) : 1.0;
1434
1435 QRhiResourceUpdateBatch *batch = rhi()->nextResourceUpdateBatch();
1436
1437 if (isVboDirty())
1438 rebuildVBOs(batch);
1439
1440 updateUBO(batch);
1441
1442 // ── Overlay image (annotations + events; bands computed in shader) ──
1443 ensureOverlayPipeline();
1444 bool overlayReady = false;
1445 if (m_overlayPipeline && m_overlayVbo && m_overlayUbo && pw > 0 && ph > 0 && logicalW > 0 && logicalH > 0) {
1446
1447 // Upload the static quad VBO if it was just created
1448 if (m_overlayVboNeedsUpload) {
1449 static constexpr float kQuadVerts[] = {
1450 -1.f, -1.f, 0.f, 1.f,
1451 -1.f, 1.f, 0.f, 0.f,
1452 1.f, -1.f, 1.f, 1.f,
1453 1.f, 1.f, 1.f, 0.f,
1454 };
1455 batch->uploadStaticBuffer(m_overlayVbo.get(), 0,
1456 static_cast<quint32>(sizeof(kQuadVerts)), kQuadVerts);
1457 m_overlayVboNeedsUpload = false;
1458 }
1459
1460 // Compute the overlay prefetch window (wider than the viewport)
1461 const float visibleSamples = static_cast<float>(logicalW) * m_samplesPerPixel;
1462 const float extraSamples = kOverlayPrefetchFactor * visibleSamples;
1463 const float overlayFirstSample = m_scrollSample - extraSamples;
1464 const float overlayTotalSamples = visibleSamples + 2.0f * extraSamples;
1465 // Width of overlay texture in logical pixels = 1 + 2*prefetch factor times viewport
1466 const int overlayLogicalW = static_cast<int>(std::ceil(
1467 (1.0f + 2.0f * kOverlayPrefetchFactor) * static_cast<float>(logicalW)));
1468
1469 // Rebuild the overlay QImage if dirty or resized
1470 const QSize requiredTexSize(qRound(overlayLogicalW * overlayDpr),
1471 qRound(logicalH * overlayDpr));
1472 if (m_overlayDirty || requiredTexSize != m_overlayTexSize) {
1473 // Store the sample range covered by this overlay build
1474 m_overlayFirstSample = overlayFirstSample;
1475 m_overlayTotalSamples = overlayTotalSamples;
1476
1477 rebuildOverlayImage(overlayLogicalW, logicalH, overlayDpr);
1478
1479 // (Re)create texture at the correct pixel size
1480 if (requiredTexSize != m_overlayTexSize) {
1481 m_overlayTex.reset(rhi()->newTexture(QRhiTexture::RGBA8, requiredTexSize));
1482 m_overlayTex->create();
1483 // Re-create SRB because it references the texture
1484 m_overlaySrb->setBindings({
1485 QRhiShaderResourceBinding::sampledTexture(
1486 1, QRhiShaderResourceBinding::FragmentStage,
1487 m_overlayTex.get(), m_overlaySampler.get()),
1488 QRhiShaderResourceBinding::uniformBuffer(
1489 2, QRhiShaderResourceBinding::FragmentStage,
1490 m_overlayUbo.get())
1491 });
1492 m_overlaySrb->create();
1493 m_overlayTexSize = requiredTexSize;
1494 }
1495 QRhiTextureUploadEntry entry(0, 0, QRhiTextureSubresourceUploadDescription(m_overlayImage));
1496 batch->uploadTexture(m_overlayTex.get(), entry);
1497 }
1498
1499 // Upload per-frame overlay UBO (scroll params for shader-computed bands + UV mapping)
1500 struct OverlayParams {
1501 float scrollSample;
1502 float samplesPerPixel;
1503 float viewWidth;
1504 float sfreq;
1505 float firstFileSample;
1506 float gridEnabled;
1507 float overlayFirstSample;
1508 float overlayTotalSamples;
1509 };
1510 OverlayParams params;
1511 params.scrollSample = m_scrollSample;
1512 params.samplesPerPixel = m_samplesPerPixel;
1513 params.viewWidth = static_cast<float>(logicalW);
1514 params.sfreq = m_sfreq;
1515 params.firstFileSample = static_cast<float>(m_firstFileSample);
1516 params.gridEnabled = m_gridVisible ? 1.0f : 0.0f;
1517 params.overlayFirstSample = m_overlayFirstSample;
1518 params.overlayTotalSamples = m_overlayTotalSamples;
1519 batch->updateDynamicBuffer(m_overlayUbo.get(), 0,
1520 static_cast<quint32>(sizeof(params)), &params);
1521
1522 overlayReady = true;
1523 }
1524
1525 // ── Render pass ──────────────────────────────────────────────────────
1526 QColor bg = m_bgColor;
1527 cb->beginPass(renderTarget(), bg, {1.f, 0}, batch);
1528
1529 cb->setViewport(QRhiViewport(0.f, 0.f, static_cast<float>(pw),
1530 static_cast<float>(ph)));
1531
1532 // ── Waveform traces ──────────────────────────────────────────────────
1533 cb->setGraphicsPipeline(m_pipeline.get());
1534
1535 int totalCh = totalLogicalChannels();
1536
1537 if (m_butterflyMode) {
1538 // Butterfly: render ALL channels; UBO slots indexed by logical channel
1539 int nToRender = qMin(totalCh, kMaxChannels);
1540 for (int logCh = 0; logCh < nToRender; ++logCh) {
1541 if (logCh >= static_cast<int>(m_gpuChannels.size()))
1542 break;
1543 auto &gd = m_gpuChannels[logCh];
1544 if (!gd.vbo || gd.vertexCount < 2)
1545 continue;
1546
1547 quint32 dynOffset = static_cast<quint32>(logCh * m_uboStride);
1548 QRhiCommandBuffer::DynamicOffset dynOff{0, dynOffset};
1549 cb->setShaderResources(m_srb.get(), 1, &dynOff);
1550
1551 QRhiCommandBuffer::VertexInput vi(gd.vbo.get(), 0);
1552 cb->setVertexInput(0, 1, &vi);
1553 cb->draw(static_cast<quint32>(gd.vertexCount));
1554 }
1555 } else {
1556 // Normal: render only the visible channel window
1557 int firstCh = qBound(0, m_firstVisibleChannel, totalCh);
1558 int visCnt = qMin(m_visibleChannelCount, totalCh - firstCh);
1559 int nToRender = qMin(visCnt, kMaxChannels);
1560
1561 for (int i = 0; i < nToRender; ++i) {
1562 int logCh = firstCh + i;
1563 if (logCh >= static_cast<int>(m_gpuChannels.size()))
1564 break;
1565 auto &gd = m_gpuChannels[logCh];
1566 if (!gd.vbo || gd.vertexCount < 2)
1567 continue;
1568
1569 quint32 dynOffset = static_cast<quint32>(i * m_uboStride);
1570 QRhiCommandBuffer::DynamicOffset dynOff{0, dynOffset};
1571 cb->setShaderResources(m_srb.get(), 1, &dynOff);
1572
1573 QRhiCommandBuffer::VertexInput vi(gd.vbo.get(), 0);
1574 cb->setVertexInput(0, 1, &vi);
1575 cb->draw(static_cast<quint32>(gd.vertexCount));
1576 }
1577 } // end butterfly/normal branch
1578
1579 // ── Overlay blit (bands + event lines) — drawn after waveforms ───────
1580 if (overlayReady) {
1581 cb->setGraphicsPipeline(m_overlayPipeline.get());
1582 cb->setShaderResources(m_overlaySrb.get());
1583 QRhiCommandBuffer::VertexInput overlayVi(m_overlayVbo.get(), 0);
1584 cb->setVertexInput(0, 1, &overlayVi);
1585 cb->draw(4); // TriangleStrip: 4 vertices = 2 triangles = full-screen quad
1586 }
1587
1588 cb->endPass();
1589}
1590
1591void ChannelRhiView::paintEvent(QPaintEvent *event)
1592{
1593 QRhiWidget::paintEvent(event);
1594 drawOverlays();
1595}
1596
1597//=============================================================================================================
1598// Tile cache helpers retained for off-thread waveform staging.
1599//=============================================================================================================
1600
1601bool ChannelRhiView::isTileFresh() const
1602{
1603 if (m_tileDirty || m_tileImage.isNull() || m_tileSamplesPerPixel <= 0.f)
1604 return false;
1605 if (!qFuzzyCompare(m_tileSamplesPerPixel, m_samplesPerPixel))
1606 return false;
1607 if (m_tileFirstChannel != m_firstVisibleChannel)
1608 return false;
1609 int totalCh = totalLogicalChannels();
1610 int visibleCount = qMin(m_visibleChannelCount, totalCh - m_firstVisibleChannel);
1611 if (m_tileVisibleCount != visibleCount)
1612 return false;
1613
1614 // Tile is stale if current scroll is within one visible-width of either edge
1615 float visibleSamples = width() * m_samplesPerPixel;
1616 float tileEnd = m_tileSampleFirst + m_tileImage.width() * m_tileSamplesPerPixel;
1617 if (m_scrollSample < m_tileSampleFirst + visibleSamples)
1618 return false;
1619 if (m_scrollSample + visibleSamples > tileEnd - visibleSamples)
1620 return false;
1621
1622 return true;
1623}
1624
1625//=============================================================================================================
1626
1627void ChannelRhiView::scheduleTileRebuild()
1628{
1629 // Guard: already a rebuild in flight — it will re-check m_tileDirty when done
1630 if (m_tileRebuildPending)
1631 return;
1632
1633 if (!m_model || totalLogicalChannels() == 0 || width() <= 0 || height() <= 0) {
1634 // No model / no channels / zero-size: produce a stable blank tile synchronously.
1635 // This prevents an infinite repaint loop: the async worker would return a null
1636 // image → watcher fires update() → paintEvent → rebuild → repeat.
1637 m_tileImage = QImage(qMax(width(), 1), qMax(height(), 1), QImage::Format_RGB32);
1638 m_tileImage.fill(m_bgColor.rgb());
1639 m_tileSampleFirst = m_scrollSample;
1640 m_tileSamplesPerPixel = qMax(m_samplesPerPixel, 1e-4f);
1641 m_tileFirstChannel = m_firstVisibleChannel;
1642 m_tileVisibleCount = 0;
1643 m_tileDirty = false;
1644 return;
1645 }
1646
1647 // Snapshot all view state for the worker (worker must NOT touch 'this')
1648 ChannelDataModel *model = m_model.data();
1649 float scrollSample = m_scrollSample;
1650 float spp = m_samplesPerPixel;
1651 int firstCh = m_firstVisibleChannel;
1652 int visCnt = m_visibleChannelCount;
1653 int pw = width();
1654 int ph = height();
1655 QColor bg = m_bgColor;
1656 bool gridVis = m_gridVisible;
1657 float sfreq = m_sfreq;
1658 int firstFileSample = m_firstFileSample;
1659 bool hideBad = m_hideBadChannels;
1660 QVector<int> chIndices = m_filteredChannels; // snapshot for worker
1661 QVector<EventMarker> eventsSnap = m_bShowEvents ? m_events : QVector<EventMarker>();
1662 QVector<AnnotationSpan> annotationsSnap = m_bShowAnnotations ? m_annotations : QVector<AnnotationSpan>();
1663 QVector<int> epochSnap = m_bShowEpochMarkers ? m_epochTriggerSamples : QVector<int>();
1664 bool clipSnap = m_bShowClipping;
1665 bool zscoreSnap = m_bZScoreMode;
1666
1667 m_tileDirty = false; // cleared now — any new event will set it true again
1668 m_tileRebuildPending = true;
1669 m_tileWatcher.setFuture(QtConcurrent::run([=]() {
1670 return ChannelRhiView::buildTile(model, scrollSample, spp, firstCh, visCnt,
1671 pw, ph, bg, gridVis, sfreq, firstFileSample,
1672 hideBad, chIndices, eventsSnap, annotationsSnap, epochSnap,
1673 clipSnap, zscoreSnap);
1674 }));
1675}
1676
1677//=============================================================================================================
1678
1679ChannelRhiView::TileResult ChannelRhiView::buildTile(
1680 ChannelDataModel *model,
1681 float scrollSample, float spp,
1682 int firstCh, int visCnt,
1683 int pw, int ph,
1684 QColor bgColor, bool gridVisible,
1685 float sfreq, int firstFileSample,
1686 bool hideBadChannels,
1687 const QVector<int> &channelIndices,
1688 const QVector<EventMarker> &events,
1689 const QVector<AnnotationSpan> &annotations,
1690 const QVector<int> &epochMarkers,
1691 bool showClipping,
1692 bool zScoreMode)
1693{
1694 TileResult out;
1695 out.samplesPerPixel = spp;
1696 out.firstChannel = firstCh;
1697
1698 if (!model || pw <= 0 || ph <= 0 || spp <= 0.f)
1699 return out;
1700
1701 int totalCh = channelIndices.isEmpty() ? model->channelCount() : channelIndices.size();
1702 int visibleCount = qMin(visCnt, totalCh - firstCh);
1703 if (visibleCount <= 0)
1704 return out;
1705
1706 out.visibleCount = visibleCount;
1707
1708 const int kTileMult = 5;
1709 int tilePixWidth = pw * kTileMult;
1710 float visibleSamples = pw * spp;
1711 float tileStart = scrollSample - 2.f * visibleSamples;
1712
1713 out.sampleFirst = tileStart;
1714
1715 QImage img(tilePixWidth, ph, QImage::Format_RGB32);
1716 img.fill(bgColor.rgb());
1717
1718 QPainter p(&img);
1719 p.setRenderHint(QPainter::Antialiasing, false);
1720
1721 float laneH = static_cast<float>(ph) / visibleCount;
1722 int firstSample = static_cast<int>(tileStart);
1723 int lastSample = firstSample + static_cast<int>(tilePixWidth * spp) + 1;
1724
1725 // ── Alternating per-second background bands ─────────────────────
1726 // Draw subtle alternating grey/white bands every second, like MNE-Python browser.
1727 if (sfreq > 0.f) {
1728 float samplesPerSec = sfreq;
1729 float firstBound = std::floor(
1730 (tileStart - static_cast<float>(firstFileSample)) / samplesPerSec
1731 ) * samplesPerSec + static_cast<float>(firstFileSample);
1732
1733 // Determine parity of the first band (0 = even, 1 = odd)
1734 long long bandIndex = static_cast<long long>(
1735 (firstBound - static_cast<float>(firstFileSample)) / samplesPerSec);
1736 bool oddBand = (bandIndex & 1) != 0;
1737
1738 // Compute a slightly darker shade for odd bands relative to bgColor
1739 QColor altColor(
1740 qBound(0, bgColor.red() - 10, 255),
1741 qBound(0, bgColor.green() - 10, 255),
1742 qBound(0, bgColor.blue() - 10, 255)
1743 );
1744
1745 for (float s = firstBound; s < lastSample; s += samplesPerSec, oddBand = !oddBand) {
1746 if (!oddBand)
1747 continue; // even seconds use the regular bgColor already filled
1748 float xStart = (s - tileStart) / spp;
1749 float xEnd = xStart + samplesPerSec / spp;
1750 xStart = qBound(0.f, xStart, static_cast<float>(tilePixWidth));
1751 xEnd = qBound(0.f, xEnd, static_cast<float>(tilePixWidth));
1752 if (xEnd > xStart)
1753 p.fillRect(QRectF(xStart, 0, xEnd - xStart, ph), altColor);
1754 }
1755 }
1756
1757 // ── Grid pass ──────────────────────────────────────────────────────
1758 if (gridVisible) {
1759 for (int i = 0; i < visibleCount; ++i) {
1760 float yMid = (i + 0.5f) * laneH;
1761 float yTop = i * laneH;
1762
1763 if (i > 0) {
1764 p.setPen(QPen(QColor(205, 205, 215), 1));
1765 p.drawLine(QPointF(0, yTop), QPointF(tilePixWidth, yTop));
1766 }
1767
1768 QPen guidePen(QColor(228, 228, 235), 1, Qt::DotLine);
1769 guidePen.setDashPattern({3, 4});
1770 p.setPen(guidePen);
1771 p.drawLine(QPointF(0, yMid - laneH * 0.44f), QPointF(tilePixWidth, yMid - laneH * 0.44f));
1772 p.drawLine(QPointF(0, yMid + laneH * 0.44f), QPointF(tilePixWidth, yMid + laneH * 0.44f));
1773
1774 p.setPen(QPen(QColor(210, 210, 218), 1));
1775 p.drawLine(QPointF(0, yMid), QPointF(tilePixWidth, yMid));
1776 }
1777
1778 if (sfreq > 0.f) {
1779 static const float kNiceIntervals[] = {
1780 0.05f, 0.1f, 0.2f, 0.5f, 1.f, 2.f, 5.f, 10.f, 30.f, 60.f
1781 };
1782 float pxPerSecond = sfreq / spp;
1783 float tickIntervalS = kNiceIntervals[0];
1784 for (float iv : kNiceIntervals) {
1785 tickIntervalS = iv;
1786 if (iv * pxPerSecond >= 80.f)
1787 break;
1788 }
1789 float tickSamples = tickIntervalS * sfreq;
1790 float origin = static_cast<float>(firstFileSample);
1791 float firstTick = std::ceil((tileStart - origin) / tickSamples) * tickSamples + origin;
1792
1793 p.setPen(QPen(QColor(205, 205, 210), 1));
1794 for (float s = firstTick; s < lastSample; s += tickSamples) {
1795 float xPx = (s - tileStart) / spp;
1796 p.drawLine(QPointF(xPx, 0), QPointF(xPx, ph));
1797 }
1798 }
1799 }
1800
1801 // ── Annotation span pass ────────────────────────────────────────
1802 if (!annotations.isEmpty()) {
1803 QFont font = p.font();
1804 font.setPointSizeF(8.0);
1805 font.setBold(true);
1806 p.setFont(font);
1807
1808 for (const AnnotationSpan &annotation : annotations) {
1809 float xStart = (static_cast<float>(annotation.startSample) - tileStart) / spp;
1810 float xEnd = (static_cast<float>(annotation.endSample + 1) - tileStart) / spp;
1811
1812 if (xEnd < -2.f || xStart > tilePixWidth + 2.f) {
1813 continue;
1814 }
1815
1816 xStart = qBound(0.f, xStart, static_cast<float>(tilePixWidth));
1817 xEnd = qBound(0.f, xEnd, static_cast<float>(tilePixWidth));
1818 if (xEnd <= xStart) {
1819 continue;
1820 }
1821
1822 QColor fillColor = annotation.color;
1823 fillColor.setAlpha(48);
1824 p.fillRect(QRectF(xStart, 0.f, xEnd - xStart, static_cast<float>(ph)), fillColor);
1825
1826 QColor borderColor = annotation.color;
1827 borderColor.setAlpha(165);
1828 p.setPen(QPen(borderColor, 1));
1829 p.drawLine(QPointF(xStart, 0.f), QPointF(xStart, static_cast<float>(ph)));
1830 p.drawLine(QPointF(xEnd, 0.f), QPointF(xEnd, static_cast<float>(ph)));
1831
1832 if (!annotation.label.trimmed().isEmpty()) {
1833 const QString label = annotation.label.trimmed();
1834 QFontMetrics metrics(font);
1835 QRect labelRect = metrics.boundingRect(label);
1836 labelRect.adjust(-6, -2, 6, 2);
1837 const int labelX = qBound(4,
1838 static_cast<int>(xStart) + 4,
1839 qMax(4, tilePixWidth - labelRect.width() - 4));
1840 labelRect.moveTopLeft(QPoint(labelX, 4));
1841 QColor pillColor = annotation.color;
1842 pillColor.setAlpha(215);
1843 p.fillRect(labelRect, pillColor);
1844 p.setPen(Qt::white);
1845 p.drawText(labelRect, Qt::AlignCenter, label);
1846 }
1847 }
1848 }
1849
1850 // ── Channel waveform pass ───────────────────────────────────────────
1851 for (int i = 0; i < visibleCount; ++i) {
1852 int logIdx = firstCh + i;
1853 int ch = channelIndices.isEmpty() ? logIdx
1854 : (logIdx < channelIndices.size() ? channelIndices[logIdx] : -1);
1855 if (ch < 0)
1856 continue;
1857 auto info = model->channelInfo(ch);
1858
1859 // Skip trace for bad channels when hiding
1860 if (hideBadChannels && info.bad)
1861 continue;
1862
1863 int vboFirst = 0;
1864 QVector<float> verts = model->decimatedVertices(
1865 ch, firstSample, lastSample, tilePixWidth, vboFirst);
1866 if (verts.size() < 4)
1867 continue;
1868
1869 QColor col = info.bad ? QColor(190, 40, 40) : info.color;
1870 QPen normalPen(col, 1.2);
1871 QPen clipPen(QColor(255, 0, 0), 1.6);
1872
1873 float yMid = (i + 0.5f) * laneH;
1874 int nVerts = verts.size() / 2;
1875 float yScale;
1876
1877 // Z-score normalization: compute mean and std of visible amplitudes
1878 float zMean = 0.f, zStd = 1.f;
1879 if (zScoreMode && nVerts > 1) {
1880 double sum = 0.0, sumSq = 0.0;
1881 for (int v = 0; v < nVerts; ++v) {
1882 double a = static_cast<double>(verts[v * 2 + 1]);
1883 sum += a;
1884 sumSq += a * a;
1885 }
1886 zMean = static_cast<float>(sum / nVerts);
1887 double var = sumSq / nVerts - static_cast<double>(zMean) * zMean;
1888 zStd = var > 0.0 ? static_cast<float>(qSqrt(var)) : 1.f;
1889 // Map ±4 std devs to fill the lane
1890 yScale = (laneH * 0.45f) / 4.f;
1891 } else {
1892 yScale = (laneH * 0.45f) / (info.amplitudeMax > 0.f ? info.amplitudeMax : 1.f);
1893 }
1894
1895 // Threshold for clipping: 95% of max amplitude (disabled in z-score mode)
1896 float clipThresh = 0.95f * info.amplitudeMax;
1897 bool doClip = showClipping && !info.bad && !zScoreMode && clipThresh > 0.f;
1898
1899 if (!doClip) {
1900 // Fast path: single polyline, no clipping check
1901 p.setPen(normalPen);
1902 QPolygonF poly;
1903 poly.reserve(nVerts);
1904 for (int v = 0; v < nVerts; ++v) {
1905 float samplePos = vboFirst + verts[v * 2];
1906 float xPx = (samplePos - tileStart) / spp;
1907 float amp = verts[v * 2 + 1];
1908 if (zScoreMode) amp = (amp - zMean) / zStd;
1909 float yPx = yMid - amp * yScale;
1910 poly.append(QPointF(xPx, yPx));
1911 }
1912 p.drawPolyline(poly);
1913 } else {
1914 // Clipping-aware path: split polyline into normal/clipped segments
1915 QPolygonF seg;
1916 seg.reserve(nVerts);
1917 bool prevClipped = false;
1918
1919 for (int v = 0; v < nVerts; ++v) {
1920 float amp = verts[v * 2 + 1];
1921 float samplePos = vboFirst + verts[v * 2];
1922 float xPx = (samplePos - tileStart) / spp;
1923 float yPx = yMid - amp * yScale;
1924 bool clipped = qAbs(amp) >= clipThresh;
1925
1926 if (v > 0 && clipped != prevClipped) {
1927 // State change — flush current segment, start new one
1928 // Include current point in the end of old segment for continuity
1929 seg.append(QPointF(xPx, yPx));
1930 p.setPen(prevClipped ? clipPen : normalPen);
1931 p.drawPolyline(seg);
1932 // Start new segment from current point
1933 seg.clear();
1934 }
1935 seg.append(QPointF(xPx, yPx));
1936 prevClipped = clipped;
1937 }
1938 // Draw remaining segment
1939 if (seg.size() > 1) {
1940 p.setPen(prevClipped ? clipPen : normalPen);
1941 p.drawPolyline(seg);
1942 }
1943 }
1944 }
1945
1946 // ── Event / stimulus marker pass ─────────────────────────────────
1947 // Draw coloured vertical lines spanning the full channel area.
1948 // Label chips are shown in the TimeRulerWidget stim lane.
1949 if (!events.isEmpty() && spp > 0.f) {
1950 for (const EventMarker &ev : events) {
1951 float xF = (static_cast<float>(ev.sample) - tileStart) / spp;
1952 if (xF < -2.f || xF > tilePixWidth + 2.f)
1953 continue;
1954 int ix = static_cast<int>(xF);
1955
1956 QColor lineColor = ev.color;
1957 lineColor.setAlpha(180);
1958 p.setPen(QPen(lineColor, 1));
1959 p.drawLine(ix, 0, ix, ph);
1960 }
1961 }
1962
1963 // ── Epoch trigger marker pass ────────────────────────────────────
1964 // Draw dashed grey vertical lines at epoch trigger positions.
1965 if (!epochMarkers.isEmpty() && spp > 0.f) {
1966 QPen epochPen(QColor(100, 100, 100, 140), 1, Qt::DashLine);
1967 p.setPen(epochPen);
1968 for (int trigSample : epochMarkers) {
1969 float xF = (static_cast<float>(trigSample) - tileStart) / spp;
1970 if (xF < -2.f || xF > tilePixWidth + 2.f)
1971 continue;
1972 int ix = static_cast<int>(xF);
1973 p.drawLine(ix, 0, ix, ph);
1974 }
1975 }
1976
1977 out.image = std::move(img);
1978 return out;
1979}
1980
1981//=============================================================================================================
1982
1983void ChannelRhiView::drawOverlays()
1984{
1985 // Schedule an overlay repaint so crosshair/scalebars/ruler stay in sync
1986 // after GPU-driven scroll/zoom repaints. Use update() (asynchronous)
1987 // instead of repaint() because this is called from within paintEvent;
1988 // synchronous repaint() from inside a paint handler can starve sibling
1989 // widgets (e.g. the overview bar) of paint cycles.
1990 if (m_overlay && (m_crosshairEnabled || m_scalebarsVisible || m_rulerActive))
1991 m_overlay->update();
1992}
1993
1994//=============================================================================================================
1995
1996static QString formatAmplitude(float amp, const QString &unit)
1997{
1998 float absAmp = qAbs(amp);
1999 if (absAmp == 0.f)
2000 return QStringLiteral("0 ") + unit;
2001 if (absAmp < 1e-9f)
2002 return QString::number(amp * 1e12f, 'f', 1) + QStringLiteral(" p") + unit;
2003 if (absAmp < 1e-6f)
2004 return QString::number(amp * 1e9f, 'f', 1) + QStringLiteral(" n") + unit;
2005 if (absAmp < 1e-3f)
2006 return QString::number(amp * 1e6f, 'f', 1) + QStringLiteral(" µ") + unit;
2007 if (absAmp < 1.f)
2008 return QString::number(amp * 1e3f, 'f', 1) + QStringLiteral(" m") + unit;
2009 return QString::number(amp, 'f', 3) + QStringLiteral(" ") + unit;
2010}
2011
2012static QString unitForType(const QString &typeLabel)
2013{
2014 if (typeLabel == QStringLiteral("MEG grad"))
2015 return QStringLiteral("T/m");
2016 if (typeLabel == QStringLiteral("MEG mag") || typeLabel == QStringLiteral("MEG"))
2017 return QStringLiteral("T");
2018 if (typeLabel == QStringLiteral("EEG") ||
2019 typeLabel == QStringLiteral("EOG") ||
2020 typeLabel == QStringLiteral("ECG") ||
2021 typeLabel == QStringLiteral("EMG"))
2022 return QStringLiteral("V");
2023 return QStringLiteral("AU");
2024}
2025
2026//=============================================================================================================
2027
2029{
2030 if (m_crosshairX < 0 || m_crosshairY < 0)
2031 return;
2032 if (!m_model || totalLogicalChannels() == 0)
2033 return;
2034
2035 const int w = width();
2036 const int h = height();
2037
2038 // Draw crosshair lines
2039 QPen crossPen(QColor(80, 80, 80, 160), 1, Qt::DashLine);
2040 p.setPen(crossPen);
2041 p.drawLine(m_crosshairX, 0, m_crosshairX, h);
2042 p.drawLine(0, m_crosshairY, w, m_crosshairY);
2043
2044 int sample = static_cast<int>(m_scrollSample + static_cast<float>(m_crosshairX) * m_samplesPerPixel);
2045 float timeSec = (m_sfreq > 0.f) ? static_cast<float>(sample - m_firstFileSample) / m_sfreq : 0.f;
2046 QString channelLabel;
2047 QString unitStr;
2048 float value = 0.f;
2049
2050 if (m_butterflyMode) {
2051 // In butterfly mode, lanes correspond to type groups
2052 const auto groups = butterflyTypeGroups();
2053 int nLanes = groups.size();
2054 if (nLanes <= 0)
2055 return;
2056 float laneH = static_cast<float>(h) / nLanes;
2057 int lane = qBound(0, static_cast<int>(m_crosshairY / laneH), nLanes - 1);
2058 channelLabel = groups[lane].typeLabel;
2059 unitStr = unitForType(groups[lane].typeLabel);
2060 } else {
2061 // Normal mode: determine the channel and sample under the cursor
2062 int totalCh = totalLogicalChannels();
2063 int visCnt = qMin(m_visibleChannelCount, totalCh - m_firstVisibleChannel);
2064 if (visCnt <= 0)
2065 return;
2066
2067 float laneH = static_cast<float>(h) / visCnt;
2068 int row = qBound(0, static_cast<int>(m_crosshairY / laneH), visCnt - 1);
2069 int ch = actualChannelAt(m_firstVisibleChannel + row);
2070 if (ch < 0)
2071 return;
2072
2073 auto info = m_model->channelInfo(ch);
2074 value = m_model->sampleValueAt(ch, sample);
2075 channelLabel = info.name;
2076 unitStr = unitForType(info.typeLabel);
2077 }
2078
2079 // Draw info label near cursor
2080 QString timeStr;
2081 if (m_useClockTime && timeSec >= 0.f) {
2082 int totalMs = static_cast<int>(timeSec * 1000.f + 0.5f);
2083 int m = totalMs / 60000;
2084 int sec = (totalMs % 60000) / 1000;
2085 int ms = totalMs % 1000;
2086 timeStr = QString("%1:%2.%3")
2087 .arg(m, 2, 10, QChar('0'))
2088 .arg(sec, 2, 10, QChar('0'))
2089 .arg(ms, 3, 10, QChar('0'));
2090 } else {
2091 timeStr = QString::number(static_cast<double>(timeSec), 'f', 3) + QStringLiteral(" s");
2092 }
2093 QString label = QString("%1 %2 %3")
2094 .arg(channelLabel,
2095 timeStr,
2096 formatAmplitude(value, unitStr));
2097
2098 QFont f = font();
2099 f.setPointSizeF(8.5);
2100 p.setFont(f);
2101 QFontMetrics fm(f);
2102 QRect labelRect = fm.boundingRect(label);
2103 int lx = m_crosshairX + 10;
2104 int ly = m_crosshairY - 10;
2105 if (lx + labelRect.width() + 8 > w)
2106 lx = m_crosshairX - labelRect.width() - 18;
2107 if (ly - labelRect.height() < 4)
2108 ly = m_crosshairY + labelRect.height() + 6;
2109 labelRect.moveTopLeft(QPoint(lx, ly - labelRect.height()));
2110 labelRect.adjust(-4, -2, 4, 2);
2111 p.fillRect(labelRect, QColor(255, 255, 255, 220));
2112 p.setPen(QColor(30, 30, 30));
2113 p.drawText(labelRect, Qt::AlignCenter, label);
2114}
2115
2116//=============================================================================================================
2117
2119{
2120 if (m_crosshairX < 0 || m_crosshairY < 0)
2121 return;
2122 if (!m_model || totalLogicalChannels() == 0)
2123 return;
2124
2125 const int h = height();
2126 int sample = static_cast<int>(m_scrollSample + static_cast<float>(m_crosshairX) * m_samplesPerPixel);
2127 float timeSec = (m_sfreq > 0.f) ? static_cast<float>(sample - m_firstFileSample) / m_sfreq : 0.f;
2128
2129 if (m_butterflyMode) {
2130 const auto groups = butterflyTypeGroups();
2131 int nLanes = groups.size();
2132 if (nLanes <= 0) return;
2133 float laneH = static_cast<float>(h) / nLanes;
2134 int lane = qBound(0, static_cast<int>(m_crosshairY / laneH), nLanes - 1);
2135 emit cursorDataChanged(timeSec, 0.f,
2136 groups[lane].typeLabel,
2137 unitForType(groups[lane].typeLabel));
2138 } else {
2139 int totalCh = totalLogicalChannels();
2140 int visCnt = qMin(m_visibleChannelCount, totalCh - m_firstVisibleChannel);
2141 if (visCnt <= 0) return;
2142 float laneH = static_cast<float>(h) / visCnt;
2143 int row = qBound(0, static_cast<int>(m_crosshairY / laneH), visCnt - 1);
2144 int ch = actualChannelAt(m_firstVisibleChannel + row);
2145 if (ch < 0) return;
2146 auto info = m_model->channelInfo(ch);
2147 float value = m_model->sampleValueAt(ch, sample);
2148 emit cursorDataChanged(timeSec, value, info.name, unitForType(info.typeLabel));
2149 }
2150}
2151
2152//=============================================================================================================
2153
2155{
2156 if (!m_model || totalLogicalChannels() == 0)
2157 return;
2158
2159 int visCnt;
2160 if (m_butterflyMode) {
2161 visCnt = butterflyLaneCount();
2162 } else {
2163 int totalCh = totalLogicalChannels();
2164 visCnt = qMin(m_visibleChannelCount, totalCh - m_firstVisibleChannel);
2165 }
2166 if (visCnt <= 0)
2167 return;
2168
2169 float laneH = static_cast<float>(height()) / visCnt;
2170
2171 // Collect unique channel types and their amplitude scales
2172 QMap<QString, float> typeScales;
2173 if (m_butterflyMode) {
2174 const auto groups = butterflyTypeGroups();
2175 for (const auto &g : groups)
2176 if (g.amplitudeMax > 0.f)
2177 typeScales[g.typeLabel] = g.amplitudeMax;
2178 } else {
2179 for (int i = 0; i < visCnt; ++i) {
2180 int ch = actualChannelAt(m_firstVisibleChannel + i);
2181 if (ch < 0) continue;
2182 auto info = m_model->channelInfo(ch);
2183 if (!typeScales.contains(info.typeLabel) && info.amplitudeMax > 0.f)
2184 typeScales[info.typeLabel] = info.amplitudeMax;
2185 }
2186 }
2187
2188 if (typeScales.isEmpty())
2189 return;
2190
2191 QFont f = font();
2192 f.setPointSizeF(8.0);
2193 p.setFont(f);
2194 QFontMetrics fm(f);
2195
2196 // Draw scalebars in the bottom-right corner
2197 const int margin = 12;
2198 const int barHeight = qBound(20, static_cast<int>(laneH * 0.35f), 60);
2199 int x = width() - margin;
2200 int y = height() - margin;
2201
2202 for (auto it = typeScales.constEnd(); it != typeScales.constBegin(); ) {
2203 --it;
2204 QString unit = unitForType(it.key());
2205 float ampValue = it.value();
2206 QString label = it.key() + QStringLiteral(": ") + formatAmplitude(ampValue, unit);
2207
2208 int textW = fm.horizontalAdvance(label);
2209 int barX = x - textW - 14;
2210
2211 // Background pill
2212 QRect bgRect(barX - 4, y - barHeight - fm.height() - 4, textW + 22, barHeight + fm.height() + 8);
2213 p.fillRect(bgRect, QColor(255, 255, 255, 200));
2214
2215 // Draw bar
2216 QPen barPen(QColor(40, 40, 40), 2);
2217 p.setPen(barPen);
2218 int barTop = y - barHeight;
2219 p.drawLine(barX + 4, barTop, barX + 4, y);
2220 // Tick marks
2221 p.drawLine(barX, barTop, barX + 8, barTop);
2222 p.drawLine(barX, y, barX + 8, y);
2223
2224 // Label
2225 p.setPen(QColor(30, 30, 30));
2226 p.drawText(barX + 14, y - barHeight / 2 + fm.ascent() / 2, label);
2227
2228 y -= barHeight + fm.height() + 16;
2229 }
2230}
2231
2232//=============================================================================================================
2233
2235{
2236 int x0 = m_rulerX0, y0 = m_rulerY0;
2237 int x1 = m_rulerX1, y1 = m_rulerY1;
2238
2239 const bool snapH = (m_rulerSnap == RulerSnap::Horizontal);
2240 const bool snapV = (m_rulerSnap == RulerSnap::Vertical);
2241
2242 const QColor activeColor(40, 120, 200, 220);
2243 const QColor dimColor(130, 160, 200, 120);
2244 int tickLen = 5;
2245
2246 // ── Semi-transparent "frozen" overlay over the measured area ──
2247 {
2248 QRect measured;
2249 if (snapH)
2250 measured = QRect(QPoint(qMin(x0, x1), 0),
2251 QPoint(qMax(x0, x1), height()));
2252 else if (snapV)
2253 measured = QRect(QPoint(0, qMin(y0, y1)),
2254 QPoint(width(), qMax(y0, y1)));
2255 else
2256 measured = QRect(QPoint(qMin(x0, x1), qMin(y0, y1)),
2257 QPoint(qMax(x0, x1), qMax(y0, y1)));
2258 p.fillRect(measured, QColor(255, 255, 255, 60));
2259 // Subtle border around the measured region
2260 p.setPen(QPen(QColor(40, 120, 200, 80), 1));
2261 p.drawRect(measured);
2262 }
2263
2264 // Vertical guide lines at the two X positions
2265 QPen vLinePen(snapV ? dimColor : activeColor, 1, Qt::DashLine);
2266 p.setPen(vLinePen);
2267 p.drawLine(x0, 0, x0, height());
2268 if (!snapV)
2269 p.drawLine(x1, 0, x1, height());
2270
2271 // Horizontal guide lines at the two Y positions (only when vertical snap)
2272 if (snapV) {
2273 QPen hGuidePen(activeColor, 1, Qt::DashLine);
2274 p.setPen(hGuidePen);
2275 p.drawLine(0, y0, width(), y0);
2276 p.drawLine(0, y1, width(), y1);
2277 }
2278
2279 // Horizontal span line at y0
2280 QPen hLinePen(snapV ? dimColor : activeColor, snapH ? 2 : 1);
2281 p.setPen(hLinePen);
2282 if (!snapV)
2283 p.drawLine(qMin(x0, x1), y0, qMax(x0, x1), y0);
2284
2285 // Vertical span line at x0
2286 QPen vSpanPen(snapH ? dimColor : activeColor, snapV ? 2 : 1);
2287 p.setPen(vSpanPen);
2288 if (!snapH)
2289 p.drawLine(x0, qMin(y0, y1), x0, qMax(y0, y1));
2290
2291 // End tick marks
2292 if (!snapV) {
2293 p.setPen(QPen(activeColor, 1));
2294 p.drawLine(x0 - tickLen, y0, x0 + tickLen, y0);
2295 p.drawLine(x1 - tickLen, y0, x1 + tickLen, y0);
2296 }
2297 if (!snapH) {
2298 p.setPen(QPen(activeColor, 1));
2299 p.drawLine(x0, y0 - tickLen, x0, y0 + tickLen);
2300 p.drawLine(x0, y1 - tickLen, x0, y1 + tickLen);
2301 }
2302
2303 // ── Measurement labels ────────────────────────────────────────────
2304 float deltaSamples = static_cast<float>(x1 - x0) * m_samplesPerPixel;
2305 float deltaSec = (m_sfreq > 0.f) ? deltaSamples / m_sfreq : 0.f;
2306
2307 float deltaAmp = 0.f;
2308 QString ampUnit = QStringLiteral("AU");
2309 if (m_model && totalLogicalChannels() > 0) {
2310 int totalCh = totalLogicalChannels();
2311 int visCnt = qMin(m_visibleChannelCount, totalCh - m_firstVisibleChannel);
2312 if (visCnt > 0) {
2313 float laneH = static_cast<float>(height()) / visCnt;
2314 int row = qBound(0, static_cast<int>(y0 / laneH), visCnt - 1);
2315 int ch = actualChannelAt(m_firstVisibleChannel + row);
2316 if (ch < 0) ch = 0;
2317 auto info = m_model->channelInfo(ch);
2318 if (info.amplitudeMax > 0.f) {
2319 float dyPx = static_cast<float>(y1 - y0);
2320 float yScale = info.amplitudeMax / (laneH * 0.45f);
2321 deltaAmp = -dyPx * yScale;
2322
2323 ampUnit = unitForType(info.typeLabel);
2324 }
2325 }
2326 }
2327
2328 auto fmtTime = [](float sec) -> QString {
2329 float absSec = qAbs(sec);
2330 if (absSec < 1.f)
2331 return QString::number(sec * 1000.f, 'f', 1) + QStringLiteral(" ms");
2332 return QString::number(sec, 'f', 3) + QStringLiteral(" s");
2333 };
2334 auto fmtAmp = [](float amp, const QString &unit) -> QString {
2335 float absAmp = qAbs(amp);
2336 if (absAmp < 1e-6f)
2337 return QString::number(amp * 1e9f, 'f', 3) + QStringLiteral(" n") + unit;
2338 if (absAmp < 1e-3f)
2339 return QString::number(amp * 1e6f, 'f', 3) + QStringLiteral(" µ") + unit;
2340 if (absAmp < 1.f)
2341 return QString::number(amp * 1e3f, 'f', 3) + QStringLiteral(" m") + unit;
2342 return QString::number(amp, 'f', 3) + QStringLiteral(" ") + unit;
2343 };
2344
2345 QString timeLabel = QStringLiteral("\u0394T = ") + fmtTime(deltaSec);
2346 QString ampLabel = QStringLiteral("\u0394A = ") + fmtAmp(deltaAmp, ampUnit);
2347
2348 QFont f = font();
2349 f.setPointSizeF(9.0);
2350 f.setBold(true);
2351 p.setFont(f);
2352 QFontMetrics fm(f);
2353
2354 // Time label (shown unless vertical snap)
2355 if (!snapV) {
2356 int labelX = (x0 + x1) / 2;
2357 int labelY = y0 - 6;
2358 if (labelY < 14)
2359 labelY = y0 + 16;
2360
2361 QRect tRect = fm.boundingRect(timeLabel);
2362 tRect.moveCenter(QPoint(labelX, labelY));
2363 tRect.adjust(-4, -2, 4, 2);
2364 p.fillRect(tRect, QColor(255, 255, 255, 210));
2365 p.setPen(QColor(20, 80, 160));
2366 p.drawText(tRect, Qt::AlignCenter, timeLabel);
2367 }
2368
2369 // Amplitude label (shown unless horizontal snap)
2370 if (!snapH) {
2371 int aLabelX = x0 + 8;
2372 int aLabelY = (y0 + y1) / 2;
2373 QRect aRect = fm.boundingRect(ampLabel);
2374 aRect.moveCenter(QPoint(aLabelX + aRect.width() / 2, aLabelY));
2375 aRect.adjust(-4, -2, 4, 2);
2376 p.fillRect(aRect, QColor(255, 255, 255, 210));
2377 p.setPen(QColor(20, 80, 160));
2378 p.drawText(aRect, Qt::AlignCenter, ampLabel);
2379 }
2380}
2381
2382//=============================================================================================================
2383
2385{
2386 const int x0 = qMin(m_annSelX0, m_annSelX1);
2387 const int x1 = qMax(m_annSelX0, m_annSelX1);
2388 const int h = height();
2389
2390 // Semi-transparent fill matching annotation overlay style
2391 p.fillRect(QRect(x0, 0, x1 - x0, h), QColor(210, 60, 60, 50));
2392
2393 // Left and right borders
2394 QPen borderPen(QColor(210, 60, 60, 180), 2);
2395 p.setPen(borderPen);
2396 p.drawLine(x0, 0, x0, h);
2397 p.drawLine(x1, 0, x1, h);
2398
2399 // Duration label pill at the top
2400 if (m_sfreq > 0.f && m_samplesPerPixel > 0.f) {
2401 float deltaSamples = static_cast<float>(x1 - x0) * m_samplesPerPixel;
2402 float deltaSec = deltaSamples / m_sfreq;
2403 QString label;
2404 if (deltaSec < 1.f)
2405 label = QString::number(deltaSec * 1000.f, 'f', 0) + QStringLiteral(" ms");
2406 else
2407 label = QString::number(deltaSec, 'f', 2) + QStringLiteral(" s");
2408
2409 QFont f = p.font();
2410 f.setPointSizeF(8.0);
2411 f.setBold(true);
2412 p.setFont(f);
2413 QFontMetrics fm(f);
2414 QRect labelRect = fm.boundingRect(label);
2415 labelRect.adjust(-6, -2, 6, 2);
2416 labelRect.moveTopLeft(QPoint(x0 + 4, 4));
2417 p.fillRect(labelRect, QColor(210, 60, 60, 215));
2418 p.setPen(Qt::white);
2419 p.drawText(labelRect, Qt::AlignCenter, label);
2420 }
2421}
2422
2423//=============================================================================================================
2424// Shared event handlers
2425//=============================================================================================================
2426
2427void ChannelRhiView::resizeEvent(QResizeEvent *event)
2428{
2429 QRhiWidget::resizeEvent(event);
2430 m_vboDirty = true;
2431 m_overlayDirty = true;
2432 m_tileDirty = true;
2433 if (m_overlay) m_overlay->syncSize();
2434 emit viewResized(width(), height());
2435 update();
2436}
2437
2438//=============================================================================================================
2439
2440void ChannelRhiView::wheelEvent(QWheelEvent *event)
2441{
2442 const QPoint delta = event->angleDelta();
2443
2444 if (event->modifiers() & Qt::ControlModifier) {
2445 // Ctrl + wheel → zoom time axis
2446 float factor = (delta.y() > 0) ? 0.8f : 1.25f;
2447 zoomTo(m_samplesPerPixel * factor, 150);
2448
2449 } else if (qAbs(delta.x()) > qAbs(delta.y())) {
2450 // Predominantly horizontal gesture (trackpad swipe left/right) → scroll time
2451 if (!m_frozen) {
2452 float step = width() * m_samplesPerPixel * 0.1f * m_scrollSpeedFactor
2453 * (delta.x() > 0 ? -1.f : 1.f);
2454 scrollTo(m_scrollSample + step, 100);
2455 }
2456
2457 } else if (m_wheelScrollsChannels) {
2458 // Vertical wheel → scroll channels (up = earlier, down = later)
2459 int channelStep = (delta.y() > 0) ? -1 : 1;
2460 int maxFirst = qMax(0, totalLogicalChannels() - m_visibleChannelCount);
2461 setFirstVisibleChannel(qBound(0, m_firstVisibleChannel + channelStep, maxFirst));
2462 } else {
2463 // Vertical wheel → scroll time
2464 if (!m_frozen) {
2465 float step = width() * m_samplesPerPixel * 0.15f * m_scrollSpeedFactor
2466 * (delta.y() > 0 ? -1.f : 1.f);
2467 scrollTo(m_scrollSample + step, 100);
2468 }
2469 }
2470
2471 event->accept();
2472}
2473
2474//=============================================================================================================
2475
2476void ChannelRhiView::mousePressEvent(QMouseEvent *event)
2477{
2478 // Right-click → annotation range selection (when annotation mode is ON)
2479 // → ruler measurement (when annotation mode is OFF)
2480 if (event->button() == Qt::RightButton) {
2481 // Stop any running inertial scroll
2482 if (m_pInertialAnim) {
2483 m_pInertialAnim->stop();
2484 m_pInertialAnim = nullptr;
2485 }
2486
2487 if (m_annotationSelectionEnabled) {
2488 // Annotation mode: right-drag creates a new annotation range
2489 m_annSelecting = true;
2490 m_annSelX0 = m_annSelX1 = event->position().toPoint().x();
2491 if (m_overlay) m_overlay->repaint();
2492 } else {
2493 // Normal mode: right-drag starts ruler measurement
2494 m_rulerActive = true;
2495 m_rulerSnap = RulerSnap::Free;
2496 m_rulerX0 = m_rulerX1 = m_rulerRawX1 = event->position().toPoint().x();
2497 m_rulerY0 = m_rulerY1 = m_rulerRawY1 = event->position().toPoint().y();
2498 if (m_overlay) m_overlay->repaint();
2499 }
2500 event->accept();
2501 return;
2502 }
2503
2504 if (!m_frozen &&
2505 (event->button() == Qt::MiddleButton ||
2506 (event->button() == Qt::LeftButton && (event->modifiers() & Qt::AltModifier)))) {
2507 m_dragging = true;
2508 m_dragStartX = event->position().toPoint().x();
2509 m_dragStartScroll = m_scrollSample;
2510 event->accept();
2511 return;
2512 }
2513 if (event->button() == Qt::LeftButton) {
2514 // Stop any running inertial scroll
2515 if (m_pInertialAnim) {
2516 m_pInertialAnim->stop();
2517 m_pInertialAnim = nullptr;
2518 }
2519
2520 // Check if clicking on an annotation boundary for drag-resize
2521 if (m_annotationSelectionEnabled && !m_annotations.isEmpty()) {
2522 bool isStart = false;
2523 int hitIdx = hitTestAnnotationBoundary(event->position().toPoint().x(), isStart);
2524 if (hitIdx >= 0) {
2525 m_annDragging = true;
2526 m_annDragIndex = hitIdx;
2527 m_annDragIsStart = isStart;
2528 event->accept();
2529 return;
2530 }
2531 }
2532
2533 if (m_frozen) {
2534 // Frozen: clicks still emit sampleClicked but no drag
2535 float samplePos = m_scrollSample
2536 + static_cast<float>(event->position().x()) * m_samplesPerPixel;
2537 emit sampleClicked(static_cast<int>(samplePos));
2538 event->accept();
2539 return;
2540 }
2541 // Record start position; activate drag on move (threshold in mouseMoveEvent)
2542 m_leftButtonDown = true;
2543 m_leftDragActivated = false;
2544 m_leftDownX = event->position().toPoint().x();
2545 m_leftDownScroll = m_scrollSample;
2546 m_velocityHistory.clear();
2547 m_dragTimer.start();
2548 m_velocityHistory.append({m_leftDownX, 0});
2549 event->accept();
2550 return;
2551 }
2552 QRhiWidget::mousePressEvent(event);
2553}
2554
2555//=============================================================================================================
2556
2557void ChannelRhiView::mouseMoveEvent(QMouseEvent *event)
2558{
2559 // ── Annotation boundary drag-resize ──────────────────────────────
2560 if (m_annDragging) {
2561 // Visually update the annotation boundary while dragging
2562 int newSample = static_cast<int>(m_scrollSample
2563 + static_cast<float>(event->position().toPoint().x()) * m_samplesPerPixel);
2564 newSample = qMax(newSample, m_firstFileSample);
2565 if (m_lastFileSample >= 0)
2566 newSample = qMin(newSample, m_lastFileSample);
2567
2568 if (m_annDragIndex >= 0 && m_annDragIndex < m_annotations.size()) {
2569 if (m_annDragIsStart)
2570 m_annotations[m_annDragIndex].startSample = newSample;
2571 else
2572 m_annotations[m_annDragIndex].endSample = newSample;
2573 m_overlayDirty = true;
2574 m_tileDirty = true;
2575 update();
2576 }
2577 event->accept();
2578 return;
2579 }
2580
2581 // ── Annotation range selection drag (right-button, annotation mode) ─
2582 if (m_annSelecting) {
2583 m_annSelX1 = event->position().toPoint().x();
2584 if (m_overlay) m_overlay->repaint();
2585 event->accept();
2586 return;
2587 }
2588
2589 if (m_rulerActive) {
2590 m_rulerRawX1 = event->position().toPoint().x();
2591 m_rulerRawY1 = event->position().toPoint().y();
2592
2593 // Snap logic: if displacement is dominantly horizontal → lock to horizontal,
2594 // if dominantly vertical → lock to vertical, otherwise free
2595 int dx = qAbs(m_rulerRawX1 - m_rulerX0);
2596 int dy = qAbs(m_rulerRawY1 - m_rulerY0);
2597 const int kSnapThresh = 8; // minimum movement before snapping
2598 if (dx < kSnapThresh && dy < kSnapThresh) {
2599 m_rulerSnap = RulerSnap::Free;
2600 } else if (dx > dy * 2) {
2601 m_rulerSnap = RulerSnap::Horizontal;
2602 } else if (dy > dx * 2) {
2603 m_rulerSnap = RulerSnap::Vertical;
2604 } else {
2605 m_rulerSnap = RulerSnap::Free;
2606 }
2607
2608 // Apply snap
2609 switch (m_rulerSnap) {
2610 case RulerSnap::Horizontal:
2611 m_rulerX1 = m_rulerRawX1;
2612 m_rulerY1 = m_rulerY0; // lock Y
2613 break;
2614 case RulerSnap::Vertical:
2615 m_rulerX1 = m_rulerX0; // lock X
2616 m_rulerY1 = m_rulerRawY1;
2617 break;
2618 default:
2619 m_rulerX1 = m_rulerRawX1;
2620 m_rulerY1 = m_rulerRawY1;
2621 break;
2622 }
2623
2624 if (m_overlay) m_overlay->repaint();
2625 event->accept();
2626 return;
2627 }
2628
2629 if (m_dragging) {
2630 int dx = event->position().toPoint().x() - m_dragStartX;
2631 float newScroll = m_dragStartScroll - static_cast<float>(dx) * m_samplesPerPixel;
2632 setScrollSample(newScroll);
2633 event->accept();
2634 return;
2635 }
2636 if (m_leftButtonDown) {
2637 int x = event->position().toPoint().x();
2638 int dx = x - m_leftDownX;
2639 if (!m_leftDragActivated && qAbs(dx) > 5)
2640 m_leftDragActivated = true;
2641 if (m_leftDragActivated) {
2642 float newScroll = m_leftDownScroll - static_cast<float>(dx) * m_samplesPerPixel;
2643 setScrollSample(newScroll);
2644
2645 // Record velocity sample; keep only the last 100 ms
2646 qint64 now = m_dragTimer.elapsed();
2647 m_velocityHistory.append({x, now});
2648 while (m_velocityHistory.size() > 1 &&
2649 now - m_velocityHistory.first().t > 100)
2650 m_velocityHistory.removeFirst();
2651
2652 event->accept();
2653 return;
2654 }
2655 }
2656
2657 // ── Crosshair tracking (passive mouse tracking without buttons) ──
2658 if (m_crosshairEnabled) {
2659 m_crosshairX = event->position().toPoint().x();
2660 m_crosshairY = event->position().toPoint().y();
2661 if (m_overlay) m_overlay->repaint();
2662
2663 // Emit cursor data signal here (not from drawCrosshair) to keep
2664 // signal emission out of the paint path and avoid repaint cascades.
2666 }
2667
2668 // ── Annotation boundary hover cursor ─────────────────────────────
2669 if (m_annotationSelectionEnabled && !m_annotations.isEmpty()) {
2670 bool isStart = false;
2671 int hitIdx = hitTestAnnotationBoundary(event->position().toPoint().x(), isStart);
2672 if (hitIdx >= 0) {
2673 if (m_annHoverIndex != hitIdx || m_annHoverIsStart != isStart) {
2674 m_annHoverIndex = hitIdx;
2675 m_annHoverIsStart = isStart;
2676 setCursor(Qt::SizeHorCursor);
2677 }
2678 } else if (m_annHoverIndex >= 0) {
2679 m_annHoverIndex = -1;
2680 unsetCursor();
2681 }
2682 }
2683
2684 QRhiWidget::mouseMoveEvent(event);
2685}
2686
2687//=============================================================================================================
2688
2690{
2691 // ── Annotation boundary drag-resize completion ───────────────────
2692 if (m_annDragging && event->button() == Qt::LeftButton) {
2693 int newSample = static_cast<int>(m_scrollSample
2694 + static_cast<float>(event->position().toPoint().x()) * m_samplesPerPixel);
2695 newSample = qMax(newSample, m_firstFileSample);
2696 if (m_lastFileSample >= 0)
2697 newSample = qMin(newSample, m_lastFileSample);
2698
2699 emit annotationBoundaryMoved(m_annDragIndex, m_annDragIsStart, newSample);
2700 m_annDragging = false;
2701 m_annDragIndex = -1;
2702 event->accept();
2703 return;
2704 }
2705
2706 // ── Annotation range selection completion (right-button, annotation mode) ─
2707 if (m_annSelecting && event->button() == Qt::RightButton) {
2708 m_annSelX1 = event->position().toPoint().x();
2709 m_annSelecting = false;
2710 if (m_overlay) m_overlay->repaint();
2711
2712 const int x0 = qMin(m_annSelX0, m_annSelX1);
2713 const int x1 = qMax(m_annSelX0, m_annSelX1);
2714 if (qAbs(x1 - x0) > 3) {
2715 int startSample = static_cast<int>(m_scrollSample + static_cast<float>(x0) * m_samplesPerPixel);
2716 int endSample = static_cast<int>(m_scrollSample + static_cast<float>(x1) * m_samplesPerPixel);
2717
2718 startSample = qMax(startSample, m_firstFileSample);
2719 if (m_lastFileSample >= 0)
2720 endSample = qMin(endSample, m_lastFileSample);
2721
2722 if (endSample >= startSample)
2723 emit sampleRangeSelected(startSample, endSample);
2724 }
2725 event->accept();
2726 return;
2727 }
2728
2729 if (m_rulerActive && event->button() == Qt::RightButton) {
2730 m_rulerRawX1 = event->position().toPoint().x();
2731 m_rulerRawY1 = event->position().toPoint().y();
2732 // Apply final snap
2733 switch (m_rulerSnap) {
2734 case RulerSnap::Horizontal:
2735 m_rulerX1 = m_rulerRawX1; m_rulerY1 = m_rulerY0; break;
2736 case RulerSnap::Vertical:
2737 m_rulerX1 = m_rulerX0; m_rulerY1 = m_rulerRawY1; break;
2738 default:
2739 m_rulerX1 = m_rulerRawX1; m_rulerY1 = m_rulerRawY1; break;
2740 }
2741 m_rulerActive = false;
2742 if (m_overlay) m_overlay->repaint();
2743
2744 event->accept();
2745 return;
2746 }
2747
2748 if (m_dragging && (event->button() == Qt::MiddleButton ||
2749 event->button() == Qt::LeftButton)) {
2750 m_dragging = false;
2751 event->accept();
2752 return;
2753 }
2754 if (event->button() == Qt::LeftButton && m_leftButtonDown) {
2755 if (!m_leftDragActivated) {
2756 // Short tap — emit click position, no inertia
2757 float samplePos = m_leftDownScroll
2758 + static_cast<float>(event->position().x()) * m_samplesPerPixel;
2759 emit sampleClicked(static_cast<int>(samplePos));
2760 } else {
2761 // Compute velocity from recent history and launch inertial animation
2762 if (m_velocityHistory.size() >= 2) {
2763 auto oldest = m_velocityHistory.first();
2764 auto newest = m_velocityHistory.last();
2765 float dt = static_cast<float>(newest.t - oldest.t);
2766 if (dt > 5.f) {
2767 float dx = static_cast<float>(newest.x - oldest.x);
2768 // px/ms → samples/ms (positive dx = dragging right = going backward)
2769 float velSampPerMs = -(dx / dt) * m_samplesPerPixel;
2770 float speed = qAbs(velSampPerMs);
2771 if (speed > 0.3f) { // threshold: ~300 samples/s minimum
2772 // OutCubic: f'(0) = 3, so travel = v × duration / 3.
2773 // Longer duration and distance for a smooth, phone-like glide.
2774 float durationMs = qBound(500.f, speed * 1.0f, 5000.f);
2775 float targetScroll = m_scrollSample + velSampPerMs * durationMs / 3.f;
2776 targetScroll = qMax(targetScroll, static_cast<float>(m_firstFileSample));
2777
2778 m_pInertialAnim = new QPropertyAnimation(this, "scrollSample", this);
2779 m_pInertialAnim->setDuration(static_cast<int>(durationMs));
2780 m_pInertialAnim->setEasingCurve(QEasingCurve::OutCubic);
2781 m_pInertialAnim->setStartValue(m_scrollSample);
2782 m_pInertialAnim->setEndValue(targetScroll);
2783 connect(m_pInertialAnim, &QPropertyAnimation::finished, this, [this]() {
2784 m_pInertialAnim = nullptr;
2785 });
2786 m_pInertialAnim->start(QAbstractAnimation::DeleteWhenStopped);
2787 }
2788 }
2789 }
2790 }
2791 m_leftButtonDown = false;
2792 m_leftDragActivated = false;
2793 event->accept();
2794 return;
2795 }
2796 QRhiWidget::mouseReleaseEvent(event);
2797}
2798
2799//=============================================================================================================
2800
2802{
2803 if (!m_model || event->button() != Qt::LeftButton) {
2804 QRhiWidget::mouseDoubleClickEvent(event);
2805 return;
2806 }
2807
2808 int totalCh = totalLogicalChannels();
2809 int visCnt = qMin(m_visibleChannelCount, totalCh - m_firstVisibleChannel);
2810 if (visCnt <= 0)
2811 return;
2812
2813 float laneH = static_cast<float>(height()) / visCnt;
2814 int row = static_cast<int>(event->position().y() / laneH);
2815 if (row >= 0 && row < visCnt) {
2816 int ch = actualChannelAt(m_firstVisibleChannel + row);
2817 if (ch >= 0) {
2818 auto info = m_model->channelInfo(ch);
2819 m_model->setChannelBad(ch, !info.bad);
2820 }
2821 }
2822 event->accept();
2823}
Declaration of the ChannelRhiView class.
2-D display widgets and visualisation helpers (charts, topography, colour maps).
ChannelDataModel – lightweight data container for ChannelDataView / ChannelRhiView.
ChannelDisplayInfo channelInfo(int channelIdx) const
QVector< float > decimatedVertices(int channelIdx, int firstSample, int lastSample, int pixelWidth, int &vboFirstSample) const
CrosshairOverlay(ChannelRhiView *parent)
void paintEvent(QPaintEvent *) override
ChannelRhiView – QRhiWidget-based channel signal renderer.
void viewResized(int newWidth, int newHeight)
ChannelRhiView(QWidget *parent=nullptr)
void resizeEvent(QResizeEvent *event) override
void setAnnotationSelectionEnabled(bool enabled)
void setFirstFileSample(int first)
void channelOffsetChanged(int firstChannel)
void samplesPerPixelChanged(float spp)
void setLastFileSample(int last)
void sampleRangeSelected(int startSample, int endSample)
void setEvents(const QVector< EventMarker > &events)
void setAnnotations(const QVector< AnnotationSpan > &annotations)
friend class ::CrosshairOverlay
void render(QRhiCommandBuffer *cb) override
void scrollTo(float targetSample, int durationMs=200)
void setPrefetchFactor(float factor)
void setButterflyMode(bool enabled)
void drawScalebars(QPainter &p)
void setScalebarsVisible(bool visible)
void setSamplesPerPixel(float spp)
void wheelEvent(QWheelEvent *event) override
void annotationBoundaryMoved(int annotationIndex, bool isStartBoundary, int newSample)
void setModel(ChannelDataModel *model)
void setEventsVisible(bool visible)
void setCrosshairEnabled(bool enabled)
void mousePressEvent(QMouseEvent *event) override
void initialize(QRhiCommandBuffer *cb) override
void setScrollSpeedFactor(float factor)
void setClippingVisible(bool visible)
void setEpochMarkersVisible(bool visible)
void mouseMoveEvent(QMouseEvent *event) override
void drawCrosshair(QPainter &p)
void releaseResources() override
void setFirstVisibleChannel(int ch)
void setVisibleChannelCount(int count)
void setSfreq(float sfreq)
void setEpochMarkers(const QVector< int > &triggerSamples)
void setAnnotationsVisible(bool visible)
void setChannelIndices(const QVector< int > &indices)
void drawAnnotationSelectionOverlay(QPainter &p)
void setZScoreMode(bool enabled)
void setHideBadChannels(bool hide)
void sampleClicked(int sample)
void setBackgroundColor(const QColor &color)
void setScrollSample(float sample)
void mouseReleaseEvent(QMouseEvent *event) override
void paintEvent(QPaintEvent *event) override
void cursorDataChanged(float timeSec, float amplitude, const QString &channelName, const QString &unitLabel)
void drawRulerOverlay(QPainter &p)
void setGridVisible(bool visible)
void scrollSampleChanged(float sample)
void zoomTo(float targetSpp, int durationMs=200)
void setFrozen(bool frozen)
void setWheelScrollsChannels(bool channelsMode)
void mouseDoubleClickEvent(QMouseEvent *event) override
Stimulus / event marker — a coloured vertical line at a given sample.
Time-span annotation overlay.