Skip to content

DateTime axes: improve support for inverted axes #4851

@sunwayking

Description

@sunwayking

Issue:
If the axis is marked as inverted and its TickGenerator property is set to DateTimeAutomatic, no ticks will be generated.
I found that DateTimeAutomatic::Regenerate(...) can't deal good with inverted axis. If the axis is inverted, parameter range is inverted and range.Span is negative. Negative range.Span leads to the problem.
I figure out a quick fix here:

// DateTimeAutomatic.cs
public class DateTimeAutomatic : IDateTimeTickGenerator
{
  // ...
  public void Regenerate(CoordinateRange range, Edge edge, PixelLength size, SKPaint paint, LabelStyle labelStyle)
    {
        var rangeSpan = Math.Abs(range.Span); //patch: solving the problem about negative value dule to inverted axis 
        if (rangeSpan >= TimeSpan.MaxValue.Days || double.IsNaN(rangeSpan) || double.IsInfinity(rangeSpan))
        {
            // cases of extreme zoom (10,000 years)
            Ticks = [];
            return;
        }

        TimeSpan span = TimeSpan.FromDays(rangeSpan);
        ITimeUnit? timeUnit = GetAppropriateTimeUnit(span);

        // estimate the size of the largest tick label for this unit this unit
        int maxExpectedTickLabelWidth = (int)Math.Max(16, span.TotalDays / MaxTickCount);
        int tickLabelHeight = 12;
        PixelSize tickLabelBounds = new(maxExpectedTickLabelWidth, tickLabelHeight);
        double coordinatesPerPixel = rangeSpan / size.Length;

        while (true)
        {
            // determine the ideal spacing to use between ticks
            double increment = coordinatesPerPixel * tickLabelBounds.Width / timeUnit.MinSize.TotalDays;
            int? niceIncrement = LeastMemberGreaterThan(increment, timeUnit.Divisors);
            if (niceIncrement is null)
            {
                timeUnit = TheseTimeUnits.FirstOrDefault(t => t.MinSize > timeUnit.MinSize);
                if (timeUnit is not null)
                    continue;
                timeUnit = TheseTimeUnits.Last();
                niceIncrement = (int)Math.Ceiling(increment);
            }

            TimeUnit = timeUnit;

            // attempt to generate the ticks given these conditions
            (List<Tick>? ticks, PixelSize? largestTickLabelSize) = GenerateTicks(range, timeUnit, niceIncrement.Value, tickLabelBounds, paint, labelStyle);

            // if ticks were returned, use them
            if (ticks is not null)
            {
                Ticks = [.. ticks];
                return;
            }

            // if no ticks were returned it means the conditions were too dense and tick labels
            // overlapped, so expand the tick label bounds and try again.
            if (largestTickLabelSize is not null)
            {
                tickLabelBounds = tickLabelBounds.Max(largestTickLabelSize.Value);
                tickLabelBounds = new PixelSize(tickLabelBounds.Width + 10, tickLabelBounds.Height + 10);
                continue;
            }

            throw new InvalidOperationException($"{nameof(ticks)} and {nameof(largestTickLabelSize)} are both null");
        }
    }
  // ...
}

ScottPlot Version:
5.0.54

Code Sample:

MainWindow.axaml.cs

using System;
using System.Linq;
using System.Net.Sockets;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using ScottPlot;
using ScottPlot.Avalonia;
using ScottPlot.Colormaps;
using ScottPlot.Plottables;
using ScottPlot.TickGenerators;
using Range = ScottPlot.Range;

namespace scottHeatmapTest.Views;

public partial class MainWindow : Window
{
    private AvaPlot _avaPlot; // ScottPlot 控件
    private Heatmap _heatmap; // 热力图对象
    private double[,] _data; // 热力图数据
    private const int DataWidth = 100; // 距离轴数据点数量
    private const int DataHeight = 100; // 时间轴数据点数量
    private int _currentIndex = DataHeight - 1; // 当前数据行索引(从最下方开始)
    private string[] _timeLabels = new string[DataHeight]; // 存储每一行的时间标签

    private readonly Crosshair _crosshair;
    // private Task _task;

    public MainWindow()
    {
        InitializeComponent();

        // 获取 AvaPlot 控件
        _avaPlot = this.FindControl<AvaPlot>("AvaPlot");

        // 初始化数据
        _data = new double[DataHeight, DataWidth];

        // 初始化热力图
        _heatmap = _avaPlot.Plot.Add.Heatmap(_data); // ScottPlot 5.x 的 API 变化
        _heatmap.ManualRange = new Range(0, 255);
        _heatmap.Colormap = new ScottPlot.Colormaps.Turbo(); // 设置颜色映射
        _heatmap.CellHeight = DateTime.MinValue.AddSeconds(5).ToOADate() - DateTime.MinValue.ToOADate();
        _avaPlot.Plot.Axes.SetLimitsY(bottom: 1.5, top: -1.5);
        _avaPlot.Plot.Axes.AutoScaler.InvertedY = true;
        _avaPlot.Plot.Axes.Left.TickGenerator = new MyDateTimeAutomatic();
        // 设置横轴和纵轴标签
        _avaPlot.Plot.Axes.Title.Label.Text = "Heatmap Example";
        _avaPlot.Plot.Axes.Bottom.Label.Text = "Distance (m)";
        _avaPlot.Plot.Axes.Left.Label.Text = "Time";

        // 十字线
        _crosshair = _avaPlot.Plot.Add.Crosshair(0, 0);
        _crosshair.TextColor = Colors.White;
        _crosshair.TextBackgroundColor = _crosshair.HorizontalLine.Color;
        _avaPlot.PointerMoved += (s, e) =>
        {
            var pt = e.GetCurrentPoint(null).Position;
            // Pixel mousePixel = new(e.X, e.Y);
            // Coordinates mouseCoordinates = formsPlot1.Plot.GetCoordinates(mousePixel);
            var mouseCoordinates = _avaPlot.Plot.GetCoordinates((float)pt.X, (float)pt.Y);
            _crosshair.Position = _avaPlot.Plot.GetCoordinates((float)pt.X, (float)pt.Y);
            _crosshair.VerticalLine.Text = $"{mouseCoordinates.X:N3}";
            _crosshair.HorizontalLine.Text = $"{DateTime.FromOADate(mouseCoordinates.Y)}";
            _avaPlot.Refresh();
        };

        // 添加颜色条
        var colorbar = _avaPlot.Plot.Add.ColorBar(_heatmap);
        colorbar.Axis.Label.Text = "Intensity";

        // 启动 TCP 数据接收任务
        if (!Design.IsDesignMode)
            _ = ReceiveDataFromTcp();
    }

    private async Task ReceiveDataFromTcp()
    {
        using var client = new TcpClient("127.0.0.1", 5000); // 替换为你的 TCP 服务器地址和端口
        using var stream = client.GetStream();
        using var reader = new System.IO.StreamReader(stream, System.Text.Encoding.UTF8);

        while (true)
        {
            var line = await reader.ReadLineAsync(); // 读取一行数据
            if (line != null)
            {
                var values = line.Split(',').Select(double.Parse).ToArray(); // 假设数据是以逗号分隔的浮点数
                UpdateHeatmap(values);
            }
        }
    }

    private void UpdateHeatmap(double[] newData)
    {
        // 旧数据上移(FIFO 操作)
        for (int i = 0; i < DataHeight - 1; i++)
        {
            for (int j = 0; j < DataWidth; j++)
            {
                _data[i, j] = _data[i + 1, j]; // 将下一行的数据复制到当前行
            }

            _timeLabels[i] = _timeLabels[i + 1]; // 时间标签也上移
        }

        // 将新数据添加到最下方
        for (int j = 0; j < DataWidth; j++)
        {
            _data[DataHeight - 1, j] = newData[j];
        }
        
        var now = DateTime.Now;
        _heatmap.Position = new CoordinateRect(
            _heatmap.Position.Value.XRange,
            new CoordinateRange(now.ToOADate()- DataHeight * _heatmap.CellHeight, now.ToOADate() ));
        _avaPlot.Plot.Axes.SetLimitsY(bottom: now.ToOADate() , top: now.ToOADate() - DataHeight * _heatmap.CellHeight);
        Console.WriteLine($"_avaPlot.Plot.Axes.AutoScaler.InvertedY = {_avaPlot.Plot.Axes.AutoScaler.InvertedY}");
        Console.WriteLine($"_avaPlot.Plot.Axes.Left.IsInverted = {_avaPlot.Plot.Axes.Left.IsInverted()}");
        Console.WriteLine($"_heatmap.Position.Value.YRange.IsInverted = {_heatmap.Position.Value.YRange.IsInverted}");
        // _avaPlot.Plot.Axes.AutoScale(null,true);
        _avaPlot.Plot.Axes.AutoScaleY();
        Console.WriteLine($"{now}");
        
        if (_avaPlot.IsPointerOver)
        {
            // var topLevel = GetTopLevel(this);
            //
            // var pt = e.GetCurrentPoint(null).Position;
            // // Pixel mousePixel = new(e.X, e.Y);
            // // Coordinates mouseCoordinates = formsPlot1.Plot.GetCoordinates(mousePixel);
            // var mouseCoordinates = _avaPlot.Plot.GetCoordinates((float)pt.X, (float)pt.Y);
            // _crosshair.Position = _avaPlot.Plot.GetCoordinates((float)pt.X, (float)pt.Y);
            // _crosshair.VerticalLine.Text = $"{mouseCoordinates.X:N3}";
            // _crosshair.HorizontalLine.Text = $"{DateTime.FromOADate(mouseCoordinates.Y)}";
        }


        // 刷新热力图
        _heatmap.Update();
        _avaPlot.Refresh();
    }
}

server.py

import socket
import time
import random
import signal
import sys

# 定义服务器地址和端口
HOST = '127.0.0.1'
PORT = 5000

# 定义浮点数的最大值
MAX_FLOAT = 255.0

# 处理Ctrl+C信号
def signal_handler(sig, frame):
    print('Server is shutting down...')
    sys.exit(0)

# 生成浮点数列表
def generate_floats(start, end):
    return [random.uniform(start, end) for _ in range(100)]

# 主函数
def main():
    # 创建TCP socket
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.bind((HOST, PORT))
    server_socket.listen(1)
    print(f'Server is listening on {HOST}:{PORT}...')

    # 注册信号处理函数
    signal.signal(signal.SIGINT, signal_handler)

    try:
        while True:
            # 等待客户端连接
            client_socket, client_address = server_socket.accept()
            print(f'Connected by {client_address}')

            start_range = 0
            end_range = 99

            try:
                while True:
                    # 生成浮点数列表
                    floats = generate_floats(start_range, end_range)
                    # 将浮点数列表转换为字符串
                    data = ','.join(map(str, floats)) + '\n'
                    # 发送数据给客户端
                    client_socket.sendall(data.encode('utf-8'))
                    print(f'Sent: {data.strip()}')

                    # 更新浮点数范围
                    start_range += 1
                    end_range += 1
                    if end_range > MAX_FLOAT:
                        start_range = 0
                        end_range = 99

                    # 等待5秒
                    time.sleep(5)

            except ConnectionResetError:
                print('Client disconnected.')
            finally:
                client_socket.close()
                print('Connection closed.')

    except KeyboardInterrupt:
        print('Server is shutting down...')
    finally:
        server_socket.close()

if __name__ == '__main__':
    main()

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions