|
| 1 | +function [hFig, skippedNames] = openAdHocPlot(tags, mode, themePreset) |
| 2 | +%OPENADHOCPLOT Spawn an ad-hoc multi-tag plot as a live DashboardEngine. |
| 3 | +% [hFig, skippedNames] = openAdHocPlot(tags, mode, themePreset) |
| 4 | +% |
| 5 | +% Replaces the classical-figure path: every "Plot" click in the |
| 6 | +% companion now spawns a fresh DashboardEngine with live refresh |
| 7 | +% running. Mode controls layout: |
| 8 | +% - 'Overlay' — ONE RawAxesWidget; every tag drawn as an overlaid |
| 9 | +% line in a single axes. Live tick re-runs the |
| 10 | +% PlotFcn so lines update in place. |
| 11 | +% - 'LinkedGrid' — ONE FastSenseWidget per tag tiled in a 2-column |
| 12 | +% grid. Each widget has ShowEventMarkers=true and, |
| 13 | +% when the tag has a matching MonitorTag in the |
| 14 | +% registry, the monitor's EventStore is forwarded |
| 15 | +% so threshold events overlay on the plot. |
| 16 | +% |
| 17 | +% Inputs: |
| 18 | +% tags - 1xN cell of Tag handles (already resolved by caller). |
| 19 | +% Must contain >= 2 entries. |
| 20 | +% mode - char: 'Overlay' or 'LinkedGrid'. |
| 21 | +% themePreset - char: 'dark' or 'light'. |
| 22 | +% |
| 23 | +% Outputs: |
| 24 | +% hFig - DashboardEngine.hFigure (figure window the engine owns) |
| 25 | +% skippedNames - 1xM cellstr of skipped tag names (may be empty). |
| 26 | +% |
| 27 | +% Errors: |
| 28 | +% FastSenseCompanion:invalidPlotMode - mode unknown or numel(tags)<2 |
| 29 | +% FastSenseCompanion:plotSpawnFailed - all tags failed; no figure spawned |
| 30 | +% |
| 31 | +% Lifecycle: the engine starts its own live timer. The figure's |
| 32 | +% CloseRequestFcn stops live + deletes the figure so closing the |
| 33 | +% window cleans up. |
| 34 | +% |
| 35 | +% See also DashboardEngine, FastSenseWidget, RawAxesWidget, |
| 36 | +% FastSenseCompanion, TagRegistry. |
| 37 | + |
| 38 | + validModes = {'Overlay', 'LinkedGrid'}; |
| 39 | + if ~ischar(mode) || ~any(strcmp(mode, validModes)) |
| 40 | + error('FastSenseCompanion:invalidPlotMode', ... |
| 41 | + 'openAdHocPlot: mode must be one of: %s. Got: ''%s''.', ... |
| 42 | + strjoin(validModes, ', '), char(mode)); |
| 43 | + end |
| 44 | + if ~iscell(tags) || numel(tags) < 2 |
| 45 | + error('FastSenseCompanion:invalidPlotMode', ... |
| 46 | + 'openAdHocPlot: requires a cell of >= 2 tags. Got %d.', numel(tags)); |
| 47 | + end |
| 48 | + |
| 49 | + % Filter tags that have data. |
| 50 | + validTags = {}; |
| 51 | + validNames = {}; |
| 52 | + skippedNames = {}; |
| 53 | + for k = 1:numel(tags) |
| 54 | + tg = tags{k}; |
| 55 | + try |
| 56 | + nm = tg.Name; |
| 57 | + catch |
| 58 | + nm = sprintf('<tag %d>', k); |
| 59 | + end |
| 60 | + try |
| 61 | + [t, ~] = tg.getXY(); |
| 62 | + if isempty(t) |
| 63 | + skippedNames{end+1} = sprintf('%s (no data)', nm); %#ok<AGROW> |
| 64 | + continue; |
| 65 | + end |
| 66 | + validTags{end+1} = tg; %#ok<AGROW> |
| 67 | + validNames{end+1} = nm; %#ok<AGROW> |
| 68 | + catch ME |
| 69 | + skippedNames{end+1} = sprintf('%s (%s)', nm, ME.message); %#ok<AGROW> |
| 70 | + end |
| 71 | + end |
| 72 | + if isempty(validTags) |
| 73 | + error('FastSenseCompanion:plotSpawnFailed', ... |
| 74 | + 'openAdHocPlot: no tags produced data. Skipped: %s', ... |
| 75 | + strjoin(skippedNames, '; ')); |
| 76 | + end |
| 77 | + |
| 78 | + figName = buildFigureName_(validNames); |
| 79 | + |
| 80 | + engine = DashboardEngine(figName, ... |
| 81 | + 'Theme', themePreset, 'LiveInterval', 1.0); |
| 82 | + |
| 83 | + switch mode |
| 84 | + case 'Overlay' |
| 85 | + % One single widget: a RawAxesWidget that overlays every tag. |
| 86 | + % cla() runs in the widget's refresh, then PlotFcn redraws. |
| 87 | + engine.addWidget('rawaxes', ... |
| 88 | + 'Title', figName, ... |
| 89 | + 'PlotFcn', @(ax) plotOverlay_(ax, validTags, validNames), ... |
| 90 | + 'Position', [1 1 24 12]); |
| 91 | + |
| 92 | + case 'LinkedGrid' |
| 93 | + N = numel(validTags); |
| 94 | + cols = min(N, 2); |
| 95 | + rows = ceil(N / cols); |
| 96 | + unitW = max(1, floor(24 / cols)); |
| 97 | + unitH = max(1, floor(12 / rows)); |
| 98 | + for k = 1:N |
| 99 | + r = ceil(k / cols); |
| 100 | + c = mod(k - 1, cols) + 1; |
| 101 | + args = { ... |
| 102 | + 'Title', char(validNames{k}), ... |
| 103 | + 'Tag', validTags{k}, ... |
| 104 | + 'ShowEventMarkers', true, ... |
| 105 | + 'Position', [(c-1)*unitW + 1, (r-1)*unitH + 1, unitW, unitH]}; |
| 106 | + es = findEventStoreFor_(validTags{k}); |
| 107 | + if ~isempty(es) |
| 108 | + args = [args, {'EventStore', es}]; %#ok<AGROW> |
| 109 | + end |
| 110 | + engine.addWidget('fastsense', args{:}); |
| 111 | + end |
| 112 | + end |
| 113 | + |
| 114 | + engine.render(); |
| 115 | + engine.startLive(); |
| 116 | + |
| 117 | + hFig = engine.hFigure; |
| 118 | + if ~isempty(hFig) && ishandle(hFig) |
| 119 | + set(hFig, 'CloseRequestFcn', @(s, ~) closeFcn_(s, engine)); |
| 120 | + end |
| 121 | +end |
| 122 | + |
| 123 | +% --------------------------- helpers -------------------------------- |
| 124 | + |
| 125 | +function plotOverlay_(ax, tags, names) |
| 126 | +%PLOTOVERLAY_ Draw every tag as a line in the same axes; called on every refresh. |
| 127 | + hold(ax, 'on'); |
| 128 | + for k = 1:numel(tags) |
| 129 | + try |
| 130 | + [tv, y] = tags{k}.getXY(); |
| 131 | + if isempty(tv); continue; end |
| 132 | + plot(ax, tv, y, 'DisplayName', char(names{k}), 'LineWidth', 1.2); |
| 133 | + catch |
| 134 | + end |
| 135 | + end |
| 136 | + hold(ax, 'off'); |
| 137 | + try; legend(ax, 'show', 'Location', 'best'); catch; end |
| 138 | + grid(ax, 'on'); |
| 139 | + xlabel(ax, 'Time'); |
| 140 | +end |
| 141 | + |
| 142 | +function es = findEventStoreFor_(tag) |
| 143 | +%FINDEVENTSTOREFOR_ Locate an EventStore via a MonitorTag whose Parent.Key matches. |
| 144 | +% Returns [] when no matching monitor or no EventStore is registered. |
| 145 | + es = []; |
| 146 | + try |
| 147 | + if ~isobject(tag) || ~isvalid(tag) || ~isprop(tag, 'Key'); return; end |
| 148 | + monitors = TagRegistry.find(@(tt) isa(tt, 'MonitorTag') ... |
| 149 | + && ~isempty(tt.Parent) && isprop(tt.Parent, 'Key') ... |
| 150 | + && strcmp(tt.Parent.Key, tag.Key)); |
| 151 | + for k = 1:numel(monitors) |
| 152 | + m = monitors{k}; |
| 153 | + if isprop(m, 'EventStore') && ~isempty(m.EventStore) && isvalid(m.EventStore) |
| 154 | + es = m.EventStore; |
| 155 | + return; |
| 156 | + end |
| 157 | + end |
| 158 | + catch |
| 159 | + end |
| 160 | +end |
| 161 | + |
| 162 | +function closeFcn_(fig, engine) |
| 163 | +%CLOSEFCN_ Stop live + delete figure on close. |
| 164 | + try |
| 165 | + if ~isempty(engine) && isvalid(engine) && ismethod(engine, 'stopLive') |
| 166 | + engine.stopLive(); |
| 167 | + end |
| 168 | + catch |
| 169 | + end |
| 170 | + try; delete(fig); catch; end |
| 171 | +end |
| 172 | + |
| 173 | +function name = buildFigureName_(tagNames) |
| 174 | +%BUILDFIGURENAME_ Compose figure title with 80-char total truncation. |
| 175 | + prefix = 'FastSense Companion — '; |
| 176 | + maxTotal = 80; |
| 177 | + joined = strjoin(tagNames, ', '); |
| 178 | + full = [prefix, joined]; |
| 179 | + if numel(full) <= maxTotal |
| 180 | + name = full; |
| 181 | + return; |
| 182 | + end |
| 183 | + budget = maxTotal - numel(prefix) - 1; |
| 184 | + if budget < 1 |
| 185 | + name = [prefix, char(8230)]; |
| 186 | + return; |
| 187 | + end |
| 188 | + cut = joined(1:min(budget, numel(joined))); |
| 189 | + lastSep = max(strfind(cut, ', ')); |
| 190 | + if ~isempty(lastSep) && lastSep > 1 |
| 191 | + cut = cut(1:lastSep-1); |
| 192 | + end |
| 193 | + name = [prefix, cut, char(8230)]; |
| 194 | +end |
0 commit comments