diff --git a/examples/demo_current_view_box.m b/examples/demo_current_view_box.m new file mode 100644 index 00000000..69ac5da3 --- /dev/null +++ b/examples/demo_current_view_box.m @@ -0,0 +1,73 @@ +%% Demo: Lower-slider per-graph "current view" boxes (Phase 1039) +% The existing lower preview slider (TimeRangeSelector) keeps its draggable +% SELECTION window exactly as before. Phase 1039 adds, inside the slider, ONE +% smaller "current view" box PER visible graph that is NOT synchronized with +% the selection (i.e. you zoomed/panned that plot directly). Each box is drawn +% in the SAME colour as that graph's slider preview line, so you can tell at a +% glance which plot is looking where. +% +% This demo renders a 3-widget dashboard and pre-zooms TWO plots to different +% interior windows, so two differently-coloured current-view boxes are already +% visible in the lower slider on load. +% +% Try this in the figure: +% 1. Lower slider: the wide translucent rectangle is the SELECTION. The two +% smaller coloured boxes are the CURRENT VIEWS of the zoomed plots — each +% box matches its plot's preview-line colour. +% 2. Zoom/pan the THIRD plot (scroll / toolbar) — a third box appears in that +% plot's colour at its window. One box per out-of-sync graph. +% 3. Drag the slider SELECTION to match a plot, or press "Sync all" — as each +% plot comes back in sync, its box disappears. + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Synthetic 10-minute sensor data (shared time base) +rng(11); +N = 4000; +t = linspace(0, 600, N); % 10 minutes, seconds +yTemp = 70 + 5*sin(2*pi*t/120) + randn(1, N)*0.6; % degrees C +yPress = 50 + 18*sin(2*pi*t/90) + randn(1, N)*1.2; % bar +yFlow = 12 + 3*cos(2*pi*t/75) + randn(1, N)*0.4; % L/s + +sTemp = SensorTag('T-401', 'Name', 'Temperature', 'Units', [char(176) 'C'], 'X', t, 'Y', yTemp); +sPress = SensorTag('P-201', 'Name', 'Pressure', 'Units', 'bar', 'X', t, 'Y', yPress); +sFlow = SensorTag('F-101', 'Name', 'Flow', 'Units', 'L/s', 'X', t, 'Y', yFlow); + +%% 2. Three-widget dashboard (lower TimeRangeSelector builds automatically) +d = DashboardEngine('Current-View Box Demo — Phase 1039'); +d.Theme = 'dark'; +d.addWidget('fastsense', 'Position', [1 1 24 6], 'Tag', sTemp); +d.addWidget('fastsense', 'Position', [1 7 24 6], 'Tag', sPress); +d.addWidget('fastsense', 'Position', [1 13 24 6], 'Tag', sFlow); +d.render(); + +%% 3. Pre-zoom TWO plots so two coloured current-view boxes show on load +% (Real interactive zoom does exactly this — here we drive it programmatically +% so the boxes are visible immediately. Each box matches its plot's preview +% line colour.) +wTemp = d.Widgets{1}; +wPress = d.Widgets{2}; +try + xlim(wTemp.FastSenseObj.hAxes, [120 240]); % zoom Temperature -> 120..240 s + wTemp.UseGlobalTime = false; + xlim(wPress.FastSenseObj.hAxes, [400 520]); % zoom Pressure -> 400..520 s + wPress.UseGlobalTime = false; +catch +end +% Let FastSense's zoom re-resolve settle so the axes hold the zoomed window +% before we read it (FastSense rebuilds line graphics on an XLim change). +for s = 1:4; drawnow; pause(0.05); end +% Refresh the indicator now (normally fired by each widget's XLim listener). +try d.updateCurrentViewIndicatorForTest_(); catch, end + +fprintf('\nCurrent-view box demo rendered.\n'); +fprintf(' Full extent : %.0f .. %.0f s (slider DataRange)\n', t(1), t(end)); +fprintf(' Temperature zoomed: 120 .. 240 s -> box in Temperature''s colour\n'); +fprintf(' Pressure zoomed : 400 .. 520 s -> box in Pressure''s colour\n'); +fprintf('\nInteract:\n'); +fprintf(' - Zoom/pan the Flow plot -> a third box appears in Flow''s colour\n'); +fprintf(' - Re-sync a plot (Sync all / match selection) -> that plot''s box disappears\n'); +fprintf(' - One box per out-of-sync graph; the wide rectangle is the SELECTION\n'); diff --git a/examples/demo_current_view_box_multitab.m b/examples/demo_current_view_box_multitab.m new file mode 100644 index 00000000..52fa1352 --- /dev/null +++ b/examples/demo_current_view_box_multitab.m @@ -0,0 +1,75 @@ +%% Demo: per-graph current-view boxes across MULTIPLE TABS (Phase 1039) +% The lower-slider current-view boxes scope to the ACTIVE page. Each box marks +% one out-of-sync graph's window in that graph's preview-line colour. Switching +% tabs clears the previous page's boxes and surfaces the new page's — driven by +% the same DashboardEngine.switchPage path the tab buttons use. +% +% This demo builds a 2-page dashboard: +% Page 1 "Process" — Temperature, Pressure +% Page 2 "Utilities" — Flow, Level +% and pre-zooms one plot on EACH page so a coloured box is waiting on both tabs. +% +% Try this in the figure: +% 1. Page 1 is active: a coloured box marks the zoomed Temperature window. +% 2. Click the "Utilities" tab — Page 1's box clears; a coloured box marks the +% zoomed Flow window on Page 2. +% 3. Zoom/pan the other plot on a tab — a second box appears (one per graph). +% 4. Click back to "Process" — Page 1's box returns. Re-sync a plot to clear +% its box. + +close all force; +clear functions; +projectRoot = fileparts(fileparts(mfilename('fullpath'))); +run(fullfile(projectRoot, 'install.m')); + +%% 1. Synthetic 10-minute data +rng(11); +N = 4000; +t = linspace(0, 600, N); +yTemp = 70 + 5*sin(2*pi*t/120) + randn(1, N)*0.6; +yPress = 50 + 18*sin(2*pi*t/90) + randn(1, N)*1.2; +yFlow = 12 + 3*cos(2*pi*t/75) + randn(1, N)*0.4; +yLevel = 40 + 8*sin(2*pi*t/150) + randn(1, N)*0.7; + +%% 2. Two-page dashboard +d = DashboardEngine('Current-View Box — Multi-Tab Demo (Phase 1039)'); +d.Theme = 'dark'; +d.addPage('Process'); +d.addWidget('fastsense', 'Position', [1 1 24 6], 'Title', 'Temperature', 'XData', t, 'YData', yTemp); +d.addWidget('fastsense', 'Position', [1 7 24 6], 'Title', 'Pressure', 'XData', t, 'YData', yPress); +d.addPage('Utilities'); +d.switchPage(2); +d.addWidget('fastsense', 'Position', [1 1 24 6], 'Title', 'Flow', 'XData', t, 'YData', yFlow); +d.addWidget('fastsense', 'Position', [1 7 24 6], 'Title', 'Level', 'XData', t, 'YData', yLevel); +d.switchPage(1); +d.render(); + +%% 3. Pre-zoom one plot on EACH page so a box is waiting on both tabs +% Page-2 widgets only realize when their tab is first shown, so briefly visit +% page 2 to zoom Flow, then return to page 1. (Real interactive zoom does the +% same; switching tabs re-attaches each page's current-view listener.) +wTemp = d.Pages{1}.Widgets{1}; % Temperature on page 1 +wFlow = d.Pages{2}.Widgets{1}; % Flow on page 2 +try + xlim(wTemp.FastSenseObj.hAxes, [120 240]); % zoom Temperature on page 1 + wTemp.UseGlobalTime = false; +catch +end +d.switchPage(2); % realize page 2 widgets +for s = 1:3; drawnow; pause(0.05); end +try + xlim(wFlow.FastSenseObj.hAxes, [380 500]); % zoom Flow on page 2 + wFlow.UseGlobalTime = false; +catch +end +for s = 1:3; drawnow; pause(0.05); end +d.switchPage(1); % back to page 1 (Temperature box) +for s = 1:3; drawnow; pause(0.05); end + +fprintf('\nMulti-tab current-view demo rendered (2 pages).\n'); +fprintf(' Page 1 "Process" : Temperature pre-zoomed 120..240 s -> coloured box on the slider\n'); +fprintf(' Page 2 "Utilities" : Flow pre-zoomed 380..500 s (surfaces when you open the tab)\n'); +fprintf('\nInteract:\n'); +fprintf(' - Click the "Utilities" tab -> P1 box clears, Flow''s box shows\n'); +fprintf(' - Click back to "Process" -> Temperature''s box returns\n'); +fprintf(' - Boxes always reflect the ACTIVE tab; one box per out-of-sync graph\n'); diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index b5f07cf4..bc92e892 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -75,6 +75,7 @@ hTimeEnd = [] hTimeResetBtn = [] % Reset button on time panel (260508-f7p — needed for theme switch) SliderDebounceTimer = [] % MATLAB timer for coalescing rapid slider events + CurrentViewDebounceTimer_ = [] % Phase 1039: coalesces XLim events so the current-view box refreshes once AFTER FastSense's zoom re-resolve settles ResizeDebounceTimer = [] % MATLAB timer for coalescing rapid resize events (260513-q7w) ResizeFinalRedrawTimer = [] % Longer-period backstop timer: unconditional rerenderWidgets after resize fully settles (260513-q7w fu) % 260513-q7w fu2: true while rerenderWidgets is in flight — suppresses @@ -353,6 +354,23 @@ function switchPage(obj, pageIdx) try obj.computePlantLogMarkers(); catch err if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end end + % Phase 1039 — attach the current-view XLim listener to the now-active + % page's widgets. Widgets on pages other than page 1 are not realized + % at render() time (no axes yet), so the render-time attach loop skips + % them; they get realized here on first switch. attachCurrentViewXLimListener_ + % is idempotent and skips unrealized widgets, so this is safe to repeat. + % Without it, zooming a plot on a second tab never refreshes its box. + try + cvWs = obj.flattenWidgetsForPreview_(obj.activePageWidgets()); + for ci = 1:numel(cvWs) + if isa(cvWs{ci}, 'FastSenseWidget') + obj.attachCurrentViewXLimListener_(cvWs{ci}); + end + end + catch + end + % Phase 1039 — recompute the current-view box for the newly active page. + try obj.updateCurrentViewIndicator_(); catch, end end function w = addWidget(obj, type, varargin) @@ -529,6 +547,19 @@ function render(obj) % Auto-detect time range from data obj.updateGlobalTimeRange(); + + % Phase 1039 — attach a current-view XLim listener to each FastSenseWidget + % so user zoom/pan refreshes the slider current-view box. Octave-skipped + % inside the attach helper; non-FastSense widgets are skipped (guarded). + try + cvWs = obj.flattenWidgetsForPreview_(obj.allPageWidgets()); + for ci = 1:numel(cvWs) + if isa(cvWs{ci}, 'FastSenseWidget') + obj.attachCurrentViewXLimListener_(cvWs{ci}); + end + end + catch + end end function startLive(obj) @@ -2023,6 +2054,9 @@ function broadcastTimeRange(obj, tStart, tEnd) ws{i}.Title, ME.message); end end + % Phase 1039 — a re-sync (broadcast) means the plots now match the + % Selection, so the current-view box should hide. Recompute the indicator. + try obj.updateCurrentViewIndicator_(); catch, end end function resetGlobalTime(obj) @@ -2082,6 +2116,12 @@ function onScrollRealize(obj, topRow, bottomRow) w = ws{i}; if ~w.Realized && obj.Layout.isWidgetVisible(w.Position) obj.Layout.realizeWidget(w); + % Phase 1039 — a widget realized on scroll (below the fold at + % render time) needs its current-view XLim listener too, else + % zooming it never refreshes the slider box. Idempotent. + if isa(w, 'FastSenseWidget') + try obj.attachCurrentViewXLimListener_(w); catch, end + end end end drawnow; @@ -2209,6 +2249,9 @@ function onLiveTick(obj) try obj.computePlantLogMarkers(); catch err if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end end + % Phase 1039 — refresh the current-view box each tick so a widget that + % drifted out of sync (or back in) updates without user interaction. + try obj.updateCurrentViewIndicator_(); catch, end end function markAllDirty(obj) @@ -2578,6 +2621,11 @@ function delete(obj) try delete(obj.SliderDebounceTimer); catch, end obj.SliderDebounceTimer = []; end + if ~isempty(obj.CurrentViewDebounceTimer_) + try stop(obj.CurrentViewDebounceTimer_); catch, end + try delete(obj.CurrentViewDebounceTimer_); catch, end + obj.CurrentViewDebounceTimer_ = []; + end if ~isempty(obj.ResizeDebounceTimer) try stop(obj.ResizeDebounceTimer); catch, end try delete(obj.ResizeDebounceTimer); catch, end @@ -2751,6 +2799,15 @@ function attachPlantLogXLimListenerForTest_(obj, widget) obj.attachPlantLogXLimListener_(widget); end + function updateCurrentViewIndicatorForTest_(obj) + %UPDATECURRENTVIEWINDICATORFORTEST_ Phase 1039 test seam. + % Routes to the private updateCurrentViewIndicator_ so class-based + % tests can assert the show/hide decision without depending on the + % Octave-skipped XLim PostSet listener. Mirrors the existing + % attachPlantLogXLimListenerForTest_ / setTimeRangeSelectorForTest_ idiom. + obj.updateCurrentViewIndicator_(); + end + function setTimeRangeSelectorForTest_(obj, sel) %SETTIMERANGESELECTORFORTEST_ Phase 1031 test seam — inject a % TimeRangeSelector handle without going through render(). Used by @@ -3000,6 +3057,36 @@ function attachPlantLogXLimListener_(obj, widget) end end + function attachCurrentViewXLimListener_(obj, widget) + %ATTACHCURRENTVIEWXLIMLISTENER_ XLim PostSet listener -> current-view box refresh (Phase 1039). + % Stored in widget.CurrentViewXLimListener_; released by + % widget.delete(). Idempotent: replaces any prior listener. Octave + % skips (its addlistener lacks the 4-arg PostSet form) — the live + % tick + post-broadcast + page-switch hooks still refresh the box + % there. Mirrors attachPlantLogXLimListener_. + if isempty(widget) || ~isa(widget, 'FastSenseWidget'), return; end + if ~isempty(widget.CurrentViewXLimListener_) + try delete(widget.CurrentViewXLimListener_); catch, end + widget.setCurrentViewXLimListenerForEngine_([]); + end + if isempty(widget.FastSenseObj) || ~widget.FastSenseObj.IsRendered + return; + end + ax = widget.FastSenseObj.hAxes; + if isempty(ax) || ~ishandle(ax), return; end + if exist('OCTAVE_VERSION', 'builtin') + return; + end + try + lis = addlistener(ax, 'XLim', 'PostSet', ... + @(~,~) obj.scheduleCurrentViewRefresh_()); + widget.setCurrentViewXLimListenerForEngine_(lis); + catch err + warning('DashboardEngine:currentViewIndicatorFailed', ... + 'attachCurrentViewXLimListener_ failed: %s', err.message); + end + end + function attachPlantLogWidgetHover_(obj, widget) %ATTACHPLANTLOGWIDGETHOVER_ Lazy-construct a PlantLogWidgetHover for one widget (Phase 1032 PLOG-VIZ-07). % Tears down any prior hover for this widget first (idempotent), @@ -3189,6 +3276,117 @@ function removeDetachedByRef(obj, mirrorHolder) obj.DetachedMirrors = obj.DetachedMirrors(keep); end + function scheduleCurrentViewRefresh_(obj) + %SCHEDULECURRENTVIEWREFRESH_ Debounce the current-view box refresh (Phase 1039). + % The per-widget XLim PostSet listener fires MULTIPLE times during a + % single zoom: once for the user's change, then again as FastSense + % re-resolves/rebuilds its axes for the new window. Refreshing the box + % on each fire races that rebuild and can leave the box hidden even + % though the final view is zoomed. Instead, restart a short one-shot + % timer on every XLim event; once FastSense settles, the timer fires + % ONCE and reads the final getCurrentXLim. Mirrors scheduleResizeRefresh_. + % Falls back to an inline refresh if timers are unavailable (Octave / + % headless) — though the listener itself is Octave-skipped. + if ~obj.isObjValid_(), return; end + if ~isempty(obj.CurrentViewDebounceTimer_) + try + if isvalid(obj.CurrentViewDebounceTimer_) + stop(obj.CurrentViewDebounceTimer_); + delete(obj.CurrentViewDebounceTimer_); + end + catch + end + obj.CurrentViewDebounceTimer_ = []; + end + try + obj.CurrentViewDebounceTimer_ = timer( ... + 'ExecutionMode', 'singleShot', ... + 'StartDelay', 0.15, ... + 'Tag', 'DashboardEngineCurrentViewDebounce', ... + 'TimerFcn', @(~,~) obj.updateCurrentViewIndicator_()); + start(obj.CurrentViewDebounceTimer_); + catch + obj.updateCurrentViewIndicator_(); % timers unavailable — run inline + end + end + + function updateCurrentViewIndicator_(obj) + %UPDATECURRENTVIEWINDICATOR_ Drive the slider current-view boxes (Phase 1039). + % Draws ONE box per visible, out-of-sync graph (UseGlobalTime==false on + % the active page, recursing into GroupWidgets), each at that graph's + % LIVE x-limits and coloured to MATCH that graph's slider preview line. + % A graph's box is shown only when its view differs from the current + % Selection beyond an epsilon scaled to the DataRange span; graphs that + % are synced (or have no live view) contribute no box. When nothing is + % out of sync, all boxes are hidden. Purely indicative — never touches + % the Selection or fires broadcasts. Guarded: no-op when the slider is + % absent. Wrapped by callers in try/catch. + % + % Colour parity: each box's colour index is the graph's PREVIEW-LINE + % index — its position among active-page widgets that yield a valid + % getPreviewSeries — mirroring computePreviewEnvelopeReturning_'s + % linesList order, so box k uses the same shared previewPalette_ slot as + % preview line k. + if ~obj.isObjValid_(), return; end % debounce timer may fire post-delete + sel = obj.TimeRangeSelector_; + if isempty(sel) || ~isa(sel, 'TimeRangeSelector') + return; + end + ws = obj.flattenWidgetsForPreview_(obj.activePageWidgets()); + % Same bucket count the preview used (cache hit on getPreviewSeries). + nB = obj.PreviewNBuckets_; + if ~(isscalar(nB) && isfinite(nB) && nB > 0) + nB = 200; + end + span = obj.DataTimeRange(2) - obj.DataTimeRange(1); + if ~isfinite(span) || span <= 0 + span = 1; + end + eps_ = 0.005 * span; % anti-flicker tolerance vs the Selection + [selStart, selEnd] = sel.getSelection(); + ranges = []; + colorIdxs = []; + previewIdx = 0; % mirrors linesList position (preview-line colour slot) + for i = 1:numel(ws) + w = ws{i}; + % A widget contributes a preview line (and thus a palette slot) + % iff getPreviewSeries returns a valid struct — mirror exactly so + % box colours line up with the drawn preview lines. + hasSeries = false; + try + s = w.getPreviewSeries(nB); + hasSeries = ~isempty(s) && isstruct(s) && ... + isfield(s, 'xCenters') && ~isempty(s.xCenters); + catch + hasSeries = false; + end + if hasSeries + previewIdx = previewIdx + 1; + end + % Only FastSenseWidgets expose getCurrentXLim + UseGlobalTime. + if ~isa(w, 'FastSenseWidget'), continue; end + if w.UseGlobalTime, continue; end % synced — no box + xl = []; + try xl = w.getCurrentXLim(); catch, xl = []; end + if isempty(xl) || numel(xl) ~= 2 || ~all(isfinite(xl)), continue; end + % Only show when THIS graph's view differs from the Selection. + if (abs(xl(1) - selStart) <= eps_) && (abs(xl(2) - selEnd) <= eps_) + continue; + end + ranges(end + 1, :) = [xl(1) xl(2)]; %#ok + if hasSeries + colorIdxs(end + 1) = previewIdx; %#ok matches its preview line + else + colorIdxs(end + 1) = numel(colorIdxs) + 1; %#ok no line — next slot + end + end + if isempty(ranges) + sel.hideCurrentView(); % nothing out of sync + else + sel.setCurrentViews(ranges, colorIdxs); + end + end + function flat = flattenWidgetsForPreview_(obj, widgets, depth) %FLATTENWIDGETSFORPREVIEW_ Depth-first flatten of a widget list. % Unwraps any container widget that returns a non-empty diff --git a/libs/Dashboard/DashboardTheme.m b/libs/Dashboard/DashboardTheme.m index be2217ee..5d94b6b9 100644 --- a/libs/Dashboard/DashboardTheme.m +++ b/libs/Dashboard/DashboardTheme.m @@ -68,6 +68,7 @@ d.TabActiveBg = [0.16 0.22 0.34]; d.TabInactiveBg = [0.10 0.12 0.18]; d.MarkerPlantLog = [0 0 0]; % Phase 1031 PLOG-VIZ-09: black plant-log slider markers + d.CurrentViewBoxColor = [0.95 0.62 0.20]; % Phase 1039: amber current-view box, contrasts with bluish-gray Selection otherwise % 'light' (also: legacy aliases default/industrial/scientific/ocean) d.DashboardBackground = [0.96 0.96 0.97]; d.WidgetBackground = [1.00 1.00 1.00]; @@ -83,6 +84,7 @@ d.TabActiveBg = [0.90 0.92 0.95]; d.TabInactiveBg = [0.82 0.84 0.88]; d.MarkerPlantLog = [0 0 0]; % Phase 1031 PLOG-VIZ-09: black plant-log slider markers + d.CurrentViewBoxColor = [0.85 0.45 0.05]; % Phase 1039: dark amber, contrasts with the dark-blue Selection on light bg end % Axis label/tick color — derive from toolbar font (readable on widget bg) diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m index c49674ef..e5c30206 100644 --- a/libs/Dashboard/FastSenseWidget.m +++ b/libs/Dashboard/FastSenseWidget.m @@ -90,6 +90,18 @@ % {?ClassName}). properties (SetAccess = private) PlantLogXLimListener_ = [] % Phase 1032 — addlistener handle for XLim PostSet refresh; non-empty when ShowPlantLog=true and widget is rendered + CurrentViewXLimListener_ = [] % Phase 1039 — addlistener handle for the current-view-box XLim PostSet notify; engine owns lifecycle + end + + properties (Hidden) + % Phase 1039 test seam. When non-empty (1x2), getCurrentXLim returns it + % verbatim instead of reading the live axes. Lets integration tests drive + % the engine's current-view decision deterministically — FastSense rebuilds + % its axes on zoom-re-resolve, so a raw programmatic xlim() poke is not a + % durable way to simulate "this widget is showing a sub-window" under the + % unittest runner's event flushing. The real axes<->getCurrentXLim path is + % covered separately by TestFastSenseWidgetCurrentXLim. Empty in production. + CurrentXLimOverrideForTest_ = [] end properties (Access = private, Constant) @@ -484,6 +496,14 @@ function setPlantLogXLimListenerForEngine_(obj, lis) obj.PlantLogXLimListener_ = lis; end + % Hidden — DashboardEngine writes CurrentViewXLimListener_ via this seam + % (Phase 1039) since the property is SetAccess=private. Hidden methods are + % callable from anywhere (Octave-safe idiom). The engine owns the handle's + % lifecycle; the widget only stores it so delete() can release it. + function setCurrentViewXLimListenerForEngine_(obj, lis) + obj.CurrentViewXLimListener_ = lis; + end + function setShowPlantLog(obj, tf, engine) %SETSHOWPLANTLOG Toggle the per-widget plant-log overlay (Phase 1032 PLOG-VIZ-03). % tf — boolean; true enables overlay + attaches XLim listener, @@ -714,6 +734,44 @@ function onXLimChanged(obj) end end + function xl = getCurrentXLim(obj) + %GETCURRENTXLIM Live x-limits of the wrapped FastSense axes (Phase 1039). + % Returns the 1x2 [xMin xMax] the plot is CURRENTLY showing — the + % actual view window, read live from the axes via get(ax,'XLim'). + % Returns [] when the widget is not rendered (no FastSenseObj, not + % IsRendered, or no valid axes). + % + % This is deliberately NOT getTimeRange(): getTimeRange returns the + % cached DATA extent (CachedXMin/CachedXMax), which does not move + % when the user zooms/pans. The current-view box (DashboardEngine. + % updateCurrentViewIndicator_) needs the live view, so it calls this. + xl = []; + % Phase 1039 test seam: a forced value bypasses the live-axes read. + if ~isempty(obj.CurrentXLimOverrideForTest_) + v = obj.CurrentXLimOverrideForTest_; + if numel(v) == 2 && all(isfinite(v)) && v(2) > v(1) + xl = [v(1), v(2)]; + end + return; + end + if isempty(obj.FastSenseObj) || ~isa(obj.FastSenseObj, 'FastSense') || ... + ~obj.FastSenseObj.IsRendered + return; + end + ax = obj.FastSenseObj.hAxes; + if isempty(ax) || ~ishandle(ax) + return; + end + try + v = get(ax, 'XLim'); + catch + return; + end + if numel(v) == 2 && all(isfinite(v)) && v(2) > v(1) + xl = [v(1), v(2)]; + end + end + function series = getPreviewSeries(obj, nBuckets) %GETPREVIEWSERIES Per-bucket min/max preview for the dashboard envelope. % series = getPreviewSeries(obj, nBuckets) returns a struct with @@ -1167,6 +1225,12 @@ function delete(obj) try delete(obj.PlantLogXLimListener_); catch, end obj.PlantLogXLimListener_ = []; end + % Phase 1039 — release the current-view XLim listener before FastSenseObj + % teardown destroys the axes the listener is bound to. + if ~isempty(obj.CurrentViewXLimListener_) + try delete(obj.CurrentViewXLimListener_); catch, end + obj.CurrentViewXLimListener_ = []; + end % Explicitly stop FastSense timers (hRefineTimer, LiveTimer, % DeferredTimer) before the base-class delete() destroys hPanel. % Without this, an errored singleShot hRefineTimer can survive diff --git a/libs/Dashboard/TimeRangeSelector.m b/libs/Dashboard/TimeRangeSelector.m index 07abdf74..34cdaaee 100644 --- a/libs/Dashboard/TimeRangeSelector.m +++ b/libs/Dashboard/TimeRangeSelector.m @@ -31,14 +31,25 @@ % % Properties (read-only, set internally): % hPanel, hFigure, hAxes, hEnvelope, hSelection, hEdgeLeft, hEdgeRight + % hCurrentViewBoxes, hCurrentViewEdgesL, hCurrentViewEdgesR (Phase 1039: + % a POOL of visually-distinct "current view" boxes — one per + % out-of-sync graph — each coloured to match that graph's + % slider preview line; empty unless setCurrentViews is called. + % See DashboardEngine.updateCurrentViewIndicator_) % DataRange 1x2 [tMin tMax]. % Selection 1x2 [tStart tEnd]. + % CurrentViews Nx2 [tStart tEnd] rows (one per box), or [] when hidden. % DragState 'idle' | 'panning' | 'resizeLeft' | 'resizeRight'. % % Methods: % setDataRange(tMin, tMax) Set full extent; rescales selection. % setSelection(tStart, tEnd) Set/clamp/reorder selection; fires callback. % getSelection() Return [tStart, tEnd]. + % setCurrentViews(ranges, colorIdxs) Show one non-interactive current-view + % box per row of ranges (Nx2), each coloured + % from the shared preview palette by colorIdxs. + % setCurrentView(tStart, tEnd) Back-compat single-box wrapper. + % hideCurrentView() Hide/clear all current-view boxes. % setEnvelope(xC, yMin, yMax) Update or hide aggregate envelope. % delete() Restore saved figure callbacks. % @@ -69,6 +80,11 @@ hSelection = [] % patch for selection rectangle hEdgeLeft = [] % line: left drag handle hEdgeRight = [] % line: right drag handle + hCurrentViewBoxes = [] % Phase 1039: patch handles, ONE per out-of-sync graph (pool, reused across updates) + hCurrentViewEdgesL = [] % Phase 1039: left edge line per box + hCurrentViewEdgesR = [] % Phase 1039: right edge line per box + CurrentViews = [] % Phase 1039: Nx2 [tStart tEnd] rows (one per box), or [] when hidden + CurrentViewColorIdx_ = [] % Phase 1039: 1xN preview-line indices used to colour each box from the shared palette hRangeLabelLeft = [] % text label BELOW slider — slider LEFT selection-edge timestamp (260512-hrn-followup) hRangeLabelMiddle = [] % text label BELOW slider — selection duration (e.g. "3d 12h") hRangeLabelRight = [] % text label BELOW slider — slider RIGHT selection-edge timestamp @@ -215,6 +231,69 @@ function setSelection(obj, tStart, tEnd) tEnd = obj.Selection(2); end + function setCurrentView(obj, tStart, tEnd) + %setCurrentView Back-compat single-box wrapper around setCurrentViews. + % Phase 1039. Shows ONE current-view box at [tStart, tEnd] coloured + % with preview-palette index 1. Prefer setCurrentViews for the + % per-graph multi-box path. No-throw on bad/empty input. + if nargin < 3 || isempty(tStart) || isempty(tEnd) || ... + ~isfinite(tStart) || ~isfinite(tEnd) + return; + end + obj.setCurrentViews([tStart tEnd], 1); + end + + function setCurrentViews(obj, ranges, colorIdxs) + %setCurrentViews Show ONE current-view box per out-of-sync graph (Phase 1039). + % ranges Nx2 [tStart tEnd] rows in data-time (a flat 1x2 is + % accepted for the single-box case). + % colorIdxs 1xN preview-line indices (1-based). Each box is coloured + % from the shared preview palette (previewPalette_) by its + % index, so a box matches its graph's slider preview line. + % Defaults to 1:N when omitted. + % Each range is clamped to DataRange and reordered if swapped. Boxes + % are purely indicative (PickableParts/HitTest off) — only the + % Selection is interactive. Empty ranges hide everything. No-throw + % before render / after the figure is destroyed (handle-guarded). + if nargin < 2 || isempty(ranges) + obj.hideCurrentView(); + return; + end + if size(ranges, 2) ~= 2 + ranges = reshape(ranges(:), [], 2); + end + n = size(ranges, 1); + if nargin < 3 || isempty(colorIdxs) + colorIdxs = 1:n; + end + colorIdxs = double(colorIdxs(:)'); + % Clamp + reorder each range against DataRange. + for k = 1:n + a = ranges(k, 1); b = ranges(k, 2); + if ~isfinite(a) || ~isfinite(b) + a = NaN; b = NaN; + elseif a > b + tmp = a; a = b; b = tmp; + end + a = max(a, obj.DataRange(1)); + b = min(b, obj.DataRange(2)); + ranges(k, :) = [a b]; + end + obj.CurrentViews = ranges; + obj.CurrentViewColorIdx_ = colorIdxs; + obj.ensureCurrentViewHandles_(n); + obj.redrawCurrentViews_(); + end + + function hideCurrentView(obj) + %hideCurrentView Hide and clear ALL current-view boxes. + % Phase 1039. Clears CurrentViews and deletes every pooled box + + % edge-line handle. Safe to call before render / after delete. + obj.CurrentViews = []; + obj.CurrentViewColorIdx_ = []; + obj.deleteCurrentViewHandles_(0); + end + function setRangeLabels(obj, leftText, rightText, middleText) %setRangeLabels Update the date/time labels shown BELOW the slider. % Updates three labels: @@ -268,14 +347,7 @@ function setPreviewLines(obj, lines) % endpoint) but the same number of widget lines, so the in-place % path fires every tick and avoids the delete/recreate cycle that % blocked the MATLAB event queue. - palette = [ ... - 0.00 0.45 0.70 % blue - 0.90 0.40 0.20 % orange - 0.20 0.60 0.20 % green - 0.70 0.20 0.50 % purple - 0.85 0.70 0.20 % mustard - 0.30 0.70 0.70 % teal - 0.70 0.30 0.30]; % brick + palette = obj.previewPalette_(); % shared with current-view boxes (Phase 1039) % Hide the legacy envelope patch regardless of path taken. set(obj.hEnvelope, 'Visible', 'off'); if isempty(lines) @@ -771,6 +843,98 @@ function restoreCallback_(obj, cb) end end + function pal = previewPalette_(~) + %previewPalette_ Shared colour palette for slider preview lines AND + % current-view boxes (Phase 1039). Single source of truth so each + % current-view box matches its graph's preview line colour by index. + pal = [ ... + 0.00 0.45 0.70 % blue + 0.90 0.40 0.20 % orange + 0.20 0.60 0.20 % green + 0.70 0.20 0.50 % purple + 0.85 0.70 0.20 % mustard + 0.30 0.70 0.70 % teal + 0.70 0.30 0.30]; % brick + end + + function ensureCurrentViewHandles_(obj, n) + %ensureCurrentViewHandles_ Grow/shrink the current-view box pool to + % exactly n box+edge triples, reusing existing handles (no thrash on + % repeat updates). No-op without a valid axes. + if ~ishandle(obj.hAxes), return; end + cur = numel(obj.hCurrentViewBoxes); + for k = cur + 1:n + obj.hCurrentViewBoxes(k) = patch(obj.hAxes, NaN, NaN, [0.9 0.55 0.15], ... + 'FaceAlpha', 0.12, 'EdgeColor', 'none', ... + 'HitTest', 'off', 'PickableParts', 'none', ... + 'Tag', 'TimeRangeSelectorCurrentView', 'Visible', 'off'); + obj.hCurrentViewEdgesL(k) = line(obj.hAxes, [NaN NaN], [0 1], ... + 'Color', [0.9 0.55 0.15], 'LineWidth', 1, 'LineStyle', '--', ... + 'HitTest', 'off', 'PickableParts', 'none', 'Visible', 'off'); + obj.hCurrentViewEdgesR(k) = line(obj.hAxes, [NaN NaN], [0 1], ... + 'Color', [0.9 0.55 0.15], 'LineWidth', 1, 'LineStyle', '--', ... + 'HitTest', 'off', 'PickableParts', 'none', 'Visible', 'off'); + end + if n < cur + obj.deleteCurrentViewHandles_(n); + end + end + + function deleteCurrentViewHandles_(obj, keep) + %deleteCurrentViewHandles_ Delete pooled box handles beyond index keep + % (keep=0 clears all). Trims the handle arrays to length keep. + for k = keep + 1:numel(obj.hCurrentViewBoxes) + if ishandle(obj.hCurrentViewBoxes(k)), delete(obj.hCurrentViewBoxes(k)); end + end + for k = keep + 1:numel(obj.hCurrentViewEdgesL) + if ishandle(obj.hCurrentViewEdgesL(k)), delete(obj.hCurrentViewEdgesL(k)); end + end + for k = keep + 1:numel(obj.hCurrentViewEdgesR) + if ishandle(obj.hCurrentViewEdgesR(k)), delete(obj.hCurrentViewEdgesR(k)); end + end + if keep <= 0 + obj.hCurrentViewBoxes = []; + obj.hCurrentViewEdgesL = []; + obj.hCurrentViewEdgesR = []; + else + obj.hCurrentViewBoxes = obj.hCurrentViewBoxes(1:min(keep, numel(obj.hCurrentViewBoxes))); + obj.hCurrentViewEdgesL = obj.hCurrentViewEdgesL(1:min(keep, numel(obj.hCurrentViewEdgesL))); + obj.hCurrentViewEdgesR = obj.hCurrentViewEdgesR(1:min(keep, numel(obj.hCurrentViewEdgesR))); + end + end + + function redrawCurrentViews_(obj) + %redrawCurrentViews_ Push CurrentViews geometry + per-index colours to + % the pooled box handles and make them visible. Each box is coloured + % from previewPalette_ by its CurrentViewColorIdx_ entry, matching the + % corresponding slider preview line. + pal = obj.previewPalette_(); + np = size(pal, 1); + n = size(obj.CurrentViews, 1); + for k = 1:n + if k > numel(obj.hCurrentViewBoxes), break; end + cL = obj.CurrentViews(k, 1); cR = obj.CurrentViews(k, 2); + idx = k; + if k <= numel(obj.CurrentViewColorIdx_) + idx = obj.CurrentViewColorIdx_(k); + end + if ~isfinite(idx) || idx < 1, idx = 1; end + c = pal(mod(round(idx) - 1, np) + 1, :); + if ishandle(obj.hCurrentViewBoxes(k)) + set(obj.hCurrentViewBoxes(k), 'XData', [cL cL cR cR], 'YData', [0 1 1 0], ... + 'FaceColor', c, 'Visible', 'on'); + end + if ishandle(obj.hCurrentViewEdgesL(k)) + set(obj.hCurrentViewEdgesL(k), 'XData', [cL cL], 'YData', [0 1], ... + 'Color', c, 'Visible', 'on'); + end + if ishandle(obj.hCurrentViewEdgesR(k)) + set(obj.hCurrentViewEdgesR(k), 'XData', [cR cR], 'YData', [0 1], ... + 'Color', c, 'Visible', 'on'); + end + end + end + function buildGraphics_(obj) %buildGraphics_ Construct axes and graphics handles inside hPanel. % Slider axes height reduced (was 0.85) so three date/time labels @@ -813,6 +977,18 @@ function buildGraphics_(obj) obj.hEdgeRight = line(obj.hAxes, [NaN NaN], [0 1], ... 'Color', selColor, 'LineWidth', 2, ... 'HitTest', 'off', 'PickableParts', 'none'); + % Phase 1039 — current-view boxes: ONE visually-distinct box per + % out-of-sync graph, marking that graph's current/latest x-limits when + % it is NOT synced with the Selection. Each box is coloured to match its + % graph's slider preview line (shared previewPalette_, by index), with + % lower alpha + dashed thinner edges so boxes never read as the + % (dominant) Selection rectangle. Purely indicative (PickableParts/ + % HitTest off). The handle pool is created lazily by setCurrentViews / + % ensureCurrentViewHandles_; the engine (updateCurrentViewIndicator_) + % drives visibility via setCurrentViews / hideCurrentView. + obj.hCurrentViewBoxes = []; + obj.hCurrentViewEdgesL = []; + obj.hCurrentViewEdgesR = []; % Date/time labels BELOW the slider strip: % - LEFT : slider's LEFT selection-edge timestamp % - MIDDLE: selection duration (e.g. "7d", "3h 25m", "45 s") @@ -1025,6 +1201,12 @@ function redraw_(obj) set(obj.hSelection, 'XData', [xL xL xR xR], 'YData', [0 1 1 0]); set(obj.hEdgeLeft, 'XData', [xL xL], 'YData', [0 1]); set(obj.hEdgeRight, 'XData', [xR xR], 'YData', [0 1]); + % Phase 1039 — refresh all active current-view boxes (one per + % out-of-sync graph). Same data-time space as the Selection; hidden + % state is owned by hideCurrentView. Only refreshes when boxes exist. + if ~isempty(obj.CurrentViews) + obj.redrawCurrentViews_(); + end % Inline in-axes edge labels removed (260512-hrn-followup). % Edge timestamps now live in the text labels BELOW the slider — % populated via setRangeLabels from the engine. Widget kind is diff --git a/tests/suite/TestDashboardCurrentViewIndicator.m b/tests/suite/TestDashboardCurrentViewIndicator.m new file mode 100644 index 00000000..e3ab046f --- /dev/null +++ b/tests/suite/TestDashboardCurrentViewIndicator.m @@ -0,0 +1,299 @@ +classdef TestDashboardCurrentViewIndicator < matlab.unittest.TestCase +%TESTDASHBOARDCURRENTVIEWINDICATOR End-to-end integration suite for the Phase 1039 current-view boxes. +% Goal-backward verification: builds a real DashboardEngine with multiple +% FastSenseWidgets, zooms ONE (or more) widget(s) out of sync, drives the +% engine indicator via the public test seam updateCurrentViewIndicatorForTest_, +% and asserts the slider grows ONE box PER out-of-sync graph at that graph's +% XLim window; re-syncing (broadcastTimeRange) clears them; an all-synced +% dashboard never shows any. +% +% This asserts the observable truths of the phase goal against the INTEGRATED +% stack from Plans 01-03: +% - Plan 01: TimeRangeSelector box pool + setCurrentViews/hideCurrentView +% - Plan 02: FastSenseWidget.getCurrentXLim + UseGlobalTime out-of-sync signal +% - Plan 03: DashboardEngine.updateCurrentViewIndicator_ + the test seam +% +% Determinism note: the indicator decision is driven through the PUBLIC seam +% updateCurrentViewIndicatorForTest_ rather than the XLim PostSet listener, +% which Octave skips. zoomWidget_ additionally forces UseGlobalTime=false so +% the out-of-sync state is robust on Octave (on MATLAB the listener already +% flipped it, so the explicit set is a no-op). +% +% Coverage: +% testBoxHiddenWhenAllSynced -> all-synced -> pool empty, CurrentViews empty +% testBoxAppearsWhenWidgetZoomed -> one zoomed -> one box at that window +% testBoxHidesAfterResync -> broadcastTimeRange re-sync -> pool cleared +% testTwoOutOfSyncProduceSeparateBoxes-> two zoomed -> two boxes, distinct colours +% testPageSwitchScopesBoxesToActivePage-> multi-tab -> boxes follow the active page +% testSubEpsilonDifferenceStaysHidden -> sub-epsilon delta from Selection -> no box +% testNoSelectorGuardNoThrow -> engine without a slider -> seam no-throw + + properties + Engines = {} + end + + methods (TestClassSetup) + function addPaths(testCase) %#ok + thisDir = fileparts(mfilename('fullpath')); + repoRoot = fileparts(fileparts(thisDir)); + addpath(repoRoot); + install(); + end + end + + methods (TestMethodTeardown) + function cleanup(testCase) + for k = 1:numel(testCase.Engines) + try + if ~isempty(testCase.Engines{k}) && isvalid(testCase.Engines{k}) + delete(testCase.Engines{k}); + end + catch + end + end + testCase.Engines = {}; + try close all force; catch, end + try drawnow; catch, end + end + end + + methods (Access = private) + function [d, x] = makeDashboard_(testCase, nWidgets) + %makeDashboard_ Build + render an nWidgets FastSense dashboard over X in [0 100]. + % Inline XData/YData so the rendered axes carry a real data range + % (DataTimeRange == [0 100]) the indicator can reason about. The + % figure is made invisible after render so the suite runs headless + % without flicker; the slider (TimeRangeSelector_) still builds. + x = linspace(0, 100, 300); + d = DashboardEngine('CurrentView Test'); + for k = 1:nWidgets + d.addWidget('fastsense', ... + 'Position', [1, 1 + (k - 1) * 4, 24, 4], ... + 'XData', x, 'YData', sin(x / 5) + k); + end + d.render(); + try set(d.hFigure, 'Visible', 'off'); catch, end + drawnow; + testCase.Engines{end + 1} = d; + end + + function zoomWidget_(~, w, a, b) + %zoomWidget_ Simulate a widget showing the sub-window [a b], out of sync. + % Drives the deterministic test seam CurrentXLimOverrideForTest_ rather + % than poking the live axes: FastSense rebuilds its axes during the + % zoom-re-resolve, so a raw xlim() set is not durable under the + % unittest runner's event flushing (it can snap back to the data + % extent before the seam reads it). The real axes<->getCurrentXLim + % path is verified deterministically by TestFastSenseWidgetCurrentXLim. + % We also set the live axes (realistic) and force UseGlobalTime=false + % (on Octave the XLim PostSet listener that flips it may not fire). + try + ax = w.FastSenseObj.hAxes; + if ishandle(ax); xlim(ax, [a b]); end + catch + end + w.CurrentXLimOverrideForTest_ = [a b]; + w.UseGlobalTime = false; + drawnow; + end + end + + methods (Test) + function testBoxHiddenWhenAllSynced(testCase) + % All widgets in sync (none UseGlobalTime==false): the box stays + % hidden and CurrentView is empty even after the seam runs. + [d, ~] = testCase.makeDashboard_(2); + sel = d.TimeRangeSelector_; + testCase.assumeNotEmpty(sel, ... + 'No TimeRangeSelector built (slider-less environment) — skip.'); + + d.updateCurrentViewIndicatorForTest_(); + + testCase.verifyEmpty(sel.hCurrentViewBoxes, ... + 'All-synced dashboard must leave the current-view box pool empty.'); + testCase.verifyEmpty(sel.CurrentViews, ... + 'CurrentViews must be empty when nothing is out of sync.'); + end + + function testBoxAppearsWhenWidgetZoomed(testCase) + % Zoom widget 1 to an interior sub-window distinct from the Selection, + % drive the seam, and assert the box surfaces at exactly that window. + [d, ~] = testCase.makeDashboard_(2); + sel = d.TimeRangeSelector_; + testCase.assumeNotEmpty(sel, ... + 'No TimeRangeSelector built (slider-less environment) — skip.'); + + w1 = d.Widgets{1}; + testCase.zoomWidget_(w1, 20, 40); + d.updateCurrentViewIndicatorForTest_(); + + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 1, ... + 'One out-of-sync graph must produce exactly one box.'); + testCase.verifyEqual(char(get(sel.hCurrentViewBoxes(1), 'Visible')), 'on', ... + 'Zooming a widget out of sync must surface its current-view box.'); + testCase.verifyEqual(sel.CurrentViews, [20 40], 'AbsTol', 0.1, ... + 'CurrentViews must match the zoomed widget''s XLim window.'); + boxX = reshape(get(sel.hCurrentViewBoxes(1), 'XData'), 1, []); + testCase.verifyEqual(boxX, [20 20 40 40], 'AbsTol', 0.1, ... + 'Box XData must follow the [xL xL xR xR] patch shape at the zoomed window.'); + end + + function testBoxHidesAfterResync(testCase) + % From a zoomed/showing state, re-sync via the PUBLIC broadcastTimeRange + % path (exercises Plan 03 SITE 2: broadcastTimeRange calls the indicator). + % With no widget out of sync, the box hides. + [d, ~] = testCase.makeDashboard_(2); + sel = d.TimeRangeSelector_; + testCase.assumeNotEmpty(sel, ... + 'No TimeRangeSelector built (slider-less environment) — skip.'); + + w1 = d.Widgets{1}; + testCase.zoomWidget_(w1, 20, 40); + d.updateCurrentViewIndicatorForTest_(); + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 1, ... + 'Precondition: one box must exist before re-sync.'); + + % Re-sync: clear the out-of-sync flag, then broadcast a window. + % broadcastTimeRange itself re-runs updateCurrentViewIndicator_; + % with the widget back in sync the boxes must clear. Also clear the + % test override so getCurrentXLim no longer reports a sub-window. + w1.UseGlobalTime = true; + w1.CurrentXLimOverrideForTest_ = []; + d.broadcastTimeRange(20, 40); + drawnow; + + testCase.verifyEmpty(sel.hCurrentViewBoxes, ... + 'Re-syncing via broadcastTimeRange must clear the current-view boxes.'); + testCase.verifyEmpty(sel.CurrentViews, ... + 'CurrentViews must be empty after re-sync.'); + end + + function testTwoOutOfSyncProduceSeparateBoxes(testCase) + % Two widgets zoomed to disjoint windows: TWO SEPARATE boxes (one per + % graph), each at its own window — NOT a single union box. Each box is + % coloured from the shared preview palette by its preview-line index, + % so the two boxes differ in colour. + [d, ~] = testCase.makeDashboard_(2); + sel = d.TimeRangeSelector_; + testCase.assumeNotEmpty(sel, ... + 'No TimeRangeSelector built (slider-less environment) — skip.'); + + w1 = d.Widgets{1}; + w2 = d.Widgets{2}; + testCase.zoomWidget_(w1, 10, 30); + testCase.zoomWidget_(w2, 50, 80); + d.updateCurrentViewIndicatorForTest_(); + + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 2, ... + 'Two out-of-sync graphs must produce two separate boxes (not a union).'); + % CurrentViews rows follow widget order (w1 then w2). + rows = sortrows(sel.CurrentViews, 1); + testCase.verifyEqual(rows(1, :), [10 30], 'AbsTol', 0.1, ... + 'First box must mark widget 1''s window [10 30].'); + testCase.verifyEqual(rows(2, :), [50 80], 'AbsTol', 0.1, ... + 'Second box must mark widget 2''s window [50 80].'); + c1 = get(sel.hCurrentViewBoxes(1), 'FaceColor'); + c2 = get(sel.hCurrentViewBoxes(2), 'FaceColor'); + testCase.verifyNotEqual(c1, c2, ... + 'Per-graph boxes must use distinct preview-palette colours.'); + end + + function testPageSwitchScopesBoxesToActivePage(testCase) + % Multi-tab: a box belongs to the page its graph lives on. Switching + % tabs via the REAL switchPage (exercising Plan 03 SITE 4) must clear + % the now-inactive page's boxes and surface the now-active page's + % out-of-sync boxes. Confirms the indicator scopes to + % activePageWidgets() and the switchPage wiring auto-refreshes it + % (no manual seam call after the switches below). + x = linspace(0, 100, 300); + d = DashboardEngine('CurrentView MultiPage'); + testCase.Engines{end + 1} = d; + d.addPage('P1'); + d.addWidget('fastsense', 'Title', 'A', 'XData', x, 'YData', sin(x / 5)); + d.addPage('P2'); + d.switchPage(2); + d.addWidget('fastsense', 'Title', 'C', 'XData', x, 'YData', cos(x / 7)); + d.switchPage(1); + d.render(); + try set(d.hFigure, 'Visible', 'off'); catch, end + drawnow; + sel = d.TimeRangeSelector_; + testCase.assumeNotEmpty(sel, ... + 'No TimeRangeSelector built (slider-less environment) — skip.'); + + wA = d.Pages{1}.Widgets{1}; + wC = d.Pages{2}.Widgets{1}; + + % Zoom A (page 1) out of sync; P1 active -> one box. + wA.CurrentXLimOverrideForTest_ = [20 40]; + wA.UseGlobalTime = false; + d.updateCurrentViewIndicatorForTest_(); + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 1, ... + 'Page 1 active with A zoomed must show one box.'); + + % Real switchPage(2): A is on the inactive page -> its box clears. + d.switchPage(2); + drawnow; + testCase.verifyEmpty(sel.hCurrentViewBoxes, ... + 'Switching to P2 must clear P1''s box (indicator scopes to active page).'); + + % Zoom C (page 2) out of sync; P2 active -> one box at C's window. + wC.CurrentXLimOverrideForTest_ = [60 90]; + wC.UseGlobalTime = false; + d.updateCurrentViewIndicatorForTest_(); + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 1, ... + 'Page 2 active with C zoomed must show one box.'); + testCase.verifyEqual(sel.CurrentViews, [60 90], 'AbsTol', 0.1, ... + 'P2 box must mark C''s window.'); + + % Switch back to P1: A's box returns at its window (no manual seam). + d.switchPage(1); + drawnow; + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 1, ... + 'Switching back to P1 must restore A''s box.'); + testCase.verifyEqual(sel.CurrentViews, [20 40], 'AbsTol', 0.1, ... + 'Restored P1 box must mark A''s window.'); + end + + function testSubEpsilonDifferenceStaysHidden(testCase) + % A widget whose live XLim differs from the Selection by less than the + % epsilon (0.005 * span = 0.5 over span 100) must NOT show the box — + % this guards against float-noise flicker. The widget IS flagged + % out-of-sync, but the union ~ Selection within epsilon -> hidden. + [d, ~] = testCase.makeDashboard_(1); + sel = d.TimeRangeSelector_; + testCase.assumeNotEmpty(sel, ... + 'No TimeRangeSelector built (slider-less environment) — skip.'); + + [selStart, selEnd] = sel.getSelection(); + % Nudge by 0.1 each side: well under epsilon (0.5) for span 100. + w1 = d.Widgets{1}; + testCase.zoomWidget_(w1, selStart + 0.1, selEnd - 0.1); + d.updateCurrentViewIndicatorForTest_(); + + testCase.verifyEmpty(sel.hCurrentViewBoxes, ... + 'A sub-epsilon difference from the Selection must NOT show a box.'); + testCase.verifyEmpty(sel.CurrentViews, ... + 'CurrentViews must stay empty for a sub-epsilon difference.'); + end + + function testNoSelectorGuardNoThrow(testCase) + % Guard: an engine with no TimeRangeSelector_ (never rendered) must + % no-op cleanly when the seam is called — never throw. + d = DashboardEngine('NoSelector'); + testCase.Engines{end + 1} = d; + d.addWidget('fastsense', 'Position', [1 1 24 4], ... + 'XData', linspace(0, 100, 50), 'YData', linspace(0, 1, 50)); + testCase.verifyEmpty(d.TimeRangeSelector_, ... + 'Precondition: no slider before render.'); + try + d.updateCurrentViewIndicatorForTest_(); + testCase.verifyTrue(true, ... + 'Seam must no-op (not throw) when no TimeRangeSelector_ exists.'); + catch err + testCase.verifyFail(sprintf( ... + 'Seam must not throw without a slider, but threw: %s', err.message)); + end + end + end +end diff --git a/tests/suite/TestFastSenseWidgetCurrentXLim.m b/tests/suite/TestFastSenseWidgetCurrentXLim.m new file mode 100644 index 00000000..b6239d69 --- /dev/null +++ b/tests/suite/TestFastSenseWidgetCurrentXLim.m @@ -0,0 +1,97 @@ +classdef TestFastSenseWidgetCurrentXLim < matlab.unittest.TestCase +%TESTFASTSENSEWIDGETCURRENTXLIM Unit suite for FastSenseWidget.getCurrentXLim (Phase 1039 Plan 02). +% Proves the live-view-vs-data-cache semantics of getCurrentXLim(): +% - returns [] before render (no FastSenseObj / not IsRendered), +% - returns the live wrapped-FastSense axes XLim (1x2) after render, +% - tracks a programmatic xlim() change (reads the LIVE axes, not a cache), +% - diverges from getTimeRange() (the data-extent cache) when zoomed. +% Also covers the engine-owned CurrentViewXLimListener_ slot setter and +% delete() cleanup (both must be no-throw). +% +% Mirrors the off-screen-figure + addTeardown convention used by the other +% FastSenseWidget suites (TestFastSenseWidgetTag, TestFastSenseWidgetPlantLog). +% +% See also FastSenseWidget, FastSenseWidget.getCurrentXLim, FastSenseWidget.getTimeRange. + + methods (TestClassSetup) + function addPaths(testCase) %#ok + here = fileparts(mfilename('fullpath')); + repo = fileparts(fileparts(here)); + addpath(repo); + install(); + end + end + + methods (Access = private) + function w = makeRenderedWidget(testCase) + %MAKERENDEREDWIDGET Build + render an inline-data FastSenseWidget off-screen. + % Inline-data binding (XData/YData) needs no Tag/registry. Returns the + % rendered widget; the host figure and widget are torn down via + % addTeardown so each test cleans up after itself. + x = linspace(0, 10, 200); + y = sin(x); + w = FastSenseWidget('XData', x, 'YData', y); + hFig = figure('Visible', 'off'); + testCase.addTeardown(@() close(hFig)); + hp = uipanel('Parent', hFig, 'Position', [0 0 1 1]); + w.render(hp); + testCase.addTeardown(@() delete(w)); + drawnow; % let the axes settle so XLim is populated + end + end + + methods (Test) + + function testEmptyBeforeRender(testCase) + %TESTEMPTYBEFORERENDER getCurrentXLim() is [] before render. + w = FastSenseWidget('XData', 0:10, 'YData', sin(0:10)); + testCase.addTeardown(@() delete(w)); + testCase.verifyEmpty(w.getCurrentXLim()); + end + + function testReturnsLiveXLimAfterRender(testCase) + %TESTRETURNSLIVEXLIMAFTERRENDER 1x2 finite increasing == live axes XLim. + w = testCase.makeRenderedWidget(); + xl = w.getCurrentXLim(); + testCase.verifySize(xl, [1 2]); + testCase.verifyTrue(all(isfinite(xl))); + testCase.verifyGreaterThan(xl(2), xl(1)); + axXl = get(w.FastSenseObj.hAxes, 'XLim'); + testCase.verifyEqual(xl, [axXl(1), axXl(2)], 'AbsTol', 1e-9); + end + + function testReflectsZoom(testCase) + %TESTREFLECTSZOOM Programmatic xlim() change is reflected (LIVE read). + w = testCase.makeRenderedWidget(); + xlim(w.FastSenseObj.hAxes, [2 5]); + drawnow; + testCase.verifyEqual(w.getCurrentXLim(), [2 5], 'AbsTol', 1e-9); + end + + function testNotEqualToDataExtentWhenZoomed(testCase) + %TESTNOTEQUALTODATAEXTENTWHENZOOMED Live view differs from data cache. + w = testCase.makeRenderedWidget(); + xlim(w.FastSenseObj.hAxes, [2 5]); + drawnow; + [dMin, dMax] = w.getTimeRange(); + testCase.verifyFalse(isequal(w.getCurrentXLim(), [dMin, dMax])); + end + + function testListenerSlotSetterAndDeleteNoThrow(testCase) + %TESTLISTENERSLOTSETTERANDDELETENOTHROW Engine slot setter + delete() are safe. + x = linspace(0, 10, 200); + y = sin(x); + w = FastSenseWidget('XData', x, 'YData', y); + hFig = figure('Visible', 'off'); + testCase.addTeardown(@() close(hFig)); + hp = uipanel('Parent', hFig, 'Position', [0 0 1 1]); + w.render(hp); + drawnow; + % The engine (Plan 03) hands a real addlistener handle here; storing + % an empty placeholder must not throw, and delete() must release it. + testCase.verifyWarningFree(@() w.setCurrentViewXLimListenerForEngine_([])); + testCase.verifyWarningFree(@() delete(w)); + end + + end +end diff --git a/tests/suite/TestTimeRangeSelectorCurrentView.m b/tests/suite/TestTimeRangeSelectorCurrentView.m new file mode 100644 index 00000000..4eab3994 --- /dev/null +++ b/tests/suite/TestTimeRangeSelectorCurrentView.m @@ -0,0 +1,170 @@ +classdef TestTimeRangeSelectorCurrentView < matlab.unittest.TestCase +%TESTTIMERANGESELECTORCURRENTVIEW Unit tests for the Phase 1039 current-view boxes. +% Covers TimeRangeSelector.setCurrentViews / setCurrentView / hideCurrentView: +% the per-graph box pool (patch + two dashed edge lines each), DataRange +% clamping, swapped-bound reordering, per-index palette colouring, the +% visually-distinct + non-interactive style, and the no-throw lifecycle after +% the underlying graphics are destroyed. + + methods (TestClassSetup) + function addPaths(testCase) %#ok + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (Access = private) + function sel = makeSelector(testCase) + %makeSelector Off-screen selector over DataRange [0 100]. + hFig = figure('Visible', 'off'); + testCase.addTeardown(@() close(hFig)); + hp = uipanel('Parent', hFig, 'Position', [0 0 1 1]); + sel = TimeRangeSelector(hp); + testCase.addTeardown(@() delete(sel)); + sel.setDataRange(0, 100); + end + end + + methods (Test) + function testSetCurrentViewDrawsVisibleBox(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(20, 60); + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 1, ... + 'A single box must exist after setCurrentView.'); + testCase.verifyTrue(ishandle(sel.hCurrentViewBoxes(1)), ... + 'Pooled box must be a valid handle after setCurrentView.'); + testCase.verifyEqual(char(get(sel.hCurrentViewBoxes(1), 'Visible')), 'on', ... + 'Box must become visible after setCurrentView.'); + testCase.verifyEqual(sel.CurrentViews, [20 60], ... + 'CurrentViews must store the requested [tStart tEnd] row.'); + end + + function testCurrentViewBoxGeometry(testCase) + % Compare orientation-agnostically: MATLAB and Octave disagree on + % whether patch/line XData/YData come back as rows or columns, so + % flatten each to a row before checking. + sel = testCase.makeSelector(); + sel.setCurrentView(20, 60); + boxX = reshape(get(sel.hCurrentViewBoxes(1), 'XData'), 1, []); + boxY = reshape(get(sel.hCurrentViewBoxes(1), 'YData'), 1, []); + leftX = reshape(get(sel.hCurrentViewEdgesL(1), 'XData'), 1, []); + rightX = reshape(get(sel.hCurrentViewEdgesR(1), 'XData'), 1, []); + testCase.verifyEqual(boxX, [20 20 60 60], ... + 'Box XData must follow the [xL xL xR xR] patch shape.'); + testCase.verifyEqual(boxY, [0 1 1 0], ... + 'Box YData must span the full [0 1 1 0] height.'); + testCase.verifyEqual(leftX, [20 20], ... + 'Left edge must sit at the box start.'); + testCase.verifyEqual(rightX, [60 60], ... + 'Right edge must sit at the box end.'); + end + + function testMultipleBoxesPerGraphColoured(testCase) + % Two out-of-sync graphs -> two SEPARATE boxes, each coloured from the + % shared preview palette by its colour index (box k matches preview + % line k). palette row 1 = blue [0 .45 .70], row 2 = orange [.90 .40 .20]. + sel = testCase.makeSelector(); + sel.setCurrentViews([10 30; 50 80], [1 2]); + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 2, ... + 'Two ranges must produce two boxes.'); + testCase.verifyEqual(size(sel.CurrentViews, 1), 2, ... + 'CurrentViews must hold two rows.'); + c1 = get(sel.hCurrentViewBoxes(1), 'FaceColor'); + c2 = get(sel.hCurrentViewBoxes(2), 'FaceColor'); + testCase.verifyEqual(c1, [0.00 0.45 0.70], 'AbsTol', 1e-6, ... + 'Box 1 must use preview-palette colour index 1 (blue).'); + testCase.verifyEqual(c2, [0.90 0.40 0.20], 'AbsTol', 1e-6, ... + 'Box 2 must use preview-palette colour index 2 (orange).'); + x1 = reshape(get(sel.hCurrentViewBoxes(1), 'XData'), 1, []); + x2 = reshape(get(sel.hCurrentViewBoxes(2), 'XData'), 1, []); + testCase.verifyEqual(x1, [10 10 30 30], 'Box 1 geometry must match its range.'); + testCase.verifyEqual(x2, [50 50 80 80], 'Box 2 geometry must match its range.'); + end + + function testBoxPoolShrinks(testCase) + % Going from two boxes to one must delete the surplus handle. + sel = testCase.makeSelector(); + sel.setCurrentViews([10 30; 50 80], [1 2]); + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 2); + sel.setCurrentViews([40 60], 3); + testCase.verifyEqual(numel(sel.hCurrentViewBoxes), 1, ... + 'Pool must shrink to one box.'); + c = get(sel.hCurrentViewBoxes(1), 'FaceColor'); + testCase.verifyEqual(c, [0.20 0.60 0.20], 'AbsTol', 1e-6, ... + 'Single remaining box must use palette index 3 (green).'); + end + + function testHideCurrentViewClearsPool(testCase) + sel = testCase.makeSelector(); + sel.setCurrentViews([10 30; 50 80], [1 2]); + sel.hideCurrentView(); + testCase.verifyEmpty(sel.hCurrentViewBoxes, ... + 'Box pool must be emptied after hideCurrentView.'); + testCase.verifyEmpty(sel.hCurrentViewEdgesL, ... + 'Left-edge pool must be emptied after hideCurrentView.'); + testCase.verifyEmpty(sel.hCurrentViewEdgesR, ... + 'Right-edge pool must be emptied after hideCurrentView.'); + testCase.verifyEmpty(sel.CurrentViews, ... + 'CurrentViews must be cleared after hideCurrentView.'); + end + + function testClampToDataRange(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(-50, 250); + testCase.verifyEqual(sel.CurrentViews, [0 100], ... + 'setCurrentView must clamp to DataRange [0 100].'); + end + + function testReordersSwappedBounds(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(70, 30); + testCase.verifyEqual(sel.CurrentViews, [30 70], ... + 'Swapped bounds must be reordered to [30 70].'); + end + + function testDistinctFromSelection(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(20, 60); + cvAlpha = get(sel.hCurrentViewBoxes(1), 'FaceAlpha'); + selAlpha = get(sel.hSelection, 'FaceAlpha'); + testCase.verifyEqual(cvAlpha, 0.12, ... + 'Current-view box FaceAlpha must be 0.12.'); + testCase.verifyEqual(selAlpha, 0.20, ... + 'Selection FaceAlpha must remain 0.20 (unchanged).'); + testCase.verifyNotEqual(cvAlpha, selAlpha, ... + 'Current-view box must be visually distinct from the Selection.'); + testCase.verifyEqual(char(get(sel.hCurrentViewBoxes(1), 'PickableParts')), 'none', ... + 'Current-view box must be non-pickable.'); + testCase.verifyEqual(char(get(sel.hCurrentViewBoxes(1), 'HitTest')), 'off', ... + 'Current-view box must not intercept mouse events.'); + end + + function testNoThrowAfterGraphicsDestroyed(testCase) + % Realistic "after delete" contract: the underlying figure (and every + % graphics handle the selector owns) is destroyed, but the + % TimeRangeSelector OBJECT itself is still a live handle. The API must + % no-op via its ishandle guards rather than throw. + % + % NOTE: calling a method on a *deleted* handle object (delete(sel) then + % sel.method()) is not testable — MATLAB throws "Invalid or deleted + % object" at dispatch. Destroying the graphics while keeping the object + % alive is the case the production guards are designed for. + hFig = figure('Visible', 'off'); + hp = uipanel('Parent', hFig, 'Position', [0 0 1 1]); + sel = TimeRangeSelector(hp); + testCase.addTeardown(@() delete(sel)); + sel.setDataRange(0, 100); + sel.setCurrentViews([10 20; 40 60], [1 2]); % boxes live before nuke + delete(hFig); % destroys axes + boxes + try + sel.setCurrentViews([30 40], 1); + sel.hideCurrentView(); + testCase.verifyTrue(true, ... + 'setCurrentViews/hideCurrentView must not throw after graphics destroyed.'); + catch err + testCase.verifyFail(sprintf( ... + 'Post-graphics-destroy API must not throw, but threw: %s', err.message)); + end + end + end +end