From 08d562c81eaad3761650ba98dac53a9ab578bd31 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 19:52:30 +0200 Subject: [PATCH 01/14] feat(1039-01): add CurrentViewBoxColor theme token to both presets - dark: amber [0.95 0.62 0.20], contrasts bluish-gray Selection - light: dark amber [0.85 0.45 0.05], contrasts dark-blue Selection - per-preset (mirrors MarkerPlantLog) so light/dark can differ --- libs/Dashboard/DashboardTheme.m | 2 ++ 1 file changed, 2 insertions(+) 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) From 68ce5fa19ef5fdc5fd08b3e61feae6cb0f99ddf3 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 19:53:59 +0200 Subject: [PATCH 02/14] feat(1039-02): add FastSenseWidget.getCurrentXLim() + engine-owned XLim listener slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getCurrentXLim() returns the LIVE wrapped-FastSense axes XLim ([min max]) read via get(ax,'XLim'), or [] when not rendered — distinct from the getTimeRange() data-extent cache - CurrentViewXLimListener_ engine-owned listener slot + Hidden setCurrentViewXLimListenerForEngine_ setter (mirrors PlantLogXLimListener_) - delete() releases the listener before FastSenseObj teardown (try/catch) --- libs/Dashboard/FastSenseWidget.m | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m index c49674ef..b5a43dae 100644 --- a/libs/Dashboard/FastSenseWidget.m +++ b/libs/Dashboard/FastSenseWidget.m @@ -90,6 +90,7 @@ % {?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 (Access = private, Constant) @@ -484,6 +485,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 +723,36 @@ 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 = []; + 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 +1206,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 From 57df9216ba8211c3b7f7b2491cdb20b6cb40521c Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 19:54:05 +0200 Subject: [PATCH 03/14] feat(1039-01): add current-view box to TimeRangeSelector - hCurrentViewBox patch + hCurrentViewLeft/Right dashed edge lines - distinct style: FaceAlpha 0.12, LineStyle '--' LineWidth 1, theme CurrentViewBoxColor with amber fallback; PickableParts/HitTest off - setCurrentView(tStart,tEnd): validate finite, reorder, clamp to DataRange, store CurrentView, redraw, make visible (no-throw guarded) - hideCurrentView(): clear CurrentView, NaN data, Visible off - redraw_ refreshes box geometry when CurrentView is non-empty - header doc updated (Properties/Methods/CurrentView) --- libs/Dashboard/TimeRangeSelector.m | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/libs/Dashboard/TimeRangeSelector.m b/libs/Dashboard/TimeRangeSelector.m index 07abdf74..0ec5ac24 100644 --- a/libs/Dashboard/TimeRangeSelector.m +++ b/libs/Dashboard/TimeRangeSelector.m @@ -31,14 +31,22 @@ % % Properties (read-only, set internally): % hPanel, hFigure, hAxes, hEnvelope, hSelection, hEdgeLeft, hEdgeRight + % hCurrentViewBox, hCurrentViewLeft, hCurrentViewRight (Phase 1039: + % the visually-distinct "current view" box marking the + % plots' live x-limits; hidden unless setCurrentView is + % called — see DashboardEngine.updateCurrentViewIndicator_) % DataRange 1x2 [tMin tMax]. % Selection 1x2 [tStart tEnd]. + % CurrentView 1x2 [tStart tEnd] of the current-view 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]. + % setCurrentView(tStart, tEnd) Show the (non-interactive) current-view + % box at [tStart, tEnd], clamped to DataRange. + % hideCurrentView() Hide the current-view box. % setEnvelope(xC, yMin, yMax) Update or hide aggregate envelope. % delete() Restore saved figure callbacks. % @@ -69,6 +77,10 @@ hSelection = [] % patch for selection rectangle hEdgeLeft = [] % line: left drag handle hEdgeRight = [] % line: right drag handle + hCurrentViewBox = [] % Phase 1039: patch for the current-view box (out-of-sync plot x-limits) + hCurrentViewLeft = [] % Phase 1039: left edge line of the current-view box + hCurrentViewRight = [] % Phase 1039: right edge line of the current-view box + CurrentView = [] % Phase 1039: 1x2 [tStart tEnd] of the current-view box, or [] when hidden 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 +227,46 @@ function setSelection(obj, tStart, tEnd) tEnd = obj.Selection(2); end + function setCurrentView(obj, tStart, tEnd) + %setCurrentView Show the current-view box at [tStart, tEnd] (data-time). + % Phase 1039. Clamps to DataRange (same shape as setSelection), + % reorders swapped bounds, stores CurrentView, redraws, and makes + % the box + edge lines visible. Purely indicative — does NOT fire + % OnRangeChanged and does NOT touch the Selection. No-throw if the + % handles are not yet created (pre-render) or already deleted. + if nargin < 3 || isempty(tStart) || isempty(tEnd) || ... + ~isfinite(tStart) || ~isfinite(tEnd) + return; + end + if tStart > tEnd + tmp = tStart; tStart = tEnd; tEnd = tmp; + end + tStart = max(tStart, obj.DataRange(1)); + tEnd = min(tEnd, obj.DataRange(2)); + obj.CurrentView = [tStart tEnd]; + obj.redraw_(); + if ishandle(obj.hCurrentViewBox), set(obj.hCurrentViewBox, 'Visible', 'on'); end + if ishandle(obj.hCurrentViewLeft), set(obj.hCurrentViewLeft, 'Visible', 'on'); end + if ishandle(obj.hCurrentViewRight), set(obj.hCurrentViewRight, 'Visible', 'on'); end + end + + function hideCurrentView(obj) + %hideCurrentView Hide the current-view box. + % Phase 1039. Clears CurrentView, sets the three handles Visible + % off, and NaNs their data so a stale geometry never flashes. Safe + % to call before render / after delete (handles guarded). + obj.CurrentView = []; + if ishandle(obj.hCurrentViewBox) + set(obj.hCurrentViewBox, 'XData', NaN, 'YData', NaN, 'Visible', 'off'); + end + if ishandle(obj.hCurrentViewLeft) + set(obj.hCurrentViewLeft, 'XData', [NaN NaN], 'YData', [0 1], 'Visible', 'off'); + end + if ishandle(obj.hCurrentViewRight) + set(obj.hCurrentViewRight, 'XData', [NaN NaN], 'YData', [0 1], 'Visible', 'off'); + end + end + function setRangeLabels(obj, leftText, rightText, middleText) %setRangeLabels Update the date/time labels shown BELOW the slider. % Updates three labels: @@ -813,6 +865,27 @@ function buildGraphics_(obj) obj.hEdgeRight = line(obj.hAxes, [NaN NaN], [0 1], ... 'Color', selColor, 'LineWidth', 2, ... 'HitTest', 'off', 'PickableParts', 'none'); + % Phase 1039 — current-view box: a SECOND, visually-distinct box marking + % the plots' current/latest x-limits when they are NOT synced with the + % Selection. Lower alpha + dashed thinner edges + a contrasting color so + % it never reads as the (dominant) Selection rectangle. Purely indicative: + % PickableParts='none' / HitTest='off' — only the Selection is interactive. + % Created hidden; the engine (DashboardEngine.updateCurrentViewIndicator_) + % calls setCurrentView / hideCurrentView to drive visibility. + cvColor = [0.90 0.55 0.15]; % amber fallback if theme lacks the token + if isstruct(obj.Theme) && isfield(obj.Theme, 'CurrentViewBoxColor') + cvColor = obj.Theme.CurrentViewBoxColor; + end + obj.hCurrentViewBox = patch(obj.hAxes, NaN, NaN, cvColor, ... + 'FaceAlpha', 0.12, 'EdgeColor', 'none', ... + 'HitTest', 'off', 'PickableParts', 'none', ... + 'Tag', 'TimeRangeSelectorCurrentView', 'Visible', 'off'); + obj.hCurrentViewLeft = line(obj.hAxes, [NaN NaN], [0 1], ... + 'Color', cvColor, 'LineWidth', 1, 'LineStyle', '--', ... + 'HitTest', 'off', 'PickableParts', 'none', 'Visible', 'off'); + obj.hCurrentViewRight = line(obj.hAxes, [NaN NaN], [0 1], ... + 'Color', cvColor, 'LineWidth', 1, 'LineStyle', '--', ... + 'HitTest', 'off', 'PickableParts', 'none', 'Visible', 'off'); % 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 +1098,22 @@ 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 — redraw the current-view box if one is active. Same + % [xL xL xR xR]/[0 1 1 0] patch shape as the Selection; same data-time + % space. Hidden state is owned by hideCurrentView (this only refreshes + % geometry when CurrentView is non-empty). + if ~isempty(obj.CurrentView) && numel(obj.CurrentView) == 2 + cL = obj.CurrentView(1); cR = obj.CurrentView(2); + if ishandle(obj.hCurrentViewBox) + set(obj.hCurrentViewBox, 'XData', [cL cL cR cR], 'YData', [0 1 1 0]); + end + if ishandle(obj.hCurrentViewLeft) + set(obj.hCurrentViewLeft, 'XData', [cL cL], 'YData', [0 1]); + end + if ishandle(obj.hCurrentViewRight) + set(obj.hCurrentViewRight, 'XData', [cR cR], 'YData', [0 1]); + end + 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 From 70d0ae1779b68f4c7e7edaeb2330e93835edeb7f Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 19:55:20 +0200 Subject: [PATCH 04/14] test(1039-02): cover getCurrentXLim live-view-vs-cache semantics - testEmptyBeforeRender: [] before render - testReturnsLiveXLimAfterRender: 1x2 == live axes XLim - testReflectsZoom: tracks programmatic xlim([2 5]) (reads LIVE axes) - testNotEqualToDataExtentWhenZoomed: live view differs from getTimeRange cache - testListenerSlotSetterAndDeleteNoThrow: engine slot setter + delete() are safe --- tests/suite/TestFastSenseWidgetCurrentXLim.m | 97 ++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/suite/TestFastSenseWidgetCurrentXLim.m 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 From 1691763999527607ff5a5f404b3eb21a1bf6d084 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 19:56:11 +0200 Subject: [PATCH 05/14] test(1039-01): add TestTimeRangeSelectorCurrentView suite - 7 class-based tests: visible-box, geometry, hide, clamp, reorder, distinct-style/non-interactive, no-throw-after-delete - off-screen figure factory mirrors TestTimeRangeSelectorEventMarkers - geometry asserts are orientation-agnostic (MATLAB/Octave parity) --- .../suite/TestTimeRangeSelectorCurrentView.m | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/suite/TestTimeRangeSelectorCurrentView.m diff --git a/tests/suite/TestTimeRangeSelectorCurrentView.m b/tests/suite/TestTimeRangeSelectorCurrentView.m new file mode 100644 index 00000000..4365dbeb --- /dev/null +++ b/tests/suite/TestTimeRangeSelectorCurrentView.m @@ -0,0 +1,125 @@ +classdef TestTimeRangeSelectorCurrentView < matlab.unittest.TestCase +%TESTTIMERANGESELECTORCURRENTVIEW Unit tests for the Phase 1039 current-view box. +% Covers TimeRangeSelector.setCurrentView / hideCurrentView: the box +% graphics (patch + two dashed edge lines), DataRange clamping, swapped- +% bound reordering, the visually-distinct + non-interactive style, and the +% no-throw lifecycle when called after the selector is deleted. + + methods (TestClassSetup) + function addPaths(testCase) + 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.verifyTrue(ishandle(sel.hCurrentViewBox), ... + 'hCurrentViewBox must be a valid handle after setCurrentView.'); + testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... + 'Box must become visible after setCurrentView.'); + testCase.verifyEqual(sel.CurrentView, [20 60], ... + 'CurrentView must store the requested [tStart tEnd].'); + 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 (mirrors the robust pattern + % in TestTimeRangeSelectorEventMarkers). + sel = testCase.makeSelector(); + sel.setCurrentView(20, 60); + boxX = reshape(get(sel.hCurrentViewBox, 'XData'), 1, []); + boxY = reshape(get(sel.hCurrentViewBox, 'YData'), 1, []); + leftX = reshape(get(sel.hCurrentViewLeft, 'XData'), 1, []); + rightX = reshape(get(sel.hCurrentViewRight, '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 testHideCurrentViewHidesBox(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(20, 60); + sel.hideCurrentView(); + testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... + 'Box must be hidden after hideCurrentView.'); + testCase.verifyEqual(char(get(sel.hCurrentViewLeft, 'Visible')), 'off', ... + 'Left edge must be hidden after hideCurrentView.'); + testCase.verifyEqual(char(get(sel.hCurrentViewRight, 'Visible')), 'off', ... + 'Right edge must be hidden after hideCurrentView.'); + testCase.verifyEmpty(sel.CurrentView, ... + 'CurrentView must be cleared after hideCurrentView.'); + end + + function testClampToDataRange(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(-50, 250); + testCase.verifyEqual(sel.CurrentView, [0 100], ... + 'setCurrentView must clamp to DataRange [0 100].'); + end + + function testReordersSwappedBounds(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(70, 30); + testCase.verifyEqual(sel.CurrentView, [30 70], ... + 'Swapped bounds must be reordered to [30 70].'); + end + + function testDistinctFromSelection(testCase) + sel = testCase.makeSelector(); + sel.setCurrentView(20, 60); + cvAlpha = get(sel.hCurrentViewBox, '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.hCurrentViewBox, 'PickableParts')), 'none', ... + 'Current-view box must be non-pickable.'); + testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'HitTest')), 'off', ... + 'Current-view box must not intercept mouse events.'); + end + + function testNoThrowAfterDelete(testCase) + % Dedicated selector with its own figure so the explicit delete() + % is the path under test (no shared teardown delete). + hFig = figure('Visible', 'off'); + testCase.addTeardown(@() close(hFig)); + hp = uipanel('Parent', hFig, 'Position', [0 0 1 1]); + sel = TimeRangeSelector(hp); + sel.setDataRange(0, 100); + delete(sel); + try + sel.setCurrentView(10, 20); + sel.hideCurrentView(); + testCase.verifyTrue(true, ... + 'setCurrentView/hideCurrentView must not throw after delete.'); + catch err + testCase.verifyFail(sprintf( ... + 'Post-delete API must not throw, but threw: %s', err.message)); + end + end + end +end From e63daf6bca269a20d31491724d6a1810993d3f75 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:02:19 +0200 Subject: [PATCH 06/14] test(1039-01): fix impossible post-delete contract in current-view test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testNoThrowAfterDelete asserted that calling setCurrentView/hideCurrentView after delete(selector) does not throw — impossible in MATLAB, which throws "Invalid or deleted object" at method dispatch on a deleted handle before any in-method guard runs. Replaced with testNoThrowAfterGraphicsDestroyed: destroy the underlying figure (killing all graphics handles) while the selector object stays alive, then call the API — exercises the real ishandle guards (redraw_ + each box handle). Wave 1 now: TimeRangeSelector 7/7, FastSenseWidget 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suite/TestTimeRangeSelectorCurrentView.m | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/suite/TestTimeRangeSelectorCurrentView.m b/tests/suite/TestTimeRangeSelectorCurrentView.m index 4365dbeb..6651f6b7 100644 --- a/tests/suite/TestTimeRangeSelectorCurrentView.m +++ b/tests/suite/TestTimeRangeSelectorCurrentView.m @@ -102,23 +102,34 @@ function testDistinctFromSelection(testCase) 'Current-view box must not intercept mouse events.'); end - function testNoThrowAfterDelete(testCase) - % Dedicated selector with its own figure so the explicit delete() - % is the path under test (no shared teardown delete). + function testNoThrowAfterGraphicsDestroyed(testCase) + % Realistic "after delete" contract: the underlying figure (and + % therefore every graphics handle the selector owns) is destroyed, + % but the TimeRangeSelector OBJECT itself is still a live handle. + % setCurrentView/hideCurrentView must no-op via their 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, before any in-method guard can run. + % Destroying the graphics while keeping the object alive is the + % case the production guards (ishandle on hAxes + each box handle) + % are actually designed for. 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); - delete(sel); + sel.setCurrentView(10, 20); % box live before we nuke the figure + delete(hFig); % destroys axes + all box graphics try - sel.setCurrentView(10, 20); + sel.setCurrentView(30, 40); sel.hideCurrentView(); testCase.verifyTrue(true, ... - 'setCurrentView/hideCurrentView must not throw after delete.'); + 'setCurrentView/hideCurrentView must not throw after graphics destroyed.'); catch err testCase.verifyFail(sprintf( ... - 'Post-delete API must not throw, but threw: %s', err.message)); + 'Post-graphics-destroy API must not throw, but threw: %s', err.message)); end end end From ba08b2cae98bb7181e1feafad4c07543e4eb892b Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:07:07 +0200 Subject: [PATCH 07/14] feat(1039-03): add current-view indicator engine methods + test seam - updateCurrentViewIndicator_ (private): collects out-of-sync widget XLims via getCurrentXLim, unions them, shows/hides the slider current-view box per the epsilon-vs-Selection rule (0.005*span) - attachCurrentViewXLimListener_ (Hidden): engine-owned XLim PostSet listener mirroring attachPlantLogXLimListener_ (idempotent, Octave-skip, try/catch, namespaced DashboardEngine:currentViewIndicatorFailed) - updateCurrentViewIndicatorForTest_ (Hidden seam): routes to the private method so Wave 3 integration tests drive the decision on Octave too Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/Dashboard/DashboardEngine.m | 92 ++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index b5f07cf4..a765de0d 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -2751,6 +2751,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 +3009,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.updateCurrentViewIndicator_()); + 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 +3228,59 @@ function removeDetachedByRef(obj, mirrorHolder) obj.DetachedMirrors = obj.DetachedMirrors(keep); end + function updateCurrentViewIndicator_(obj) + %UPDATECURRENTVIEWINDICATOR_ Drive the slider current-view box (Phase 1039). + % Collects the LIVE x-limits of every out-of-sync widget + % (UseGlobalTime==false) on the active page (recursing into + % GroupWidgets), unions them, and shows the box at that union ONLY + % when it differs from the current Selection beyond an epsilon + % scaled to the DataRange span. When everything is synced (or no + % widget is out of sync), the box is hidden. Purely indicative — + % never touches the Selection or fires broadcasts. Fully guarded: + % no-op when the slider is absent. Wrapped by callers in try/catch. + sel = obj.TimeRangeSelector_; + if isempty(sel) || ~isa(sel, 'TimeRangeSelector') + return; + end + ws = obj.flattenWidgetsForPreview_(obj.activePageWidgets()); + starts = []; + ends = []; + for i = 1:numel(ws) + w = ws{i}; + % Only FastSenseWidgets expose getCurrentXLim + UseGlobalTime as + % the out-of-sync signal. isa guard skips mixed widget lists + % (StatusWidget, GaugeWidget, ...) cleanly. + if ~isa(w, 'FastSenseWidget'), continue; end + if w.UseGlobalTime, continue; end % synced widget — ignore + xl = []; + try xl = w.getCurrentXLim(); catch, xl = []; end + if isempty(xl) || numel(xl) ~= 2 || ~all(isfinite(xl)), continue; end + starts(end + 1) = xl(1); %#ok + ends(end + 1) = xl(2); %#ok + end + if isempty(starts) + sel.hideCurrentView(); % nothing out of sync + return; + end + uStart = min(starts); + uEnd = max(ends); + % Epsilon scaled to the data span (reuse MinWidthFrac-style 0.005) + % so sub-pixel float noise between a synced widget XLim and the + % Selection does not flicker the box on/off. + span = obj.DataTimeRange(2) - obj.DataTimeRange(1); + if ~isfinite(span) || span <= 0 + span = max(abs(uEnd - uStart), 1); + end + eps_ = 0.005 * span; + [selStart, selEnd] = sel.getSelection(); + differs = (abs(uStart - selStart) > eps_) || (abs(uEnd - selEnd) > eps_); + if differs + sel.setCurrentView(uStart, uEnd); + else + sel.hideCurrentView(); % union matches Selection — synced + 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 From 2e356343d3ef7ec63e4252e9a42546bd331d7e80 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:08:45 +0200 Subject: [PATCH 08/14] feat(1039-03): wire current-view indicator to its 4 hook sites - render() tail: attach a current-view XLim listener to every FastSenseWidget across all pages (SITE 1; Octave-skipped in the helper) - broadcastTimeRange tail: recompute indicator so a re-sync hides the box (SITE 2) - onLiveTick tail: recompute each tick so drift in/out of sync updates without user interaction (SITE 3) - switchPage tail: recompute for the newly active page (SITE 4) All calls appended + try/catch wrapped; sync/Selection semantics of broadcastTimeRange, onLiveTick, and switchPage are unchanged (additive only). mh_lint/style/metric clean; Octave render+seam smoke passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/Dashboard/DashboardEngine.m | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index a765de0d..e90c3d1a 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -353,6 +353,8 @@ function switchPage(obj, pageIdx) try obj.computePlantLogMarkers(); catch err if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end 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 +531,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 +2038,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) @@ -2209,6 +2227,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) From 52d318654dc6858cccb4ce2c861cb36e219c8acf Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:32:00 +0200 Subject: [PATCH 09/14] test(1039-04): integration suite + demo for slider current-view box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/suite/TestDashboardCurrentViewIndicator.m (6 tests): all-synced hidden, one-zoomed visible at window, two-zoomed union, re-sync hides, sub-epsilon stays hidden, no-selector guard no-throw. Drives the engine via updateCurrentViewIndicatorForTest_. - examples/demo_current_view_box.m: 3-widget dashboard, top plot pre-zoomed so the amber current-view box shows on load; interaction instructions. - FastSenseWidget: add CurrentXLimOverrideForTest_ Hidden seam — getCurrentXLim returns it verbatim when set. FastSense rebuilds its axes during zoom re-resolve, so a raw programmatic xlim() poke is not durable under the unittest runner's event flushing; the override makes the integration test deterministic. The real axes<->getCurrentXLim path stays covered by TestFastSenseWidgetCurrentXLim. Empty default => zero production impact. Verified (matlab-MCP authoritative): TimeRangeSelector 7/7, FastSenseWidget 5/5, Dashboard integration 6/6 = 18/18. Real interactive zoom confirmed live: getCurrentXLim tracks the zoomed window, box shows it. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/demo_current_view_box.m | 64 +++++ libs/Dashboard/FastSenseWidget.m | 19 ++ .../suite/TestDashboardCurrentViewIndicator.m | 227 ++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 examples/demo_current_view_box.m create mode 100644 tests/suite/TestDashboardCurrentViewIndicator.m diff --git a/examples/demo_current_view_box.m b/examples/demo_current_view_box.m new file mode 100644 index 00000000..988676cd --- /dev/null +++ b/examples/demo_current_view_box.m @@ -0,0 +1,64 @@ +%% Demo: Lower-slider "current view" box (Phase 1039) +% The existing lower preview slider (TimeRangeSelector) keeps its draggable +% SELECTION window exactly as before. Phase 1039 adds a second, smaller, +% amber "current view" box that marks where the plots are ACTUALLY looking +% right now — and it only appears when a plot's x-limits are NOT synchronized +% with the slider selection (i.e. you zoomed/panned one plot directly). +% +% This demo renders a 3-widget dashboard and pre-zooms the top plot to an +% interior window so the amber current-view box is already visible in the +% lower slider on load. +% +% Try this in the figure: +% 1. Lower slider: the wide translucent rectangle is the SELECTION; the +% smaller amber box is the CURRENT VIEW of the zoomed (top) plot. +% 2. Zoom/pan the MIDDLE or BOTTOM plot (scroll / toolbar) — its window +% joins the amber box (the box spans the union of out-of-sync plots). +% 3. Drag the slider SELECTION to match a plot, or press the dashboard's +% "Sync all" — once every plot is back in sync, the amber 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 the top plot so the amber current-view box shows on load +% (Real interactive zoom does exactly this — here we drive it programmatically +% so the box is visible immediately.) +wTop = d.Widgets{1}; +try + xlim(wTop.FastSenseObj.hAxes, [120 240]); % zoom Temperature to 120..240 s + wTop.UseGlobalTime = false; % detach it from the slider selection +catch +end +drawnow; +% Refresh the indicator now (normally fired by the 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(' Top plot zoomed : 120 .. 240 s -> amber CURRENT-VIEW box in the lower slider\n'); +fprintf('\nInteract:\n'); +fprintf(' - Zoom/pan another plot -> amber box grows to span all out-of-sync views\n'); +fprintf(' - Drag slider selection / Sync all to re-sync -> amber box disappears\n'); +fprintf(' - The wide translucent rectangle is the SELECTION (unchanged behavior)\n'); diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m index b5a43dae..e5c30206 100644 --- a/libs/Dashboard/FastSenseWidget.m +++ b/libs/Dashboard/FastSenseWidget.m @@ -93,6 +93,17 @@ 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) % PREVIEWRAWTHRESHOLD_ Sample-count threshold below which % getPreviewSeries skips downsampling and renders one bucket @@ -735,6 +746,14 @@ function onXLimChanged(obj) % 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; diff --git a/tests/suite/TestDashboardCurrentViewIndicator.m b/tests/suite/TestDashboardCurrentViewIndicator.m new file mode 100644 index 00000000..42354a8d --- /dev/null +++ b/tests/suite/TestDashboardCurrentViewIndicator.m @@ -0,0 +1,227 @@ +classdef TestDashboardCurrentViewIndicator < matlab.unittest.TestCase +%TESTDASHBOARDCURRENTVIEWINDICATOR End-to-end integration suite for the Phase 1039 current-view box. +% 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's hCurrentViewBox becomes visible at the zoomed +% widget's XLim union; re-syncing (broadcastTimeRange) hides it; an all-synced +% dashboard never shows it. +% +% This asserts the observable truths of the phase goal against the INTEGRATED +% stack from Plans 01-03: +% - Plan 01: TimeRangeSelector.hCurrentViewBox + setCurrentView/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 -> box hidden, CurrentView empty +% testBoxAppearsWhenWidgetZoomed -> one zoomed -> box visible at that window +% testBoxHidesAfterResync -> broadcastTimeRange re-sync -> box hidden +% testUnionOfTwoOutOfSyncWidgets -> two zoomed -> box spans the union +% testSubEpsilonDifferenceStaysHidden -> sub-epsilon delta from Selection -> hidden +% 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.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... + 'All-synced dashboard must keep the current-view box hidden.'); + testCase.verifyEmpty(sel.CurrentView, ... + 'CurrentView 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(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... + 'Zooming a widget out of sync must surface the current-view box.'); + testCase.verifyEqual(sel.CurrentView, [20 40], 'AbsTol', 0.1, ... + 'CurrentView must match the zoomed widget''s XLim window.'); + boxX = reshape(get(sel.hCurrentViewBox, '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(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... + 'Precondition: box must be visible 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 box must hide. + w1.UseGlobalTime = true; + d.broadcastTimeRange(20, 40); + drawnow; + + testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... + 'Re-syncing via broadcastTimeRange must hide the current-view box.'); + testCase.verifyEmpty(sel.CurrentView, ... + 'CurrentView must be empty after re-sync.'); + end + + function testUnionOfTwoOutOfSyncWidgets(testCase) + % Two widgets zoomed to disjoint windows: the box spans their UNION + % (min start, max end). + [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(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... + 'Two out-of-sync widgets must surface the current-view box.'); + testCase.verifyEqual(sel.CurrentView, [10 80], 'AbsTol', 0.1, ... + 'CurrentView must be the union [min(starts) max(ends)] of both windows.'); + 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.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... + 'A sub-epsilon difference from the Selection must NOT show the box.'); + testCase.verifyEmpty(sel.CurrentView, ... + 'CurrentView 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 From 13a9d9f5ab085d0a6fdf4d5cace2c121332f3d14 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:49:53 +0200 Subject: [PATCH 10/14] feat(1039): per-graph current-view boxes coloured by preview line Change from a single union box to ONE box per visible, out-of-sync graph, each coloured to match that graph's slider preview line (per user request). - TimeRangeSelector: replace single hCurrentViewBox with a reused handle POOL (hCurrentViewBoxes/EdgesL/EdgesR). New setCurrentViews(ranges, colorIdxs) draws N boxes; setCurrentView is a back-compat single-box wrapper; hideCurrentView clears the pool. Palette extracted to shared previewPalette_ used by BOTH preview lines and current-view boxes, so box k matches preview line k exactly. redraw_ refreshes all boxes. - DashboardEngine.updateCurrentViewIndicator_: collect per-out-of-sync-widget (range, preview-line index) instead of a union; show each box only when that graph's view differs from the Selection (epsilon 0.005*span); colour index = the graph's position among valid-preview widgets (mirrors linesList order). - Tests: TimeRangeSelector 9/9 (added multi-box + pool-shrink + colour tests), Dashboard integration 6/6 (union test -> two-separate-boxes-with-distinct- colours). FastSenseWidget 5/5 unchanged. 20 tests total, MISS_HIT clean. - Demo: pre-zooms two plots to different windows -> two differently-coloured boxes on load; instructions updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/demo_current_view_box.m | 61 ++-- libs/Dashboard/DashboardEngine.m | 93 ++++--- libs/Dashboard/TimeRangeSelector.m | 263 ++++++++++++------ .../suite/TestDashboardCurrentViewIndicator.m | 68 +++-- .../suite/TestTimeRangeSelectorCurrentView.m | 128 +++++---- 5 files changed, 393 insertions(+), 220 deletions(-) diff --git a/examples/demo_current_view_box.m b/examples/demo_current_view_box.m index 988676cd..69ac5da3 100644 --- a/examples/demo_current_view_box.m +++ b/examples/demo_current_view_box.m @@ -1,21 +1,23 @@ -%% Demo: Lower-slider "current view" box (Phase 1039) +%% 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 a second, smaller, -% amber "current view" box that marks where the plots are ACTUALLY looking -% right now — and it only appears when a plot's x-limits are NOT synchronized -% with the slider selection (i.e. you zoomed/panned one plot directly). +% 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 the top plot to an -% interior window so the amber current-view box is already visible in the -% lower slider on load. +% 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 -% smaller amber box is the CURRENT VIEW of the zoomed (top) plot. -% 2. Zoom/pan the MIDDLE or BOTTOM plot (scroll / toolbar) — its window -% joins the amber box (the box spans the union of out-of-sync plots). -% 3. Drag the slider SELECTION to match a plot, or press the dashboard's -% "Sync all" — once every plot is back in sync, the amber box disappears. +% 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; @@ -42,23 +44,30 @@ d.addWidget('fastsense', 'Position', [1 13 24 6], 'Tag', sFlow); d.render(); -%% 3. Pre-zoom the top plot so the amber current-view box shows on load +%% 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 box is visible immediately.) -wTop = d.Widgets{1}; +% 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(wTop.FastSenseObj.hAxes, [120 240]); % zoom Temperature to 120..240 s - wTop.UseGlobalTime = false; % detach it from the slider selection + 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 -drawnow; -% Refresh the indicator now (normally fired by the widget's XLim listener). +% 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(' Top plot zoomed : 120 .. 240 s -> amber CURRENT-VIEW box in the lower slider\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 another plot -> amber box grows to span all out-of-sync views\n'); -fprintf(' - Drag slider selection / Sync all to re-sync -> amber box disappears\n'); -fprintf(' - The wide translucent rectangle is the SELECTION (unchanged behavior)\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/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index e90c3d1a..bdad2854 100644 --- a/libs/Dashboard/DashboardEngine.m +++ b/libs/Dashboard/DashboardEngine.m @@ -3250,55 +3250,78 @@ function removeDetachedByRef(obj, mirrorHolder) end function updateCurrentViewIndicator_(obj) - %UPDATECURRENTVIEWINDICATOR_ Drive the slider current-view box (Phase 1039). - % Collects the LIVE x-limits of every out-of-sync widget - % (UseGlobalTime==false) on the active page (recursing into - % GroupWidgets), unions them, and shows the box at that union ONLY - % when it differs from the current Selection beyond an epsilon - % scaled to the DataRange span. When everything is synced (or no - % widget is out of sync), the box is hidden. Purely indicative — - % never touches the Selection or fires broadcasts. Fully guarded: - % no-op when the slider is absent. Wrapped by callers in try/catch. + %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. sel = obj.TimeRangeSelector_; if isempty(sel) || ~isa(sel, 'TimeRangeSelector') return; end ws = obj.flattenWidgetsForPreview_(obj.activePageWidgets()); - starts = []; - ends = []; + % 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}; - % Only FastSenseWidgets expose getCurrentXLim + UseGlobalTime as - % the out-of-sync signal. isa guard skips mixed widget lists - % (StatusWidget, GaugeWidget, ...) cleanly. + % 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 widget — ignore + 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 - starts(end + 1) = xl(1); %#ok - ends(end + 1) = xl(2); %#ok + % 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(starts) + if isempty(ranges) sel.hideCurrentView(); % nothing out of sync - return; - end - uStart = min(starts); - uEnd = max(ends); - % Epsilon scaled to the data span (reuse MinWidthFrac-style 0.005) - % so sub-pixel float noise between a synced widget XLim and the - % Selection does not flicker the box on/off. - span = obj.DataTimeRange(2) - obj.DataTimeRange(1); - if ~isfinite(span) || span <= 0 - span = max(abs(uEnd - uStart), 1); - end - eps_ = 0.005 * span; - [selStart, selEnd] = sel.getSelection(); - differs = (abs(uStart - selStart) > eps_) || (abs(uEnd - selEnd) > eps_); - if differs - sel.setCurrentView(uStart, uEnd); else - sel.hideCurrentView(); % union matches Selection — synced + sel.setCurrentViews(ranges, colorIdxs); end end diff --git a/libs/Dashboard/TimeRangeSelector.m b/libs/Dashboard/TimeRangeSelector.m index 0ec5ac24..1a9bf3f0 100644 --- a/libs/Dashboard/TimeRangeSelector.m +++ b/libs/Dashboard/TimeRangeSelector.m @@ -31,22 +31,25 @@ % % Properties (read-only, set internally): % hPanel, hFigure, hAxes, hEnvelope, hSelection, hEdgeLeft, hEdgeRight - % hCurrentViewBox, hCurrentViewLeft, hCurrentViewRight (Phase 1039: - % the visually-distinct "current view" box marking the - % plots' live x-limits; hidden unless setCurrentView is - % called — see DashboardEngine.updateCurrentViewIndicator_) + % 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]. - % CurrentView 1x2 [tStart tEnd] of the current-view box, or [] when hidden. + % 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]. - % setCurrentView(tStart, tEnd) Show the (non-interactive) current-view - % box at [tStart, tEnd], clamped to DataRange. - % hideCurrentView() Hide the current-view box. + % 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. % @@ -77,10 +80,11 @@ hSelection = [] % patch for selection rectangle hEdgeLeft = [] % line: left drag handle hEdgeRight = [] % line: right drag handle - hCurrentViewBox = [] % Phase 1039: patch for the current-view box (out-of-sync plot x-limits) - hCurrentViewLeft = [] % Phase 1039: left edge line of the current-view box - hCurrentViewRight = [] % Phase 1039: right edge line of the current-view box - CurrentView = [] % Phase 1039: 1x2 [tStart tEnd] of the current-view box, or [] when hidden + 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 @@ -228,43 +232,66 @@ function setSelection(obj, tStart, tEnd) end function setCurrentView(obj, tStart, tEnd) - %setCurrentView Show the current-view box at [tStart, tEnd] (data-time). - % Phase 1039. Clamps to DataRange (same shape as setSelection), - % reorders swapped bounds, stores CurrentView, redraws, and makes - % the box + edge lines visible. Purely indicative — does NOT fire - % OnRangeChanged and does NOT touch the Selection. No-throw if the - % handles are not yet created (pre-render) or already deleted. + %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 - if tStart > tEnd - tmp = tStart; tStart = tEnd; tEnd = tmp; + 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 - tStart = max(tStart, obj.DataRange(1)); - tEnd = min(tEnd, obj.DataRange(2)); - obj.CurrentView = [tStart tEnd]; - obj.redraw_(); - if ishandle(obj.hCurrentViewBox), set(obj.hCurrentViewBox, 'Visible', 'on'); end - if ishandle(obj.hCurrentViewLeft), set(obj.hCurrentViewLeft, 'Visible', 'on'); end - if ishandle(obj.hCurrentViewRight), set(obj.hCurrentViewRight, 'Visible', 'on'); 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 the current-view box. - % Phase 1039. Clears CurrentView, sets the three handles Visible - % off, and NaNs their data so a stale geometry never flashes. Safe - % to call before render / after delete (handles guarded). - obj.CurrentView = []; - if ishandle(obj.hCurrentViewBox) - set(obj.hCurrentViewBox, 'XData', NaN, 'YData', NaN, 'Visible', 'off'); - end - if ishandle(obj.hCurrentViewLeft) - set(obj.hCurrentViewLeft, 'XData', [NaN NaN], 'YData', [0 1], 'Visible', 'off'); - end - if ishandle(obj.hCurrentViewRight) - set(obj.hCurrentViewRight, 'XData', [NaN NaN], 'YData', [0 1], 'Visible', 'off'); - end + %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) @@ -320,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) @@ -823,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 @@ -865,27 +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 box: a SECOND, visually-distinct box marking - % the plots' current/latest x-limits when they are NOT synced with the - % Selection. Lower alpha + dashed thinner edges + a contrasting color so - % it never reads as the (dominant) Selection rectangle. Purely indicative: - % PickableParts='none' / HitTest='off' — only the Selection is interactive. - % Created hidden; the engine (DashboardEngine.updateCurrentViewIndicator_) - % calls setCurrentView / hideCurrentView to drive visibility. - cvColor = [0.90 0.55 0.15]; % amber fallback if theme lacks the token - if isstruct(obj.Theme) && isfield(obj.Theme, 'CurrentViewBoxColor') - cvColor = obj.Theme.CurrentViewBoxColor; - end - obj.hCurrentViewBox = patch(obj.hAxes, NaN, NaN, cvColor, ... - 'FaceAlpha', 0.12, 'EdgeColor', 'none', ... - 'HitTest', 'off', 'PickableParts', 'none', ... - 'Tag', 'TimeRangeSelectorCurrentView', 'Visible', 'off'); - obj.hCurrentViewLeft = line(obj.hAxes, [NaN NaN], [0 1], ... - 'Color', cvColor, 'LineWidth', 1, 'LineStyle', '--', ... - 'HitTest', 'off', 'PickableParts', 'none', 'Visible', 'off'); - obj.hCurrentViewRight = line(obj.hAxes, [NaN NaN], [0 1], ... - 'Color', cvColor, 'LineWidth', 1, 'LineStyle', '--', ... - 'HitTest', 'off', 'PickableParts', 'none', 'Visible', 'off'); + % 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") @@ -1098,21 +1201,11 @@ 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 — redraw the current-view box if one is active. Same - % [xL xL xR xR]/[0 1 1 0] patch shape as the Selection; same data-time - % space. Hidden state is owned by hideCurrentView (this only refreshes - % geometry when CurrentView is non-empty). - if ~isempty(obj.CurrentView) && numel(obj.CurrentView) == 2 - cL = obj.CurrentView(1); cR = obj.CurrentView(2); - if ishandle(obj.hCurrentViewBox) - set(obj.hCurrentViewBox, 'XData', [cL cL cR cR], 'YData', [0 1 1 0]); - end - if ishandle(obj.hCurrentViewLeft) - set(obj.hCurrentViewLeft, 'XData', [cL cL], 'YData', [0 1]); - end - if ishandle(obj.hCurrentViewRight) - set(obj.hCurrentViewRight, 'XData', [cR cR], 'YData', [0 1]); - end + % 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 — diff --git a/tests/suite/TestDashboardCurrentViewIndicator.m b/tests/suite/TestDashboardCurrentViewIndicator.m index 42354a8d..5b84aac6 100644 --- a/tests/suite/TestDashboardCurrentViewIndicator.m +++ b/tests/suite/TestDashboardCurrentViewIndicator.m @@ -108,10 +108,10 @@ function testBoxHiddenWhenAllSynced(testCase) d.updateCurrentViewIndicatorForTest_(); - testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... - 'All-synced dashboard must keep the current-view box hidden.'); - testCase.verifyEmpty(sel.CurrentView, ... - 'CurrentView must be empty when nothing is out of sync.'); + 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) @@ -126,11 +126,13 @@ function testBoxAppearsWhenWidgetZoomed(testCase) testCase.zoomWidget_(w1, 20, 40); d.updateCurrentViewIndicatorForTest_(); - testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... - 'Zooming a widget out of sync must surface the current-view box.'); - testCase.verifyEqual(sel.CurrentView, [20 40], 'AbsTol', 0.1, ... - 'CurrentView must match the zoomed widget''s XLim window.'); - boxX = reshape(get(sel.hCurrentViewBox, 'XData'), 1, []); + 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 @@ -147,25 +149,29 @@ function testBoxHidesAfterResync(testCase) w1 = d.Widgets{1}; testCase.zoomWidget_(w1, 20, 40); d.updateCurrentViewIndicatorForTest_(); - testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... - 'Precondition: box must be visible before re-sync.'); + 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 box must hide. + % 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.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... - 'Re-syncing via broadcastTimeRange must hide the current-view box.'); - testCase.verifyEmpty(sel.CurrentView, ... - 'CurrentView must be empty after re-sync.'); + 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 testUnionOfTwoOutOfSyncWidgets(testCase) - % Two widgets zoomed to disjoint windows: the box spans their UNION - % (min start, max 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, ... @@ -177,10 +183,18 @@ function testUnionOfTwoOutOfSyncWidgets(testCase) testCase.zoomWidget_(w2, 50, 80); d.updateCurrentViewIndicatorForTest_(); - testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... - 'Two out-of-sync widgets must surface the current-view box.'); - testCase.verifyEqual(sel.CurrentView, [10 80], 'AbsTol', 0.1, ... - 'CurrentView must be the union [min(starts) max(ends)] of both windows.'); + 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 testSubEpsilonDifferenceStaysHidden(testCase) @@ -199,10 +213,10 @@ function testSubEpsilonDifferenceStaysHidden(testCase) testCase.zoomWidget_(w1, selStart + 0.1, selEnd - 0.1); d.updateCurrentViewIndicatorForTest_(); - testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... - 'A sub-epsilon difference from the Selection must NOT show the box.'); - testCase.verifyEmpty(sel.CurrentView, ... - 'CurrentView must stay empty for a sub-epsilon difference.'); + 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) diff --git a/tests/suite/TestTimeRangeSelectorCurrentView.m b/tests/suite/TestTimeRangeSelectorCurrentView.m index 6651f6b7..4eab3994 100644 --- a/tests/suite/TestTimeRangeSelectorCurrentView.m +++ b/tests/suite/TestTimeRangeSelectorCurrentView.m @@ -1,12 +1,13 @@ classdef TestTimeRangeSelectorCurrentView < matlab.unittest.TestCase -%TESTTIMERANGESELECTORCURRENTVIEW Unit tests for the Phase 1039 current-view box. -% Covers TimeRangeSelector.setCurrentView / hideCurrentView: the box -% graphics (patch + two dashed edge lines), DataRange clamping, swapped- -% bound reordering, the visually-distinct + non-interactive style, and the -% no-throw lifecycle when called after the selector is deleted. +%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) + function addPaths(testCase) %#ok addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); install(); end @@ -28,25 +29,26 @@ function addPaths(testCase) function testSetCurrentViewDrawsVisibleBox(testCase) sel = testCase.makeSelector(); sel.setCurrentView(20, 60); - testCase.verifyTrue(ishandle(sel.hCurrentViewBox), ... - 'hCurrentViewBox must be a valid handle after setCurrentView.'); - testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'on', ... + 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.CurrentView, [20 60], ... - 'CurrentView must store the requested [tStart tEnd].'); + 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 (mirrors the robust pattern - % in TestTimeRangeSelectorEventMarkers). + % flatten each to a row before checking. sel = testCase.makeSelector(); sel.setCurrentView(20, 60); - boxX = reshape(get(sel.hCurrentViewBox, 'XData'), 1, []); - boxY = reshape(get(sel.hCurrentViewBox, 'YData'), 1, []); - leftX = reshape(get(sel.hCurrentViewLeft, 'XData'), 1, []); - rightX = reshape(get(sel.hCurrentViewRight, 'XData'), 1, []); + 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], ... @@ -57,38 +59,73 @@ function testCurrentViewBoxGeometry(testCase) 'Right edge must sit at the box end.'); end - function testHideCurrentViewHidesBox(testCase) + 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.setCurrentView(20, 60); + 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.verifyEqual(char(get(sel.hCurrentViewBox, 'Visible')), 'off', ... - 'Box must be hidden after hideCurrentView.'); - testCase.verifyEqual(char(get(sel.hCurrentViewLeft, 'Visible')), 'off', ... - 'Left edge must be hidden after hideCurrentView.'); - testCase.verifyEqual(char(get(sel.hCurrentViewRight, 'Visible')), 'off', ... - 'Right edge must be hidden after hideCurrentView.'); - testCase.verifyEmpty(sel.CurrentView, ... - 'CurrentView must be cleared after 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.CurrentView, [0 100], ... + 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.CurrentView, [30 70], ... + 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.hCurrentViewBox, 'FaceAlpha'); + cvAlpha = get(sel.hCurrentViewBoxes(1), 'FaceAlpha'); selAlpha = get(sel.hSelection, 'FaceAlpha'); testCase.verifyEqual(cvAlpha, 0.12, ... 'Current-view box FaceAlpha must be 0.12.'); @@ -96,37 +133,34 @@ function testDistinctFromSelection(testCase) '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.hCurrentViewBox, 'PickableParts')), 'none', ... + testCase.verifyEqual(char(get(sel.hCurrentViewBoxes(1), 'PickableParts')), 'none', ... 'Current-view box must be non-pickable.'); - testCase.verifyEqual(char(get(sel.hCurrentViewBox, 'HitTest')), 'off', ... + 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 - % therefore every graphics handle the selector owns) is destroyed, - % but the TimeRangeSelector OBJECT itself is still a live handle. - % setCurrentView/hideCurrentView must no-op via their ishandle - % guards rather than throw. + % 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, before any in-method guard can run. - % Destroying the graphics while keeping the object alive is the - % case the production guards (ishandle on hAxes + each box handle) - % are actually designed for. + % 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.setCurrentView(10, 20); % box live before we nuke the figure - delete(hFig); % destroys axes + all box graphics + sel.setCurrentViews([10 20; 40 60], [1 2]); % boxes live before nuke + delete(hFig); % destroys axes + boxes try - sel.setCurrentView(30, 40); + sel.setCurrentViews([30 40], 1); sel.hideCurrentView(); testCase.verifyTrue(true, ... - 'setCurrentView/hideCurrentView must not throw after graphics destroyed.'); + '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)); From e5e109c204e9399fab0bc037a845447d6e1c6e63 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:50:53 +0200 Subject: [PATCH 11/14] docs(1039): record per-graph coloured-box refinement Update CONTEXT decision (union -> one coloured box per out-of-sync graph, matching each graph's preview-line colour), HUMAN-UAT items, and the integration test header/coverage doc to the per-graph design. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suite/TestDashboardCurrentViewIndicator.m | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/suite/TestDashboardCurrentViewIndicator.m b/tests/suite/TestDashboardCurrentViewIndicator.m index 5b84aac6..10142810 100644 --- a/tests/suite/TestDashboardCurrentViewIndicator.m +++ b/tests/suite/TestDashboardCurrentViewIndicator.m @@ -1,15 +1,15 @@ classdef TestDashboardCurrentViewIndicator < matlab.unittest.TestCase -%TESTDASHBOARDCURRENTVIEWINDICATOR End-to-end integration suite for the Phase 1039 current-view box. +%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's hCurrentViewBox becomes visible at the zoomed -% widget's XLim union; re-syncing (broadcastTimeRange) hides it; an all-synced -% dashboard never shows it. +% 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.hCurrentViewBox + setCurrentView/hideCurrentView +% - Plan 01: TimeRangeSelector box pool + setCurrentViews/hideCurrentView % - Plan 02: FastSenseWidget.getCurrentXLim + UseGlobalTime out-of-sync signal % - Plan 03: DashboardEngine.updateCurrentViewIndicator_ + the test seam % @@ -20,12 +20,12 @@ % flipped it, so the explicit set is a no-op). % % Coverage: -% testBoxHiddenWhenAllSynced -> all-synced -> box hidden, CurrentView empty -% testBoxAppearsWhenWidgetZoomed -> one zoomed -> box visible at that window -% testBoxHidesAfterResync -> broadcastTimeRange re-sync -> box hidden -% testUnionOfTwoOutOfSyncWidgets -> two zoomed -> box spans the union -% testSubEpsilonDifferenceStaysHidden -> sub-epsilon delta from Selection -> hidden -% testNoSelectorGuardNoThrow -> engine without a slider -> seam no-throw +% 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 +% testSubEpsilonDifferenceStaysHidden -> sub-epsilon delta from Selection -> no box +% testNoSelectorGuardNoThrow -> engine without a slider -> seam no-throw properties Engines = {} From e116e6d23d1e1292ec12b0c1e7bf6ee6131290dc Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:55:48 +0200 Subject: [PATCH 12/14] test(1039): multi-tab coverage for current-view boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestDashboardCurrentViewIndicator: add testPageSwitchScopesBoxesToActivePage — builds a 2-page dashboard, zooms a graph on each page, and asserts boxes follow the ACTIVE page across REAL switchPage calls (P1 box clears on switch to P2, P2 box shows, P1 box restored on switch back). Verifies the indicator scopes to activePageWidgets() and the switchPage wiring (Plan 03 SITE 4) auto-refreshes — no manual seam call after the switches. 7/7 integration. - examples/demo_current_view_box_multitab.m: live 2-page ("Process"/"Utilities") demo, one plot pre-zoomed per page; boxes track the active tab. Phase 1039 totals: TimeRangeSelector 9 + FastSenseWidget 5 + Dashboard 7 = 21. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/demo_current_view_box_multitab.m | 75 +++++++++++++++++++ .../suite/TestDashboardCurrentViewIndicator.m | 58 ++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 examples/demo_current_view_box_multitab.m diff --git a/examples/demo_current_view_box_multitab.m b/examples/demo_current_view_box_multitab.m new file mode 100644 index 00000000..dbc7164b --- /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 +% (Real interactive zoom does the same; we drive it programmatically here.) +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]); + wTemp.UseGlobalTime = false; +catch +end +% Page 2 is inactive (not realized yet); set its out-of-sync window via the +% live axes if available, else it will surface when the tab is first shown and +% the plot is zoomed interactively. +try + if ~isempty(wFlow.FastSenseObj) && wFlow.FastSenseObj.IsRendered + xlim(wFlow.FastSenseObj.hAxes, [380 500]); + end + wFlow.UseGlobalTime = false; +catch +end +for s = 1:4; drawnow; pause(0.05); end +try d.updateCurrentViewIndicatorForTest_(); catch, 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/tests/suite/TestDashboardCurrentViewIndicator.m b/tests/suite/TestDashboardCurrentViewIndicator.m index 10142810..e3ab046f 100644 --- a/tests/suite/TestDashboardCurrentViewIndicator.m +++ b/tests/suite/TestDashboardCurrentViewIndicator.m @@ -24,6 +24,7 @@ % 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 @@ -197,6 +198,63 @@ function testTwoOutOfSyncProduceSeparateBoxes(testCase) '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 — From 4cf66144769d679a266360649a177c02d8ec08da Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 21:06:10 +0200 Subject: [PATCH 13/14] fix(1039): current-view boxes now work on non-first tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs made the boxes never appear on a second tab: 1. Missing listener on later-realized pages. The current-view XLim listener was attached only during render(), but widgets on pages other than page 1 aren't realized then (no axes) so the attach silently skipped them, and nothing re-attached when the tab opened. Fix: re-attach the active page's (now-realized) FastSenseWidgets in switchPage, and also attach in onScrollRealize for below-the-fold widgets. attachCurrentViewXLimListener_ is idempotent + skips unrealized widgets, so repeats are safe. 2. Listener fired mid-rebuild. FastSense re-resolves/rebuilds its axes on a zoom, firing the XLim listener multiple times; the box could be left hidden on a transient read even though the final view is zoomed. Fix: debounce — the listener now restarts a 0.15s one-shot timer (CurrentViewDebounceTimer_, mirrors SliderDebounceTimer) so the box refreshes ONCE after FastSense settles, reading the final getCurrentXLim. Timer cleaned up alongside the other debounce timers; updateCurrentViewIndicator_ guards isObjValid_ for the post-delete fire. Verified: 2-page dashboard, live-zoom (listener+debounce, no manual seam) on BOTH tabs surfaces the correct box. TimeRangeSelector 9/9, Dashboard 7/7 (incl. multi-tab), FastSenseWidget 5/5. MISS_HIT clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/demo_current_view_box_multitab.m | 20 +++---- libs/Dashboard/DashboardEngine.m | 64 ++++++++++++++++++++++- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/examples/demo_current_view_box_multitab.m b/examples/demo_current_view_box_multitab.m index dbc7164b..52fa1352 100644 --- a/examples/demo_current_view_box_multitab.m +++ b/examples/demo_current_view_box_multitab.m @@ -45,26 +45,26 @@ d.render(); %% 3. Pre-zoom one plot on EACH page so a box is waiting on both tabs -% (Real interactive zoom does the same; we drive it programmatically here.) +% 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]); + xlim(wTemp.FastSenseObj.hAxes, [120 240]); % zoom Temperature on page 1 wTemp.UseGlobalTime = false; catch end -% Page 2 is inactive (not realized yet); set its out-of-sync window via the -% live axes if available, else it will surface when the tab is first shown and -% the plot is zoomed interactively. +d.switchPage(2); % realize page 2 widgets +for s = 1:3; drawnow; pause(0.05); end try - if ~isempty(wFlow.FastSenseObj) && wFlow.FastSenseObj.IsRendered - xlim(wFlow.FastSenseObj.hAxes, [380 500]); - end + xlim(wFlow.FastSenseObj.hAxes, [380 500]); % zoom Flow on page 2 wFlow.UseGlobalTime = false; catch end -for s = 1:4; drawnow; pause(0.05); end -try d.updateCurrentViewIndicatorForTest_(); 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'); diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m index bdad2854..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,21 @@ 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 @@ -2100,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; @@ -2599,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 @@ -3052,7 +3079,7 @@ function attachCurrentViewXLimListener_(obj, widget) end try lis = addlistener(ax, 'XLim', 'PostSet', ... - @(~,~) obj.updateCurrentViewIndicator_()); + @(~,~) obj.scheduleCurrentViewRefresh_()); widget.setCurrentViewXLimListenerForEngine_(lis); catch err warning('DashboardEngine:currentViewIndicatorFailed', ... @@ -3249,6 +3276,40 @@ 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 @@ -3266,6 +3327,7 @@ function updateCurrentViewIndicator_(obj) % 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; From bab6b20327a0199d542191ecd8d6ba6d0362dc07 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Mon, 1 Jun 2026 10:42:00 +0200 Subject: [PATCH 14/14] style(1039): mh_style whitespace_colon in TimeRangeSelector loops CI runs mh_style (stricter than mh_lint); 4 'no whitespace around colon' issues in the new ensureCurrentViewHandles_/deleteCurrentViewHandles_ for loops. Auto-fixed via mh_style --fix. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/Dashboard/TimeRangeSelector.m | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/Dashboard/TimeRangeSelector.m b/libs/Dashboard/TimeRangeSelector.m index 1a9bf3f0..34cdaaee 100644 --- a/libs/Dashboard/TimeRangeSelector.m +++ b/libs/Dashboard/TimeRangeSelector.m @@ -863,7 +863,7 @@ function ensureCurrentViewHandles_(obj, n) % repeat updates). No-op without a valid axes. if ~ishandle(obj.hAxes), return; end cur = numel(obj.hCurrentViewBoxes); - for k = cur + 1 : n + 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', ... @@ -883,13 +883,13 @@ function ensureCurrentViewHandles_(obj, n) 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) + 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) + 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) + for k = keep + 1:numel(obj.hCurrentViewEdgesR) if ishandle(obj.hCurrentViewEdgesR(k)), delete(obj.hCurrentViewEdgesR(k)); end end if keep <= 0