Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions FastPlot.m
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ParentAxes = [] % axes handle, empty = create new
LinkGroup = '' % string ID for linked zoom/pan
Theme = [] % theme struct (from FastPlotTheme)
XType = 'numeric' % 'numeric' or 'datenum'
end

properties (SetAccess = private)
Expand Down Expand Up @@ -92,6 +93,14 @@ function addLine(obj, x, y, varargin)
if ~isrow(x); x = x(:)'; end
if ~isrow(y); y = y(:)'; end

% Detect and convert datetime input
if isa(x, 'datetime')
x = datenum(x);
if strcmp(obj.XType, 'numeric')
obj.XType = 'datenum';
end
end

% Validate sizes match
if numel(x) ~= numel(y)
error('FastPlot:sizeMismatch', ...
Expand Down Expand Up @@ -119,6 +128,13 @@ function addLine(obj, x, y, varargin)
val = varargin{k+1};
if strcmpi(key, 'DownsampleMethod')
dsMethod = val;
elseif strcmpi(key, 'XType')
if strcmp(obj.XType, 'numeric') || strcmp(obj.XType, val)
obj.XType = val;
else
error('FastPlot:mixedXType', ...
'All lines must use the same XType.');
end
else
opts.(key) = val;
end
Expand Down Expand Up @@ -585,6 +601,8 @@ function render(obj)
set(obj.hAxes, 'XLimMode', 'manual');
set(obj.hAxes, 'YLimMode', 'manual');

obj.updateDatetimeTicks();

obj.CachedXLim = get(obj.hAxes, 'XLim');

% --- Install listeners ---
Expand Down Expand Up @@ -661,6 +679,7 @@ function onXLimChanged(obj, ~, ~)
end

obj.drawnowLimitRate();
obj.updateDatetimeTicks();
end

function onResize(obj, ~, ~)
Expand Down Expand Up @@ -873,6 +892,27 @@ function updateViolations(obj)
end
end

function updateDatetimeTicks(obj)
if ~strcmp(obj.XType, 'datenum'); return; end
xlims = get(obj.hAxes, 'XLim');
xRange = xlims(2) - xlims(1); % in days

if xRange > 1
fmt = 'mmm dd HH:MM';
elseif xRange > 1/60 % > 1 minute
fmt = 'HH:MM';
else
fmt = 'HH:MM:SS';
end

ticks = get(obj.hAxes, 'XTick');
labels = cell(size(ticks));
for i = 1:numel(ticks)
labels{i} = datestr(ticks(i), fmt);
end
set(obj.hAxes, 'XTickLabel', labels);
end

function drawnowLimitRate(obj)
if isempty(obj.HasLimitRate)
obj.HasLimitRate = exist('OCTAVE_VERSION', 'builtin') == 0;
Expand Down
25 changes: 22 additions & 3 deletions FastPlotToolbar.m
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ function onCrosshairOff(obj)

function onMouseMove(obj)
if ~strcmp(obj.Mode, 'crosshair'); return; end
[~, ax] = obj.getActiveTarget();
[fp, ax] = obj.getActiveTarget();
if isempty(ax)
obj.hideCrosshairLines();
return;
Expand Down Expand Up @@ -225,7 +225,8 @@ function onMouseMove(obj)
set(obj.hCrosshairV, 'Parent', ax, 'XData', [xp xp], 'YData', ylims);
set(obj.hCrosshairTxt, 'Parent', ax, ...
'Position', [xlims(2), ylims(2), 0], ...
'String', sprintf('x=%.4g y=%.4g', xp, yp));
'String', sprintf('x=%s y=%.4g', ...
FastPlotToolbar.formatX(xp, obj.getXType(fp)), yp));
end
end

Expand All @@ -247,7 +248,8 @@ function onMouseClick(obj)
'LineStyle', 'none', 'Marker', 'o', 'MarkerSize', 8, ...
'Color', lineColor, 'MarkerFaceColor', lineColor, ...
'HandleVisibility', 'off', 'HitTest', 'off');
label = sprintf('(%.4g, %.4g)', sx, sy);
label = sprintf('(%s, %.4g)', ...
FastPlotToolbar.formatX(sx, obj.getXType(fp)), sy);
obj.hCursorTxt = text(sx, sy, label, 'Parent', ax, ...
'FontSize', 8, 'VerticalAlignment', 'bottom', ...
'HorizontalAlignment', 'left', ...
Expand Down Expand Up @@ -292,6 +294,14 @@ function cleanupCursor(obj)
end

methods (Access = private)
function xtype = getXType(~, fp)
if ~isempty(fp) && isprop(fp, 'XType')
xtype = fp.XType;
else
xtype = 'numeric';
end
end

function onToggleGrid(obj)
[~, ax] = obj.getActiveTarget();
if isempty(ax)
Expand Down Expand Up @@ -399,6 +409,15 @@ function onExportPNG(obj)
end

methods (Static)
function str = formatX(xVal, xtype)
%FORMATX Format an X value for display based on XType.
if strcmp(xtype, 'datenum')
str = datestr(xVal, 'mmm dd HH:MM:SS');
else
str = sprintf('%.4g', xVal);
end
end

function icon = makeIcon(name)
%MAKEICON Generate a 16x16x3 RGB icon for toolbar buttons.
icon = ones(16, 16, 3) * 0.94; % light gray background
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ tb.setCrosshair(true); % enable crosshair mode
tb.setCursor(true); % enable data cursor mode
```

### Datetime Axes

Pass `datenum` values as X data with `'XType', 'datenum'` to get auto-formatted date/time tick labels:

```matlab
x = datenum(2024,1,1) + (0:99999)/86400; % 1-second resolution
y = sin((1:100000) * 2*pi/3600);

fp = FastPlot();
fp.addLine(x, y, 'XType', 'datenum');
fp.render();
```

Tick labels auto-adapt to zoom level: `Jan 15 10:00` when zoomed out, `10:30:15` when zoomed in. The toolbar crosshair and data cursor also display datetime values.

In MATLAB, you can also pass `datetime` objects directly β€” they are auto-converted to `datenum`:

```matlab
dt = datetime(2024,1,1) + hours(0:999);
fp.addLine(dt, y); % XType set automatically
```

## Installation

```bash
Expand Down
72 changes: 72 additions & 0 deletions docs/plans/2026-03-07-datetime-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# FastPlot Datetime Support β€” Design

## Goal

Add datetime-aware X axis display to FastPlot. Users pass `datenum` values (or MATLAB `datetime` objects, auto-converted) and get human-readable date/time tick labels that adapt to zoom level.

## Architecture

### Input

- `fp.addLine(x, y, 'XType', 'datenum')` β€” explicit opt-in, X is datenum doubles
- `fp.addLine(x, y)` where `x` is MATLAB `datetime` β€” auto-detected, converted to `datenum` via `datenum(x)`, `XType` set automatically

### Internal Pipeline

**No changes.** `datenum` values are regular doubles. Binary search, MinMax/LTTB downsampling, MEX accelerators, zoom/pan callbacks, pyramid levels β€” all work unchanged.

### Storage

- New field in `Lines` struct: `XType` β€” `'numeric'` (default) or `'datenum'`
- New property on `FastPlot`: `XType` β€” set from first line that declares `'datenum'`, enforced consistent across all lines on same axes

### Tick Formatting

Custom tick formatter installed on axes when `XType == 'datenum'`. Format auto-selected based on visible X range:

| Visible range | Format | Example |
|---|---|---|
| > 1 day | `'mmm dd HH:MM'` | `Jan 15 10:00` |
| 1 hour – 1 day | `'HH:MM'` | `10:00` |
| 1 min – 1 hour | `'HH:MM'` | `10:30` |
| < 1 min | `'HH:MM:SS'` | `10:30:15` |

Tick formatter re-runs on every zoom/pan (inside `onXLimChanged`).

### Toolbar Display

- **Crosshair text:** `datestr(xp, 'mmm dd HH:MM:SS')` instead of `sprintf('x=%.4g', xp)`
- **Data cursor label:** `datestr(sx, 'mmm dd HH:MM:SS')` instead of `sprintf('(%.4g, %.4g)', sx, sy)`
- Toolbar checks `fp.XType` (or first `FastPlots{i}.XType`) to decide formatting

### FastPlotFigure

Each tile can independently have `XType == 'datenum'` or `'numeric'`. No figure-level setting needed β€” it inherits from the FastPlot instances.

## What Changes

| File | Change |
|---|---|
| `FastPlot.m` | Add `XType` property; detect datetime input in `addLine`; install tick formatter in `render`; update ticks in `onXLimChanged` |
| `FastPlotToolbar.m` | Format crosshair/cursor display based on `XType` |
| `tests/test_toolbar.m` | Add datetime formatting tests |
| `tests/test_datetime.m` | New: datenum axes, tick formatting, datetime auto-conversion |
| `examples/example_toolbar.m` | Add datetime demo section |
| `README.md` | Document datetime usage |

## What Does NOT Change

- `binary_search.m` / MEX
- `minmax_downsample.m` / MEX
- `lttb_downsample.m` / MEX
- `compute_violations.m`
- Zoom/pan pipeline (internally)
- `FastPlotFigure.m`
- `FastPlotTheme.m`

## Compatibility

- Works in both GNU Octave and MATLAB
- Uses `datenum`/`datestr` (available in both)
- MATLAB `datetime` input auto-converted via `datenum()`
- `isdatetime()` check guarded with `exist('isdatetime')` for Octave compatibility
Loading