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