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