-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathniceColorbar.m
More file actions
3953 lines (3810 loc) · 187 KB
/
Copy pathniceColorbar.m
File metadata and controls
3953 lines (3810 loc) · 187 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
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
classdef niceColorbar < handle
%NICECOLORBAR Styled, auto-refreshing colorbar with title and logo text.
% NC = NICECOLORBAR(STYLE, COLORMAPNAME, NUMCOLORMAPCOLORS) creates a
% niceColorbar object. Call NC.colorbar() to build the colorbar on the
% current axes; thereafter, changing any property (e.g. NC.Title) or
% figure size auto-refreshes the colorbar in place.
%
% See also NICECOLORBAR/COLORBAR, NICECOLORBAR/SETLIMITS,
% NICECOLORBAR/SETCAPPEDLIMITS, NICECOLORBAR/RESETLIMITS.
% Author: Alejandro Ortiz-Bernardin, aortizb@uchile.cl, camlab.cl/alejandro
properties (SetObservable, AbortSet) % default properties
% SetObservable + AbortSet is what enables auto-update: every one of
% these fires a PostSet event when it actually changes value (AbortSet
% suppresses the event if you reassign the same value), and that event
% is what drives refresh() below once the colorbar has been built.
ThemeMode = "auto" % 'auto' follows MATLAB's current light/dark mode; 'light' or 'dark'
% forces that look regardless of MATLAB's actual theme; 'cobalt' is a
% second, explicit-only dark mode (COBALT background, WHITEGRAY
% text/lines) - never selected by 'auto', which only ever resolves to
% 'light' or 'dark' based on MATLAB's own theme
TickLabelsFontName = "Times New Roman" %'Segoe UI Semibold';
TickLabelsFontSize = 10
TickLabelsFontWeight = "normal"
TickLabelsFormat = '%+.2e' % '%+.2e', '%.2e', '%.2f', '%.4e', '%.4f', etc
TickLabelsAutoScale = false % when true, tick VALUES/Limits/clim stay in real data
% units, but tick LABELS are divided by 10^n before being
% formatted, and a "x10^n" line is auto-prepended above
% Title (rendered with TitleInterpreter, matching Title's
% own styling). n = floor(log10(max(abs(current Limits))));
% n=0 (values already order-1) suppresses scaling/annotation
% entirely. E.g. Limits [-3e-3 4e-3] -> n=-3, ticks label as
% -3,-2,0,1,3,4 with "x10^-3" shown above the bar.
% While TickLabelsAutoScale is true, tick labels always use
% '%+.<TickLabelsAutoScaleDecimals>f' instead of
% TickLabelsFormat - a scaled value like -3 read alongside
% an exponential TickLabelsFormat (e.g. '%+.2e') would be
% confusing, so autoscale mode owns its own fixed-point format.
TickLabelsAutoScaleDecimals = 2 % number of decimal digits used by the '%+.<n>f' format
% that overrides TickLabelsFormat while TickLabelsAutoScale
% is true; ignored otherwise
TickLineWidth = 1.0
TickLineColor = "k" % used only when colorbar lines are not hidden
Title = {} % {['(step = ',int2str(10),')'],'$u_{1,h}$'};
TitleInterpreter = "latex"
TitleFontName = "Times New Roman"
TitleFontSize = 18
TitleFontWeight = "bold"
TitleColor = [] % [] auto-follows the theme for the whole Title; a single color/RGB triplet
% overrides the whole Title; a cell array matching Title's lines (each entry
% [] or a color) colors each line independently
Logo = {} %['\color[rgb]{0.360784,0.4,0.435294}THIS','\color[rgb]{0.8000,0.2510,0.2235}LOGO'];
LogoFontName = "Times New Roman" %'Segoe UI Semibold';
LogoFontSize = 16
LogoFontWeight = "normal"
Style % 'modern', 'modern.thin', 'modern.thick', 'ultra', 'ultra.thin', 'ultra.thick'
ColormapName % matlab's colormap library: 'jet', 'turbo', 'parula', 'nebula', 'abyss',
% 'sky', 'winter', 'cool', etc.
NumColormapColors % positive number
CappedColorBelow = [0.6 0.6 0.6] % color used at/below the capped min limit (see setCappedLimits)
CappedColorAbove = [0 0 0] % color used at/above the capped max limit (see setCappedLimits)
% ColorbarVisible/TitleVisible/LogoVisible independently hide/show the
% bar itself, the title text, and the logo text - each 'on' by default.
% None of them discard or rebuild anything: setting one back to 'on'
% restores that exact element. To hide/show everything at once, set
% all three together (see niceColorbar.session()'s hide.all/show.all).
ColorbarVisible = "on"
TitleVisible = "on"
LogoVisible = "on"
Side = "right" % 'right' (default), 'left', 'top', or 'bottom' - which side of
% the plot the colorbar (with its title and logo) is placed on.
% For 'top'/'bottom' the colorbar runs horizontally, with the
% Title to its left and the Logo to its right, both read
% horizontally (unlike 'left'/'right', where Title sits above
% and Logo below a vertical colorbar).
PdfRender = "image" % 'image' (default) or 'vector' - exportgraphics'
% ContentType used by saveAsPDF()/session()'s save.pdf.
% 'vector' preserves crisp, infinitely-scalable text/lines
% but is slow and prone to exportgraphics' own
% "Vectorized content might take a long time..." warning
% (suppressed automatically while 'vector' is selected -
% see saveFigureAs()); 'image' rasterizes instead, same
% as saveAsPNG()/saveAsTIFF(), trading scalability for speed/reliability.
ExportResolution = 300 % pixels-per-inch used by saveAsPNG(), saveAsTIFF(), and by
% saveAsPDF() while PdfRender is 'image' - exportgraphics ignores
% it entirely for PdfRender='vector' (vector content has no
% fixed resolution).
Box = "on" % 'on' (default) or 'off' - whether the axes' box outline is drawn.
% Previously colorbar() forced this on unconditionally; now it's a
% toggleable property, also reachable via session()'s box.on/box.off.
end
properties (Constant, Access = private)
InternalObjTag = "niceColorbar:internal" % Tag stamped on every graphics
% object niceColorbar creates itself (the native colorbar, Title/Logo/
% exponent text) so restoreFromLoad() can find and clear out orphaned
% copies left behind by a reloaded .fig, without touching anything the
% user added to the same axes/figure.
end
properties (Access = private)
fig % figure container object
ax % current axes object
clb % colorbar object
ylb % Logo text object, parented to ax (not to clb - see applyProperties
% for why: unlike a colorbar's Title, its native Label/ylabel is
% tied to the axis ruler and goes invisible along with the
% colorbar itself, which would make LogoVisible impossible to
% keep independent of ColorbarVisible)
ExpLabelObj % TickLabelsAutoScale's auto "x10^n" annotation, parented to ax
% (or a figure-level annotation textbox for a 3-D axes) exactly
% like ylb above - a standalone object, deliberately kept
% separate from TitleLineObjs so it never consumes/renumbers
% the user's own Title lines or TitleColor entries
ZExpLabelObj % standalone stand-in for a 3-D axes' native ZAxis.SecondaryLabel
% ("x10^n" text) while TickLabelsAutoScale is on - the native
% object is hidden (never repositioned directly: writing its
% Position even once permanently disables MATLAB's own
% adaptive camera-tracking placement for it, verified
% empirically - toggling ExponentMode/Visible/Exponent
% afterward never restores it). This mirrors it instead: a
% plain text() in 'data' units, parented to ax, positioned
% from the native label's own still-live Position each
% applyProperties call (offset 20 px up) - being a normal
% 3-D data-space object, it re-projects with the camera
% automatically between calls exactly like the native ticks,
% no per-frame polling needed
Built = false % true once colorbar() has run at least once
PropListener % PostSet listener driving auto-refresh on mutation
ViewListener % PostSet listener on obj.ax's View, driving a cheap
% re-layout after interactive 3-D rotation (see colorbar())
SettleTimer % reused singleShot timer (stop+restart, not recreated -
% see scheduleSettleRefresh) that polls until rotation
% OR a plain resize truly settles, then runs one
% accurate, live-measured layout pass
SettlePollMarker % obj.ax's last-observed pixel Position while polling for
% settlement (see onSettleRefresh) - [] between bursts
SettlePollCount = 0 % number of consecutive settle-poll retries so far this
% burst, capped in onSettleRefresh
LastKnownFigPos % obj.fig's pixel Position as of the last
% pollForMissedResize() check - see that method
LastKnownAxPos % obj.ax's pixel Position as of the last
% pollForMissedResize() check - see that method
SuspendRefresh = false % true while batching several property writes (e.g.
% hide.all/show.all) into a single refresh(), so
% the layout doesn't visibly flash through the
% in-between states of a multi-property change
ResizeLayout % layout struct cached for the SizeChangedFcn hot path
TickLabelExtent = [0 0] % [width height] of the widest current tick label,
% cached here (computed once per applyProperties call,
% NOT per resize tick) for positioning the
% TickLabelsAutoScale exponent annotation - see
% measureTickLabelExtent()/updateColorbarLayout
TitleLineObjs = {} % one text object per Title line, used only in per-line TitleColor mode (see applyProperties)
ThemeBgColor = [1 1 1] % background color, set from MATLAB's light/dark mode at build time
ThemeFontColor = [0 0 0] % font/axes-line color, set from MATLAB's light/dark mode at build time
OriginalLimits = [] % clim captured at build time; used by resetLimits()
OriginalAxesPosition = [] % normalized axes Position captured at build time;
% the layout footprint updateColorbarLayout fits
% into, so a subplot axes keeps its own tile
% instead of expanding to fill the whole figure
Capped = false % true after setCappedLimits(); cleared by setLimits()/resetLimits()
CappedLimits = [] % [minVal maxVal] passed to the most recent setCappedLimits() call
Top3DGapFrac = 0.35 % Side='top' on a 3-D axes only: extra headroom above the heuristic
% content bbox, as a fraction of its measured height. Re-measured live
% (via rasterization - see updateColorbarLayout) whenever allowed; the
% 0.35 default only matters before the first such measurement runs.
end
methods
% constructor
function obj = niceColorbar(styleVal,ColormapNameVal,NumColormapColorsVal)
arguments % argument check that serves as a default constructor
styleVal {mustBeMember(styleVal,{'modern','modern.thin','modern.thick',...
'ultra','ultra.thin','ultra.thick'})} = 'ultra'
ColormapNameVal {mustBeText} = 'ultra'
NumColormapColorsVal {mustBeInteger, mustBePositive} = 8
end
obj.Style = styleVal;
obj.ColormapName = ColormapNameVal;
obj.NumColormapColors = NumColormapColorsVal;
% Note: these three assignments do fire PostSet, but PropListener
% doesn't exist yet at this point (it's only created inside
% colorbar(), after the first build), so refresh() never runs here.
end
% ---- set methods ----------------------------------------------------
% All setters funnel through the private static helper checkAndAssign,
% which runs the validator(s), raises a niceColorbar-specific error with
% a friendly message on failure, and otherwise performs the assignment.
% This replaces ~170 lines of copy-pasted try/catch blocks with the
% same behavior in a fraction of the code.
function set.ThemeMode(obj,val)
checkAndAssign('ThemeMode',val,{@(v)mustBeMember(v,{'auto','light','dark','cobalt'})}, ...
'value must be ''auto'', ''light'', ''dark'', or ''cobalt'' ');
obj.ThemeMode = val;
end
function set.TickLabelsFontName(obj,val)
checkAndAssign('TickLabelsFontName',val,{@mustBeText}, ...
'value must be a string array or character vector');
obj.TickLabelsFontName = val;
end
function set.TickLabelsFontSize(obj,val)
checkAndAssign('TickLabelsFontSize',val,{@mustBeInteger,@mustBeNonnegative}, ...
'value must be a nonnegative integer');
obj.TickLabelsFontSize = val;
end
function set.TickLabelsFontWeight(obj,val)
checkAndAssign('TickLabelsFontWeight',val,{@(v)mustBeMember(v,{'normal','bold'})}, ...
'value must be ''normal'' or ''bold'' ');
obj.TickLabelsFontWeight = val;
end
function set.TickLabelsFormat(obj,val)
checkAndAssign('TickLabelsFormat',val,{@mustBeText}, ...
'value must be a character vector');
obj.TickLabelsFormat = val;
end
function set.TickLabelsAutoScale(obj,val)
checkAndAssign('TickLabelsAutoScale',val,{@(v)mustBeMember(v,[true false])}, ...
'value must be a logical scalar');
obj.TickLabelsAutoScale = val;
end
function set.TickLabelsAutoScaleDecimals(obj,val)
checkAndAssign('TickLabelsAutoScaleDecimals',val,{@mustBeInteger,@mustBeNonnegative}, ...
'value must be a nonnegative integer');
obj.TickLabelsAutoScaleDecimals = val;
end
function set.TickLineWidth(obj,val)
checkAndAssign('TickLineWidth',val,{@mustBeNumeric,@mustBeNonnegative}, ...
'value must be a nonnegative number');
obj.TickLineWidth = val;
end
function set.TickLineColor(obj,val)
checkAndAssign('TickLineColor',val,{@mustBeColorSpec}, ...
'value must be a color name, character vector, or 1x3 RGB triplet with components in [0,1]');
obj.TickLineColor = val;
end
function set.Title(obj,val)
checkAndAssign('Title',val,{@mustBeText}, ...
'value must be a string array, character vector, or cell array of character vectors');
obj.Title = val;
end
function set.TitleInterpreter(obj,val)
checkAndAssign('TitleInterpreter',val,{@(v)mustBeMember(v,{'tex','latex','none'})}, ...
'value must be ''tex'', ''latex'', or ''none'' ');
obj.TitleInterpreter = val;
end
function set.TitleFontName(obj,val)
checkAndAssign('TitleFontName',val,{@mustBeText}, ...
'value must be a character vector');
obj.TitleFontName = val;
end
function set.TitleFontSize(obj,val)
checkAndAssign('TitleFontSize',val,{@mustBeInteger,@mustBeNonnegative}, ...
'value must be a nonnegative integer');
obj.TitleFontSize = val;
end
function set.TitleFontWeight(obj,val)
checkAndAssign('TitleFontWeight',val,{@(v)mustBeMember(v,{'normal','bold'})}, ...
'value must be ''normal'' or ''bold'' ');
obj.TitleFontWeight = val;
end
function set.TitleColor(obj,val)
checkAndAssign('TitleColor',val,{@mustBeTitleColorSpec}, ...
['value must be empty (auto-follow the theme), a color name/character vector/1x3 RGB triplet ', ...
'(applies to the whole Title), or a cell array of those (each may be empty) to color each ', ...
'Title line independently']);
obj.TitleColor = val;
end
function set.Logo(obj,val)
checkAndAssign('Logo',val,{@mustBeText}, ...
'value must be a string array, character vector, or cell array of character vectors');
obj.Logo = val;
end
function set.LogoFontName(obj,val)
checkAndAssign('LogoFontName',val,{@mustBeText}, ...
'value must be a character vector');
obj.LogoFontName = val;
end
function set.LogoFontSize(obj,val)
checkAndAssign('LogoFontSize',val,{@mustBeInteger,@mustBeNonnegative}, ...
'value must be a nonnegative integer');
obj.LogoFontSize = val;
end
function set.LogoFontWeight(obj,val)
checkAndAssign('LogoFontWeight',val,{@(v)mustBeMember(v,{'normal','bold'})}, ...
'value must be ''normal'' or ''bold'' ');
obj.LogoFontWeight = val;
end
function set.Style(obj,val)
checkAndAssign('Style',val, ...
{@(v)mustBeMember(v,{'modern','modern.thin','modern.thick','ultra','ultra.thin','ultra.thick'})}, ...
'value must be ''modern'', ''modern.thin'', ''modern.thick'', ''ultra'', ''ultra.thin'', ''ultra.thick'' ');
obj.Style = val;
end
function set.ColormapName(obj,val)
validNames = getValidColormapNames();
checkAndAssign('ColormapName',val,{@mustBeText,@(v)mustBeMember(v,validNames)}, ...
sprintf('value must be one of:\n%s',wrapCommaList(validNames,100)));
obj.ColormapName = val;
end
function set.NumColormapColors(obj,val)
checkAndAssign('NumColormapColors',val,{@mustBeInteger,@mustBeNonnegative}, ...
'value must be a nonnegative integer');
obj.NumColormapColors = val;
end
function set.CappedColorBelow(obj,val)
checkAndAssign('CappedColorBelow',val,{@mustBeColorSpec}, ...
'value must be a color name, character vector, or 1x3 RGB triplet with components in [0,1]');
obj.CappedColorBelow = val;
end
function set.CappedColorAbove(obj,val)
checkAndAssign('CappedColorAbove',val,{@mustBeColorSpec}, ...
'value must be a color name, character vector, or 1x3 RGB triplet with components in [0,1]');
obj.CappedColorAbove = val;
end
function set.ColorbarVisible(obj,val)
checkAndAssign('ColorbarVisible',val,{@(v)mustBeMember(v,{'on','off'})}, ...
'value must be ''on'' or ''off'' ');
obj.ColorbarVisible = val;
end
function set.TitleVisible(obj,val)
checkAndAssign('TitleVisible',val,{@(v)mustBeMember(v,{'on','off'})}, ...
'value must be ''on'' or ''off'' ');
obj.TitleVisible = val;
end
function set.LogoVisible(obj,val)
checkAndAssign('LogoVisible',val,{@(v)mustBeMember(v,{'on','off'})}, ...
'value must be ''on'' or ''off'' ');
obj.LogoVisible = val;
end
function set.Side(obj,val)
checkAndAssign('Side',val,{@(v)mustBeMember(v,{'right','left','top','bottom'})}, ...
'value must be ''right'', ''left'', ''top'', or ''bottom'' ');
obj.Side = val;
end
function set.PdfRender(obj,val)
checkAndAssign('PdfRender',val,{@(v)mustBeMember(v,{'vector','image'})}, ...
'value must be ''vector'' or ''image'' ');
obj.PdfRender = val;
end
function set.ExportResolution(obj,val)
checkAndAssign('ExportResolution',val,{@mustBeInteger,@mustBePositive}, ...
'value must be a positive integer');
obj.ExportResolution = val;
end
function set.Box(obj,val)
checkAndAssign('Box',val,{@(v)mustBeMember(v,{'on','off'})}, ...
'value must be ''on'' or ''off'' ');
obj.Box = val;
end
end
methods (Access = public)
function colorbar(obj)
%COLORBAR Build (or rebuild) the styled colorbar on the current axes.
% Deliberately named to match MATLAB's built-in colorbar() for a
% drop-in feel. This is safe: the method is only reachable via dot
% notation (obj.colorbar()), so the bare colorbar(...) call below
% resolves to the built-in, not a recursive call into this method.
%% figure and axes settings
obj.ax = gca;
obj.fig = ancestor(obj.ax,'figure');
obj.resolveTheme();
box(obj.ax,obj.Box);
axis equal;
% capture the axes' normalized Position as it stands right now (i.e.
% before this method starts resizing it), so updateColorbarLayout can
% fit its box into that same footprint instead of assuming the axes
% owns the whole figure - this is what lets several niceColorbar
% instances coexist on one figure (e.g. one per subplot)
obj.ax.Units = "normalized";
obj.OriginalAxesPosition = obj.ax.Position;
% capture the data's color limits as they stand right now (i.e.
% before any setLimits() call), so resetLimits() has an "original"
% to revert to later
obj.OriginalLimits = clim(obj.ax);
% a fresh build should never carry over capped-mode state from a
% previous build (e.g. colorbar() called again to rebind to a new
% axes)
obj.Capped = false;
obj.CappedLimits = [];
% if colorbar() is called again (e.g. to rebind to a new axes),
% clear out anything left over from a previous build so we don't
% leak a duplicate colorbar/logo graphics object
if ~isempty(obj.clb) && isvalid(obj.clb)
delete(obj.clb);
end
obj.clb = [];
% the Logo (obj.ylb) and per-line Title text objects (see
% applyProperties) are parented to the axes, not the colorbar, so
% deleting obj.clb above doesn't clean them up - drop them explicitly
% before (re)building
if ~isempty(obj.ylb) && isvalid(obj.ylb)
delete(obj.ylb);
end
obj.ylb = [];
if ~isempty(obj.ExpLabelObj) && isvalid(obj.ExpLabelObj)
delete(obj.ExpLabelObj);
end
obj.ExpLabelObj = [];
for i = 1:numel(obj.TitleLineObjs)
if isvalid(obj.TitleLineObjs{i})
delete(obj.TitleLineObjs{i});
end
end
obj.TitleLineObjs = {};
%% create a colorbar: this is the in-built MATLAB's colorbar that is wrapped by
% niceColorbar's colorbar public method
obj.clb = colorbar("FontName",obj.TickLabelsFontName,"FontSize",obj.TickLabelsFontSize,...
"FontWeight",obj.TickLabelsFontWeight,"LineWidth",obj.TickLineWidth,...
"Color",obj.TickLineColor,"Tag",obj.InternalObjTag);
% mark built BEFORE applying properties, since applyProperties() is
% the same routine refresh() calls later - guarding on obj.Built is
% what lets refresh() safely no-op on any PostSet event that manages
% to fire before a colorbar actually exists
obj.Built = true;
%% title / logo / colormap / ticks / layout - see applyProperties()
obj.applyProperties();
% run the layout alignment helper once immediately to set the
% starting view (applyProperties already iterates this a few times
% to let it settle), then keep it in sync on every figure resize.
% onResize() re-reads obj.Style-derived layout fresh from
% obj.ResizeLayout each time, so it never uses a stale closure.
%
% Registering through resizeHub (rather than assigning
% obj.fig.SizeChangedFcn directly) is what lets more than one
% niceColorbar share the same figure - e.g. one per subplot. A plain
% `set(obj.fig,'SizeChangedFcn',@(~,~) obj.onResize())` here would
% have each new instance clobber the previous one's callback, so only
% the most-recently-built colorbar in a figure would ever re-align on
% resize.
niceColorbar.resizeHub('register',obj.fig,obj);
set(obj.fig,'SizeChangedFcn',@(src,~) niceColorbar.resizeHub('dispatch',src));
%% SizeChangedFcn does NOT reliably fire for every way a figure can
% resize either: confirmed, via direct testing in a live session,
% that toggling the desktop's "maximize figure" toolstrip icon on a
% DOCKED figure changes obj.fig.Position (verified correct
% immediately afterward) WITHOUT ever invoking SizeChangedFcn - so
% onResize() (and everything it schedules) silently never runs,
% leaving the colorbar/Title/Logo exactly where they were before the
% toggle. A plain drag-resize of the same docked panel, by contrast,
% fires SizeChangedFcn correctly every time - this gap is specific to
% that one toolstrip interaction. Figure's own Position property also
% isn't SetObservable (addlistener errors), so there's no PostSet
% event to hook instead - a low-frequency poll is the only way left
% to catch this. Checking obj.fig.Position (a cheap read) once a
% second and only invoking onResize() when it actually changed keeps
% the ongoing cost negligible; reuses the exact same
% onResize->scheduleSettleRefresh pipeline a real SizeChangedFcn event
% would have driven, so the fix for THAT lag (see onSettleRefresh)
% applies here too.
%
% A background 1Hz poll is only worth running while obj.fig is
% visible - the toolstrip interaction it exists to catch can only
% ever happen on a figure the user can actually see and click on -
% which this checks fresh on every shared-timer tick (see
% sharedPollTimer) rather than starting/stopping a per-instance timer
% off a 'Visible' PostSet listener the way an earlier version did.
%
% ONE shared timer for every live niceColorbar instance (rather than
% one independent timer per instance) - an earlier per-instance
% design was suspected of contributing to a rare but serious bug:
% docking several figures into one group at once could leave a
% docked tab rendering another tab's stale/duplicated colorbar
% content, and that bug tracked with the NUMBER of independently
% ticking timers all reacting to the same external dock event on
% their own schedule (each doing its own drawnow/Position-write) -
% a controlled comparison against plain MATLAB colorbar (same
% figure/subplot count, zero of niceColorbar's background timers)
% came back clean, isolating niceColorbar's own machinery as the
% likely factor. One coordinated pass per tick, over every
% registered instance, replaces that race with a single-threaded
% sweep - mirroring how resizeHub already centralizes SizeChangedFcn
% dispatch for the same reason.
obj.LastKnownFigPos = [];
obj.LastKnownAxPos = [];
niceColorbar.sharedPollTimer('ensure');
%% keep the layout in sync with interactive 3-D rotation too. Unlike
% a plain window resize (always fires SizeChangedFcn), rotating a
% 3-D view via the axes toolbar's Rotate button doesn't touch figure
% size at all - and rotate3d's ActionPostCallback, tried here first,
% was found not to reliably fire for that modern interaction either -
% so without this, the colorbar/Title/Logo (positioned for whatever
% view was current at the last property change or colorbar() call)
% would silently go stale the instant the user rotates further. The
% axes' own View property, by contrast, changes for every rotation
% mechanism uniformly (mouse drag, axtoolbar, or a plain view() call)
% since it's what MATLAB itself updates under the hood.
%
% onViewChanged() (see its own comment) runs a cheap position-only
% update immediately on every View change for responsiveness during
% a drag, then schedules an accurate, live-measured pass via a
% reused singleShot timer once rotation settles - needed because the
% cheap pass alone was found insufficient: at near-edge-on elevation,
% if the user stops rotating right after the cheap pass reused a
% badly-fitting gap fraction from an earlier, very different
% rotation, nothing would otherwise ever correct it.
if ~isempty(obj.ViewListener) && isvalid(obj.ViewListener)
delete(obj.ViewListener);
end
obj.ViewListener = addlistener(obj.ax,'View','PostSet',@(~,~) obj.onViewChanged());
% stop/delete a still-pending settle timer if the axes closes out
% from under it (e.g. the figure is closed mid-rotation), rather than
% leaving a running timer (and its captured reference to obj)
% dangling.
addlistener(obj.ax,'ObjectBeingDestroyed',@(~,~) obj.cancelSettleRefresh());
%% wire up auto-refresh: from this point on, mutating any public
% property (Title, Style, ColormapName, fonts, etc.) automatically
% re-applies it to the live colorbar - no need to call colorbar()
% again by hand.
if isempty(obj.PropListener)
obj.PropListener = addlistener(obj, properties(obj), 'PostSet', @(~,~) obj.refresh());
end
%% make this niceColorbar survive being saved to a .fig and reopened.
% obj itself (a handle object with listeners/timers) is never part of
% what MATLAB serializes into a .fig - only the plain graphics
% (axes/colorbar/title/logo text) and appdata are - so resizeHub loses
% this instance's registration on reload, and every property/resize
% listener above goes with it: SizeChangedFcn is still saved (it's a
% plain handle to the static resizeHub dispatcher, capturing nothing),
% but dispatching into an empty registry silently does nothing, so the
% reloaded colorbar drifts out of position on the very next resize.
% The fix: snapshot every public property into a plain (fully
% serializable) struct in appdata, and point obj.ax's CreateFcn at
% restoreFromLoad() - CreateFcn only fires when this axes is actually
% (re)created, which happens on openfig()/uiopen() but never from a
% plain property assignment on the live object here - so a fresh,
% fully live niceColorbar gets rebuilt from the snapshot exactly once
% per reload, restoring auto-refresh/resize/session() support along
% with the correct layout.
mc = metaclass(obj);
isPublicProp = strcmp({mc.PropertyList.GetAccess},'public') & ...
strcmp({mc.PropertyList.SetAccess},'public');
publicPropNames = {mc.PropertyList(isPublicProp).Name};
savedState = struct();
for i = 1:numel(publicPropNames)
savedState.(publicPropNames{i}) = obj.(publicPropNames{i});
end
setappdata(obj.ax,'niceColorbarState',savedState);
% OriginalAxesPosition (private, so not part of the public-property
% snapshot above) is the pristine, pre-shrink footprint captured at
% the very top of this method - saved separately so restoreFromLoad
% can put the axes back into that footprint before rebuilding.
% Without this, obj.ax.Position on reload is already the PREVIOUS
% build's shrunk-to-fit-the-colorbar box, so re-shrinking it again on
% top of that would compound and misplace the layout.
setappdata(obj.ax,'niceColorbarOriginalAxesPosition',obj.OriginalAxesPosition);
obj.ax.CreateFcn = @(src,~) niceColorbar.restoreFromLoad(src);
end
function setLimits(obj,newLimits)
% Sets the colorbar/axes color limits to newLimits = [minVal maxVal].
% Unlike the SetObservable properties above, this bypasses the
% property-listener refresh mechanism entirely - color limits live on
% the axes (clim), not on this object - so it applies the change and
% re-derives ticks/labels directly via applyProperties().
arguments
obj
newLimits (1,2) double {mustBeNumeric,mustBeFinite}
end
if ~obj.Built || isempty(obj.ax) || ~isvalid(obj.ax)
error('niceColorbar:setLimits:notBuilt','colorbar() must be called before setLimits()');
end
if newLimits(2) <= newLimits(1)
error('niceColorbar:setLimits:invalidValue','max value must be greater than min value');
end
obj.Capped = false; % a plain setLimits() always leaves/overrides capped mode
clim(obj.ax,newLimits);
obj.applyProperties();
end
function setCappedLimits(obj,newLimits)
% Like setLimits(), but instead of stretching the existing gradient
% colormap over the new range, caps it: values at/below newLimits(1)
% render as obj.CappedColorBelow and values at/above newLimits(2)
% render as obj.CappedColorAbove, with the normal NumColormapColors
% gradient in between. See setCappedColorbarLevels() for the actual
% colormap/tick construction - this method only records the request
% and re-triggers it.
arguments
obj
newLimits (1,2) double {mustBeNumeric,mustBeFinite}
end
if ~obj.Built || isempty(obj.ax) || ~isvalid(obj.ax)
error('niceColorbar:setCappedLimits:notBuilt','colorbar() must be called before setCappedLimits()');
end
if newLimits(2) <= newLimits(1)
error('niceColorbar:setCappedLimits:invalidValue','max value must be greater than min value');
end
obj.Capped = true;
obj.CappedLimits = newLimits;
obj.applyProperties();
end
function resetLimits(obj)
% Reverts the color limits to whatever they were when colorbar() was
% first called (captured in obj.OriginalLimits), and clears capped
% mode if setCappedLimits() had been used.
if ~obj.Built || isempty(obj.ax) || ~isvalid(obj.ax)
error('niceColorbar:resetLimits:notBuilt','colorbar() must be called before resetLimits()');
end
obj.Capped = false;
obj.CappedLimits = [];
if isempty(obj.OriginalLimits)
return
end
clim(obj.ax,obj.OriginalLimits);
obj.applyProperties();
end
function saveAsPNG(obj,folder,fileName)
% Exports the figure this colorbar is attached to as a PNG image.
% folder and fileName are both optional: called with no arguments
% (e.g. from a script like examples.m), a name is auto-generated from
% the figure number and a timestamp and written to a "SavedFigures"
% folder inside the niceColorbar toolbox folder; session() instead
% prompts the user for both via a single uiputfile dialog and passes
% them in explicitly.
arguments
obj
folder {mustBeTextScalar} = ''
fileName {mustBeTextScalar} = ''
end
obj.saveFigureAs('png',folder,fileName);
end
function saveAsPDF(obj,folder,fileName)
% Exports the figure this colorbar is attached to as a vector PDF.
% See saveAsPNG() for the folder/fileName arguments' behavior.
arguments
obj
folder {mustBeTextScalar} = ''
fileName {mustBeTextScalar} = ''
end
obj.saveFigureAs('pdf',folder,fileName);
end
function saveAsTIFF(obj,folder,fileName)
% Exports the figure this colorbar is attached to as a TIFF image.
% See saveAsPNG() for the folder/fileName arguments' behavior.
arguments
obj
folder {mustBeTextScalar} = ''
fileName {mustBeTextScalar} = ''
end
obj.saveFigureAs('tiff',folder,fileName);
end
function saveAsFIG(obj,folder,fileName)
% Saves the figure this colorbar is attached to as an editable
% MATLAB .fig file. See saveAsPNG() for the folder/fileName
% arguments' behavior.
arguments
obj
folder {mustBeTextScalar} = ''
fileName {mustBeTextScalar} = ''
end
obj.saveFigureAs('fig',folder,fileName);
end
end
methods (Access = public, Static)
function session()
% Interactive command-line loop for adjusting colorbar limits from
% the MATLAB console without writing throwaway script code. Commands
% operate on whichever figure currently has focus (per
% groot().CurrentFigure) at the moment each command is entered - since
% MATLAB still services figure-click/focus events while input() waits
% on the keyboard, clicking a different figure mid-session (e.g.
% switching from Figure2 to Figure3) redirects subsequent commands to
% that figure's own niceColorbar instance, no re-invocation needed.
disp('niceColorbar interactive session.');
disp('Click a figure to target it, then enter a command below.');
niceColorbar.printSessionCommands();
keepRunning = true;
while keepRunning
txt = strtrim(input('niceColorbar> ','s'));
switch txt
case {'help','?'}
niceColorbar.printSessionCommands();
case 'exit'
keepRunning = false;
disp('Plotting session terminated by the user.');
otherwise
if ~niceColorbar.handleSessionCommand(txt)
disp('Command not available. Type ''help'' to see the list of commands.');
end
end
end
end
function recognized = handleSessionCommand(txt)
% Executes ONE niceColorbar session command - everything
% printSessionCommands() lists except 'help'/'?' and 'exit', which
% are loop control left to the caller. Factored out of session()'s
% own loop so other interactive sessions built on top of niceColorbar
% - e.g. nicePlots' niceSession(), which mixes in its own commands
% (mesh.show/mesh.hide, ...) around this same set - can reuse this
% exact command handling instead of re-implementing it.
%
% Returns false (printing nothing) when txt isn't a recognized
% niceColorbar command, so a caller mixing in commands of its own can
% report "not available" exactly once instead of twice.
recognized = true;
switch txt
case 'limits'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
maxVal = readNumberPrompt(' -> Enter max. value: ');
minVal = readNumberPrompt(' -> Enter min. value: ');
if isnan(maxVal) || isnan(minVal)
disp('Could not set limits: value must be a number');
return
end
try
obj.setLimits([minVal maxVal]);
catch ME
disp(['Could not set limits: ',ME.message]);
end
case 'limits.capped'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
maxVal = readNumberPrompt(' -> Enter max. value: ');
minVal = readNumberPrompt(' -> Enter min. value: ');
if isnan(maxVal) || isnan(minVal)
disp('Could not set capped limits: value must be a number');
return
end
try
obj.setCappedLimits([minVal maxVal]);
catch ME
disp(['Could not set capped limits: ',ME.message]);
end
case 'limits.reset'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.resetLimits();
case 'style'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
styleTxt = strtrim(input(' -> Enter style: ','s'));
try
obj.Style = styleTxt; % setter validates and auto-refreshes the live colorbar
catch ME
disp(['Could not set style: ',ME.message]);
end
case 'colors'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
numColorsVal = readNumberPrompt(' -> Enter number of colors: ');
if isnan(numColorsVal)
disp('Could not set number of colors: value must be a number');
return
end
try
obj.NumColormapColors = numColorsVal; % setter validates and auto-refreshes the live colorbar
catch ME
disp(['Could not set number of colors: ',ME.message]);
end
case 'colormap'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
colormapTxt = strtrim(input(' -> Enter colormap: ','s'));
try
obj.ColormapName = colormapTxt; % setter validates and auto-refreshes the live colorbar
catch ME
disp(['Could not set colormap: ',ME.message]);
end
case 'dark'
list = niceColorbar.instancesOnCurrentFigure();
if isempty(list)
disp('No niceColorbar is registered on the current figure.');
return
end
for i = 1:numel(list)
list{i}.ThemeMode = 'dark';
end
case 'light'
list = niceColorbar.instancesOnCurrentFigure();
if isempty(list)
disp('No niceColorbar is registered on the current figure.');
return
end
for i = 1:numel(list)
list{i}.ThemeMode = 'light';
end
case 'cobalt'
list = niceColorbar.instancesOnCurrentFigure();
if isempty(list)
disp('No niceColorbar is registered on the current figure.');
return
end
for i = 1:numel(list)
list{i}.ThemeMode = 'cobalt';
end
case 'autoscale.on'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
decimalsVal = readNumberPrompt(' -> Enter number of decimals (blank = 2): ');
if isnan(decimalsVal)
decimalsVal = 2; % default when the user just presses Enter
end
try
obj.TickLabelsAutoScaleDecimals = decimalsVal; % setter validates and auto-refreshes the live colorbar
catch ME
disp(['Could not set number of decimals: ',ME.message]);
return
end
obj.TickLabelsAutoScale = true; % setter validates and auto-refreshes the live colorbar
case 'autoscale.off'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.TickLabelsAutoScale = false; % setter validates and auto-refreshes the live colorbar
case 'box.on'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.Box = 'on'; % setter validates and auto-refreshes the live colorbar
case 'box.off'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.Box = 'off'; % setter validates and auto-refreshes the live colorbar
case 'hide.colorbar'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.ColorbarVisible = 'off'; % setter validates and auto-refreshes the live colorbar
case 'show.colorbar'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.ColorbarVisible = 'on'; % setter validates and auto-refreshes the live colorbar
case 'hide.title'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.TitleVisible = 'off'; % setter validates and auto-refreshes the live colorbar
case 'show.title'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.TitleVisible = 'on'; % setter validates and auto-refreshes the live colorbar
case 'hide.logo'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.LogoVisible = 'off'; % setter validates and auto-refreshes the live colorbar
case 'show.logo'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.LogoVisible = 'on'; % setter validates and auto-refreshes the live colorbar
case 'hide.all'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.SuspendRefresh = true;
obj.ColorbarVisible = 'off';
obj.TitleVisible = 'off';
obj.LogoVisible = 'off';
obj.SuspendRefresh = false;
obj.refresh();
case 'show.all'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.SuspendRefresh = true;
obj.ColorbarVisible = 'on';
obj.TitleVisible = 'on';
obj.LogoVisible = 'on';
obj.SuspendRefresh = false;
obj.refresh();
case 'side.left'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.Side = 'left'; % setter validates and auto-refreshes the live colorbar
case 'side.right'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.Side = 'right'; % setter validates and auto-refreshes the live colorbar
case 'side.top'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.Side = 'top'; % setter validates and auto-refreshes the live colorbar
case 'side.bottom'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
obj.Side = 'bottom'; % setter validates and auto-refreshes the live colorbar
case 'save.png'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
resolutionVal = readNumberPrompt(' -> Enter export resolution in DPI (blank = 300): ');
if isnan(resolutionVal)
resolutionVal = 300; % default when the user just presses Enter
end
try
obj.ExportResolution = resolutionVal; % setter validates and auto-refreshes the live colorbar
catch ME
disp(['Could not set export resolution: ',ME.message]);
return
end
[fileName,folder] = uiputfile('*.png','Save PNG as','figure.png');
if isequal(fileName,0)
disp('Save cancelled.');
return
end
try
obj.saveAsPNG(folder,fileName);
catch ME
disp(['Could not save PNG: ',ME.message]);
end
case 'save.tiff'
obj = niceColorbar.currentInstance();
if isempty(obj)
disp('No niceColorbar is registered on the current figure.');
return
end
resolutionVal = readNumberPrompt(' -> Enter export resolution in DPI (blank = 300): ');
if isnan(resolutionVal)
resolutionVal = 300; % default when the user just presses Enter
end