Repository navigation
Expand file tree
/
Copy pathTimeWindowTool.m
More file actions
683 lines (643 loc) · 28.3 KB
/
Copy pathTimeWindowTool.m
File metadata and controls
683 lines (643 loc) · 28.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
classdef TimeWindowTool < handle
%TIMEWINDOWTOOL Restrict curation to a draggable time window.
% TimeWindowTool(app, curate) opens a companion window (launched from the
% Curation widget's Tools menu) for splitting units that swap amplitude
% rank over time on a single electrode.
%
% The top panel is amplitude (peak-to-peak) vs time for the curated
% clusters, with two draggable vertical bars marking the window start and
% stop. The bottom panel is a raw-trace excerpt at the bar you last moved,
% so you can verify the cut. Moving a bar restricts the Curation widget to
% the clusters' spikes within [start, stop] — isolate a band where the two
% amplitudes are separated, split there, then step to the next band.
%
% Buttons snap the start to 0, the stop to the end, or revert to the whole
% recording.
%
% "Split bands" detects horizontal amplitude bands (drift-tolerant)
% among the spikes inside the current [start, stop] window - place the
% bars around an epoch to band-split just that stretch. Amplitudes are
% divided by the main band's running median over time, and the bands
% are the modes of the resulting ratio density. Band 1 is the main
% (largest) band.
% Pick a band to see its spikes selected in the Curation widget, split
% one off with "Band -> new cluster", or give every out-of-band its
% own cluster in one go.
%
% See also CurationTool, SpikeVisualizationApp.
properties (Access = private)
App
Curate % CurationTool
ClusterIds double
UIFigure matlab.ui.Figure
AmpAxes matlab.ui.control.UIAxes
TraceAxes matlab.ui.control.UIAxes
InfoLabel matlab.ui.control.Label
StartLine images.roi.Line
StopLine images.roi.Line
Suppress logical = false
FocusSec double % time the trace excerpt is centred on
FocusColor double = [0 0.6 0] % marker colour: green/red = bar, grey = scrub
TraceSlider matlab.ui.control.Slider % scrub the trace excerpt
AmpYLim double % full (data-derived) y-range of the amp plot
AmpXFull double = [] % full x-range last applied to the amp plot
CurationSelIdx double = [] % app spike indices selected in Curation
PlotIdx double = [] % app spike index behind each amp point
PlotX double = [] % time (s) of each amp point
PlotY double = [] % peak-to-peak (uV) of each amp point
BandIdx double = [] % app spike index behind each band label
BandLabels double = [] % band per BandIdx entry (1 = main band)
BandDropdown matlab.ui.control.DropDown
BandMoveBtn matlab.ui.control.Button
BandMoveAllBtn matlab.ui.control.Button
end
methods
function tool = TimeWindowTool(app, curate)
arguments
app (1,1) SpikeVisualizationApp
curate (1,1) CurationTool
end
tool.App = app;
tool.Curate = curate;
tool.ClusterIds = curate.shownClusters();
tool.CurationSelIdx = curate.globalSelection();
w = app.timeWindowSec();
tool.FocusSec = w(1);
SpikeVisualizationApp.logEvent("TimeWindowTool ctor start");
tool.buildUI();
tool.redrawAmp();
SpikeVisualizationApp.logEvent("TimeWindowTool amp plot done");
tool.plotTrace();
tool.updateInfo();
SpikeVisualizationApp.logEvent("TimeWindowTool ctor end");
end
function delete(tool)
if ~isempty(tool.UIFigure) && isvalid(tool.UIFigure)
delete(tool.UIFigure);
end
end
function h = figureHandle(tool)
h = tool.UIFigure;
end
function setCurationSelection(tool, globalIdx)
%SETCURATIONSELECTION Reflect the Curation selection here (live link).
tool.CurationSelIdx = globalIdx;
tool.redrawAmp();
end
end
methods (Access = private)
function dur = duration(tool)
dur = tool.App.Spikes.numSamples / tool.App.Spikes.samplingRate;
end
function buildUI(tool)
tool.UIFigure = uifigure(Name="Time-window split", ...
Position=[160 120 1000 682], ...
CloseRequestFcn=@(~, ~) delete(tool));
g = uigridlayout(tool.UIFigure, [5 1]);
g.RowHeight = {"1x", "1x", 22, 34, 34};
g.Padding = [6 6 6 6];
tool.AmpAxes = uiaxes(g);
tool.AmpAxes.Layout.Row = 1;
tool.TraceAxes = uiaxes(g);
tool.TraceAxes.Layout.Row = 2;
tool.TraceSlider = uislider(g, Limits=[0 1], Value=0, ...
MajorTicks=[], MinorTicks=[], ...
ValueChangedFcn=@(~, e) tool.onTraceSlider(e));
tool.TraceSlider.Layout.Row = 3;
btns = uigridlayout(g, [1 6]);
btns.Layout.Row = 4;
btns.Padding = [0 0 0 0];
btns.ColumnWidth = {110, 110, 115, 120, 160, "1x"};
uibutton(btns, Text="Lasso select", BackgroundColor=[0.9 0.9 1], ...
ButtonPushedFcn=@(~, ~) tool.lassoSelect());
uibutton(btns, Text="Split bands", BackgroundColor=[0.9 1 0.9], ...
Tooltip="Detect horizontal amplitude bands (drift-tolerant)", ...
ButtonPushedFcn=@(~, ~) tool.splitBands());
uibutton(btns, Text="Snap start", ...
Tooltip="Start bar to the left edge of the view " + ...
"(0 when not zoomed)", ...
ButtonPushedFcn=@(~, ~) tool.snapStart());
uibutton(btns, Text="Snap stop", ...
Tooltip="Stop bar to the right edge of the view " + ...
"(recording end when not zoomed)", ...
ButtonPushedFcn=@(~, ~) tool.snapStop());
uibutton(btns, Text="Revert (whole recording)", ...
ButtonPushedFcn=@(~, ~) tool.revert());
tool.InfoLabel = uilabel(btns, Text="");
bandRow = uigridlayout(g, [1 4]);
bandRow.Layout.Row = 5;
bandRow.Padding = [0 0 0 0];
bandRow.ColumnWidth = {200, 160, 200, "1x"};
tool.BandDropdown = uidropdown(bandRow, Items={'(no bands yet)'}, ...
Tooltip="Pick a band to see its spikes in the Curation widget", ...
ValueChangedFcn=@(s, ~) tool.selectBand(s.Value));
tool.BandMoveBtn = uibutton(bandRow, Text="Band -> new cluster", ...
Enable="off", ButtonPushedFcn=@(~, ~) tool.moveBand());
tool.BandMoveAllBtn = uibutton(bandRow, ...
Text="Out-of-bands -> new clusters", Enable="off", ...
Tooltip="Give every band except the main one its own cluster", ...
ButtonPushedFcn=@(~, ~) tool.moveOutOfBands());
uilabel(bandRow, Text="band 1 = main band", ...
FontColor=[0.45 0.45 0.45]);
end
function redrawAmp(tool)
% Redraw the amplitude plot then the boundary bars (cla drops them).
tool.plotAmplitude();
tool.drawBoundaries();
end
function plotAmplitude(tool)
ax = tool.AmpAxes;
% A toolbar zoom to an epoch must survive redraws (snapping and
% selection updates replot this panel) - remember whether the
% view differs from the full range we last applied.
oldX = ax.XLim;
oldY = ax.YLim;
wasZoomed = ~isempty(tool.AmpXFull) && ...
(~isequal(oldX, tool.AmpXFull) || ~isequal(oldY, tool.AmpYLim));
cla(ax);
hold(ax, "on");
s = tool.App.Spikes;
uv = 1;
if isfield(s, "uvPerADC")
uv = s.uvPerADC;
end
allIdx = find(ismember(s.clusters, tool.ClusterIds));
tool.PlotIdx = allIdx;
tool.PlotX = s.spikeTimes(allIdx) / s.samplingRate;
tool.PlotY = s.amplitudePP(allIdx) * uv;
% Colour by detected band when Split bands has run, else by cluster.
lab = zeros(numel(allIdx), 1);
if ~isempty(tool.BandIdx)
[tf, loc] = ismember(allIdx, tool.BandIdx);
lab(tf) = tool.BandLabels(loc(tf));
end
if any(lab > 0)
% Grey follows the CURRENT bars, re-evaluated on every
% redraw: outside-window spikes grey, band-labelled spikes in
% their band colour, in-window spikes without a band label
% (window moved since the split) in default cluster colours.
w = tool.App.timeWindowSec();
inWin = tool.PlotX >= w(1) & tool.PlotX <= w(2);
k = max(lab);
pal = lines(k);
hs = gobjects(1, k);
for b = 1:k
m = lab == b & inWin;
hs(b) = scatter(ax, tool.PlotX(m), tool.PlotY(m), 4, ...
pal(b, :), "filled", MarkerFaceAlpha=0.3);
end
rest = lab == 0 & inWin;
for cid = tool.ClusterIds
m = rest & (s.clusters(allIdx) == cid);
if any(m)
scatter(ax, tool.PlotX(m), tool.PlotY(m), 4, ...
tool.App.clusterColor(cid), "filled", ...
MarkerFaceAlpha=0.3);
end
end
if any(~inWin)
scatter(ax, tool.PlotX(~inWin), tool.PlotY(~inWin), ...
4, [0.7 0.7 0.7], "filled", MarkerFaceAlpha=0.3);
end
legend(ax, hs, "band " + string(1:k), Location="best", ...
Box="off", AutoUpdate="off");
else
for cid = tool.ClusterIds
m = s.clusters(allIdx) == cid;
scatter(ax, tool.PlotX(m), tool.PlotY(m), 4, ...
tool.App.clusterColor(cid), "filled", MarkerFaceAlpha=0.3);
end
end
hi = ismember(allIdx, tool.CurationSelIdx); % Curation selection
if any(hi)
scatter(ax, tool.PlotX(hi), tool.PlotY(hi), 16, [0 0 0], ...
"o", LineWidth=0.75);
end
hold(ax, "off");
title(ax, sprintf("Amplitude vs time (drag bars; %d selected)", sum(hi)));
xlabel(ax, "Time (s)");
ylabel(ax, "Peak-to-peak (\muV)");
% Fixed, data-derived limits: ylim(ax) is stale before the axes
% first renders, which used to give the boundary bars a tiny
% vertical extent (invisible until some later redraw). The small
% x padding keeps the bars grabbable at 0 / end of recording.
dur = tool.duration();
tool.AmpXFull = [-0.01 * dur, 1.01 * dur];
if isempty(tool.PlotY)
tool.AmpYLim = [0 1];
else
lo = min(tool.PlotY);
hi2 = max(tool.PlotY);
pad = 0.05 * max(hi2 - lo, eps);
tool.AmpYLim = [lo - pad, hi2 + pad];
end
if wasZoomed
xlim(ax, oldX); % keep the user's epoch view
ylim(ax, oldY);
else
xlim(ax, tool.AmpXFull);
ylim(ax, tool.AmpYLim);
end
end
function splitBands(tool)
%SPLITBANDS Detect horizontal amplitude bands and number them by
% size (band 1 = main band). Two passes: first against a static
% reference (the overall median), then against a drift trend
% computed from the main band only - a running median over ALL
% spikes jumps between bands when two are comparable in size,
% which smears the ratio density and hides the bands.
% Only the spikes inside the current [start, stop] window take
% part - place the bars around an epoch to band-split just that.
w = tool.App.timeWindowSec();
inWin = tool.PlotX >= w(1) & tool.PlotX <= w(2);
t = tool.PlotX(inWin);
a = tool.PlotY(inWin);
n = numel(a);
if n < 50
tool.InfoLabel.Text = "Too few spikes in the window to find bands.";
return
end
SpikeVisualizationApp.logEvent(sprintf("splitBands start (%d spikes)", n));
[lab, counts] = tool.bandsFromRatio( ...
log2(max(a, eps) / max(median(a), eps)));
trend = tool.bandTrend(t, a, lab == 1, w);
[lab2, counts2] = tool.bandsFromRatio( ...
log2(max(a, eps) ./ max(trend, eps)));
if numel(counts2) >= numel(counts) % keep the better separation
lab = lab2;
counts = counts2;
end
tool.BandIdx = tool.PlotIdx(inWin);
tool.BandLabels = lab;
k = numel(counts);
tool.BandDropdown.Items = ["(pick a band)"; ...
"band " + string(1:k)' + " (" + string(counts(:)) + " spikes)"];
tool.BandDropdown.ItemsData = [{[]}; num2cell(1:k)'];
en = matlab.lang.OnOffSwitchState(k >= 2);
tool.BandMoveBtn.Enable = en;
tool.BandMoveAllBtn.Enable = en;
tool.redrawAmp();
SpikeVisualizationApp.logEvent("splitBands end");
epoch = "";
if w(1) > 0 || w(2) < tool.duration()
epoch = sprintf(" in %.1f-%.1f s", w(1), w(2));
end
if k < 2
tool.InfoLabel.Text = "Only one band found" + epoch + ...
" - nothing to split.";
else
tool.InfoLabel.Text = sprintf("%d bands found%s (band 1 = " + ...
"main). Pick one to inspect, or move the out-of-bands.", ...
k, epoch);
end
end
function trend = bandTrend(~, t, a, mask, w)
%BANDTREND Smoothed running median of a(mask) over time (the
% drift of one band), interpolated onto every t. Falls back to
% the static median when too few bins have data.
nb = max(10, min(60, floor(sum(mask) / 100)));
edges = linspace(w(1), w(2), nb + 1);
ctr = (edges(1:end-1) + edges(2:end)) / 2;
med = nan(1, nb);
for i = 1:nb
bi = mask & t >= edges(i) & t < edges(i + 1);
if sum(bi) >= 5
med(i) = median(a(bi));
end
end
ok = find(~isnan(med));
if numel(ok) >= 2
med = movmean(med, 3, "omitnan");
tc = min(max(t, ctr(ok(1))), ctr(ok(end)));
trend = interp1(ctr(ok), med(ok), tc, "linear");
else
trend = repmat(median(a(mask)), size(a));
end
end
function [lab, counts] = bandsFromRatio(~, lr)
%BANDSFROMRATIO Band label per spike from log-amplitude ratios.
% Bands = modes of the ratio density, merged until every valley
% is deep (density below half the smaller neighbouring peak, a
% scale-free test so sparse bands survive next to dense ones),
% tiny slivers folded in, then numbered by size (1 = biggest).
n = numel(lr);
[f, xi] = ksdensity(lr, NumPoints=512);
pk = find(islocalmax(f));
vl = find(islocalmin(f));
while numel(pk) > 1
sep = zeros(1, numel(pk) - 1);
for j = 1:numel(pk) - 1
vs = vl(vl > pk(j) & vl < pk(j + 1));
sep(j) = min(f(vs)) / min(f(pk(j)), f(pk(j + 1)));
end
[worst, wj] = max(sep);
if worst <= 0.5
break
end
if f(pk(wj)) < f(pk(wj + 1))
pk(wj) = [];
else
pk(wj + 1) = [];
end
end
cuts = zeros(1, numel(pk) - 1);
for j = 1:numel(pk) - 1
vs = vl(vl > pk(j) & vl < pk(j + 1));
[~, vi] = min(f(vs));
cuts(j) = xi(vs(vi));
end
raw = discretize(lr, [-inf, cuts, inf]);
% Fold tiny slivers into their bigger neighbour.
minCount = max(20, round(0.002 * n));
cnt = accumarray(raw(:), 1, [numel(cuts) + 1, 1]);
while ~isempty(cuts) && min(cnt) < minCount
[~, i] = min(cnt);
if i == 1
drop = 1;
elseif i == numel(cnt)
drop = numel(cuts);
elseif cnt(i - 1) >= cnt(i + 1)
drop = i - 1;
else
drop = i;
end
cuts(drop) = [];
raw = discretize(lr, [-inf, cuts, inf]);
cnt = accumarray(raw(:), 1, [numel(cuts) + 1, 1]);
end
[counts, gs] = groupcounts(raw(:));
[counts, order] = sort(counts, "descend");
gs = gs(order);
lab = zeros(n, 1);
for i = 1:numel(gs)
lab(raw == gs(i)) = i;
end
end
function idx = bandSpikes(tool, b)
idx = tool.BandIdx(tool.BandLabels == b);
end
function selectBand(tool, b)
%SELECTBAND Show a band's spikes as the Curation selection.
if isempty(tool.BandLabels) || ~isvalid(tool.Curate)
return
end
if isempty(b)
tool.Curate.selectGlobal([]); % (pick a band) clears it
return
end
idx = tool.bandSpikes(b);
tool.Curate.selectGlobal(idx);
tool.InfoLabel.Text = sprintf("Band %d: %d spikes " + ...
"(shown in the Curation widget).", b, numel(idx));
end
function moveBand(tool)
%MOVEBAND Split the chosen band off into a new cluster.
b = tool.BandDropdown.Value;
if isempty(tool.BandLabels) || isempty(b)
tool.InfoLabel.Text = "Pick a band in the dropdown first.";
return
end
idx = tool.bandSpikes(b);
newId = max(tool.App.Spikes.clusterIds) + 1;
tool.App.applySplit(idx(:), newId);
tool.adoptIntoCuration(newId);
tool.clearBands(sprintf("Band %d -> new cluster %d (%d spikes).", ...
b, newId, numel(idx)));
end
function moveOutOfBands(tool)
%MOVEOUTOFBANDS Every band except the main one -> its own cluster.
if isempty(tool.BandLabels) || max(tool.BandLabels) < 2
tool.InfoLabel.Text = "No out-of-bands (run Split bands first).";
return
end
newIds = [];
moved = strings(0, 1);
for b = 2:max(tool.BandLabels)
idx = tool.bandSpikes(b);
if isempty(idx)
continue
end
newId = max(tool.App.Spikes.clusterIds) + 1;
tool.App.applySplit(idx(:), newId);
newIds(end + 1) = newId; %#ok<AGROW>
moved(end + 1) = sprintf("band %d -> cluster %d", b, newId); %#ok<AGROW>
end
tool.adoptIntoCuration(newIds);
tool.clearBands("Moved " + join(moved, ", ") + ".");
end
function adoptIntoCuration(tool, newIds)
%ADOPTINTOCURATION Keep the split-off clusters visible in the
% Curation widget (and therefore in this amplitude plot).
if isempty(newIds)
return
end
if ~isempty(tool.Curate) && isvalid(tool.Curate)
tool.Curate.adoptCluster(newIds);
tool.ClusterIds = tool.Curate.shownClusters();
else
tool.ClusterIds = unique([tool.ClusterIds, newIds]);
end
end
function clearBands(tool, msg)
%CLEARBANDS Band labels are stale after any cluster edit.
tool.BandIdx = [];
tool.BandLabels = [];
tool.BandDropdown.Items = {'(no bands yet)'};
tool.BandDropdown.ItemsData = [];
tool.BandMoveBtn.Enable = "off";
tool.BandMoveAllBtn.Enable = "off";
tool.redrawAmp();
tool.InfoLabel.Text = string(msg);
end
function lassoSelect(tool)
%LASSOSELECT Freehand-select amplitude points -> Curation selection.
roi = drawInteractive(tool.AmpAxes, "freehand");
if isempty(roi) || ~isvalid(roi)
return
end
if size(roi.Position, 1) < 3
delete(roi);
return
end
poly = roi.Position;
delete(roi);
inside = inpolygon(tool.PlotX, tool.PlotY, poly(:, 1), poly(:, 2));
if isvalid(tool.Curate)
tool.Curate.selectGlobal(tool.PlotIdx(inside));
end
end
function drawBoundaries(tool)
tool.Suppress = true;
delete(tool.StartLine(isvalid(tool.StartLine)));
delete(tool.StopLine(isvalid(tool.StopLine)));
w = tool.App.timeWindowSec();
yl = tool.AmpYLim;
tool.StartLine = images.roi.Line(tool.AmpAxes, ...
Position=[w(1) yl(1); w(1) yl(2)], Color=[0 0.6 0], ...
LineWidth=3, Deletable=false);
tool.StopLine = images.roi.Line(tool.AmpAxes, ...
Position=[w(2) yl(1); w(2) yl(2)], Color=[0.85 0 0], ...
LineWidth=3, Deletable=false);
addlistener(tool.StartLine, "MovingROI", ...
@(s, ~) tool.constrainVertical(s));
addlistener(tool.StopLine, "MovingROI", ...
@(s, ~) tool.constrainVertical(s));
addlistener(tool.StartLine, "ROIMoved", @(~, ~) tool.onMoved("start"));
addlistener(tool.StopLine, "ROIMoved", @(~, ~) tool.onMoved("stop"));
tool.Suppress = false;
end
function constrainVertical(tool, roi)
if tool.Suppress
return
end
x = min(max(mean(roi.Position(:, 1)), 0), tool.duration());
roi.Position = [x tool.AmpYLim(1); x tool.AmpYLim(2)];
end
function onMoved(tool, which)
if tool.Suppress
return
end
tool.FocusSec = mean(tool.movedLine(which).Position(:, 1));
tool.FocusColor = tool.barColor(which);
tool.applyWindow();
tool.plotTrace();
end
function c = barColor(~, which)
if which == "start"
c = [0 0.6 0];
else
c = [0.85 0 0];
end
end
function onTraceSlider(tool, event)
%ONTRACESLIDER Scrub the trace excerpt through the recording.
tool.FocusSec = event.Value * tool.duration();
tool.FocusColor = [0.35 0.35 0.35]; % scrubbed, not at a bar
tool.plotTrace();
end
function roi = movedLine(tool, which)
if which == "start"
roi = tool.StartLine;
else
roi = tool.StopLine;
end
end
function applyWindow(tool)
a = mean(tool.StartLine.Position(:, 1));
b = mean(tool.StopLine.Position(:, 1));
tool.App.setTimeWindow(sort([a b])); % refreshes the Curation tool
tool.updateInfo();
end
function plotTrace(tool)
ax = tool.TraceAxes;
cla(ax);
% NO drawnow here: forcing a graphics flush mid teardown/rebuild
% of the companion windows stalled the whole app (see the
% 14:00:18 hang in SpikeVizEvents.log, 2026-08-28).
SpikeVisualizationApp.logEvent("plotTrace: reading excerpt");
[tSec, uv] = tool.App.traceExcerpt(tool.FocusSec, 0.5);
SpikeVisualizationApp.logEvent("plotTrace: excerpt read");
if isempty(tSec)
title(ax, "Raw trace unavailable");
return
end
plot(ax, tSec, uv, Color=[0 0 0 0.8], LineWidth=0.1);
hold(ax, "on");
% Marker colour matches the bar that put the focus here (green =
% start, red = stop, grey = timeline scrub).
xline(ax, tool.FocusSec, "-", Color=tool.FocusColor, LineWidth=1.5);
s = tool.App.Spikes;
yb = double(min(uv));
for cid = tool.ClusterIds
st = s.spikeTimes(s.clusters == cid) / s.samplingRate;
st = st(st >= tSec(1) & st <= tSec(end));
plot(ax, st, repmat(yb, size(st)), "^", ...
Color=tool.App.clusterColor(cid), ...
MarkerFaceColor=tool.App.clusterColor(cid), ...
MarkerSize=4, LineStyle="none");
end
hold(ax, "off");
title(ax, sprintf("Raw trace at boundary (%.2f s)", tool.FocusSec));
xlabel(ax, "Time (s)");
ylabel(ax, "Voltage (\muV)");
axis(ax, "tight");
if ~isempty(tool.TraceSlider) && isvalid(tool.TraceSlider)
tool.TraceSlider.Value = ...
min(1, max(0, tool.FocusSec / tool.duration()));
end
end
function snapStart(tool)
%SNAPSTART Start bar to the left edge of the current view -
% 0 normally, the epoch's start when zoomed in.
xl = xlim(tool.AmpAxes);
x = min(max(xl(1), 0), tool.duration());
tool.Suppress = true;
tool.StartLine.Position = [x tool.AmpYLim(1); x tool.AmpYLim(2)];
tool.Suppress = false;
tool.FocusSec = x;
tool.FocusColor = tool.barColor("start");
tool.applyWindow();
tool.plotTrace();
end
function snapStop(tool)
%SNAPSTOP Stop bar to the right edge of the current view -
% the recording's end normally, the epoch's end when zoomed in.
xl = xlim(tool.AmpAxes);
x = min(max(xl(2), 0), tool.duration());
tool.Suppress = true;
tool.StopLine.Position = [x tool.AmpYLim(1); x tool.AmpYLim(2)];
tool.Suppress = false;
tool.FocusSec = x;
tool.FocusColor = tool.barColor("stop");
tool.applyWindow();
tool.plotTrace();
end
function revert(tool)
tool.App.setTimeWindow([]); % whole recording
tool.drawBoundaries();
tool.updateInfo();
end
function updateInfo(tool)
w = tool.App.timeWindowSec();
s = tool.App.Spikes;
tSec = s.spikeTimes / s.samplingRate;
inWin = ismember(s.clusters, tool.ClusterIds) & ...
tSec >= w(1) & tSec <= w(2);
tool.InfoLabel.Text = sprintf("Window %.1f - %.1f s | %d spikes", ...
w(1), w(2), sum(inWin));
end
end
end
% =========================================================================
function roi = drawInteractive(ax, kind)
%DRAWINTERACTIVE Draw an ROI with the axes interactions parked first.
% A toolbar-toggled pan/zoom otherwise swallows the mouse: the draw never
% starts, the callback blocks forever, and the whole app freezes. Returns
% [] if the draw errors or is aborted (e.g. the window closes mid-draw).
try
fig = ancestor(ax, "figure");
zoom(fig, "off");
pan(fig, "off");
catch
% modes already off, or the figure is closing
end
disableDefaultInteractivity(ax);
cleanup = onCleanup(@() restoreInteractivity(ax));
SpikeVisualizationApp.logEvent("drawInteractive: arming " + kind);
try
if kind == "line"
roi = drawline(ax, Color=[0.2 0.2 0.2]);
else
roi = drawfreehand(ax, Color=[0.2 0.2 0.2]);
end
SpikeVisualizationApp.logEvent("drawInteractive: draw finished");
catch err
roi = [];
SpikeVisualizationApp.logEvent("drawInteractive FAILED: " + err.message);
end
end
function restoreInteractivity(ax)
if isvalid(ax)
enableDefaultInteractivity(ax);
end
end