MSChart(System.Windows.Forms.DataVisualization.Charting.Chart)是Visual Studio中的一个功能强大的图表控件。用它绘制基础图表非常方便,但是要构建一个功能完善的K线图显示工具,其默认的行为有时会显得捉襟见肘。要实现一个类似常用的专业金融软件里面那样的K线图,则需要进行很多细节调整,包括:像素级的精确定位、智能的坐标轴刻度、平滑准确的交互式缩放,双Y轴值和对应百分比同步,这些细节的打磨都需要仔细考虑。

这篇文章总结了开发K线图时遇到的这些问题,基本上能实现一个功能强大,体验较好的专业K线图看图组件。

一、 搭建图表结构


一般的K线图都包含价格区域和成交量区域双区域,其中价格区间是包含高开低收的K线图,成交量区是K线对应的成交量柱状图。一些交易软件在成交量区下面还会包含一些证券分析指标图,比如MACD,KDJ等之类的图。

多图表区域布局与对齐


多图表区域布局的第一个问题是对齐。在MSChart中一个图表区域就是一个ChartArea,在本文的基础K线图中,需要两个ChartArea,一个是PriceArea,一个是VolumeArea,需要通过X轴时间将它们对齐,这其中最关键的是AlignWithChartArea属性,通过这一属性,可以保证它们在时间轴(X轴)上能够完美同步对齐。设置图表区域对齐的代码如下:

private void SetupChartAreas()
{
    chart1.ChartAreas.Clear();
    // 价格区域,占据上方75%
    ChartArea priceArea = new ChartArea("PriceArea") { Position = new ElementPosition(0, 0, 100, 75) };
    // 成交量区域,占据下方25%
    ChartArea volumeArea = new ChartArea("VolumeArea") { Position = new ElementPosition(0, 75, 100, 25) };
    chart1.ChartAreas.Add(priceArea);
    chart1.ChartAreas.Add(volumeArea);

    // 【关键】确保两个区域的绘图区垂直对齐,这样X轴才能同步
    priceArea.AlignWithChartArea = "VolumeArea";
    priceArea.AlignmentOrientation = AreaAlignmentOrientations.Vertical;
    volumeArea.AlignWithChartArea = "PriceArea";
    volumeArea.AlignmentOrientation = AreaAlignmentOrientations.Vertical;
}

核心数据系列


设置好图表区域之后,需要设置每个图表区域里面的数据系列即Series。这里需要一个Candlestick系列用于绘制K线,一个Column系列用于绘制成交量,然后将它们分别添加到各自的ChartArea中。

private void SetupSeries()
{
    chart1.Series.Clear();
    var klineSeries = new Series("KLine")
    {
        ChartType = SeriesChartType.Candlestick,
        ChartArea = "PriceArea",
        YValuesPerPoint = 4,
        XValueType = ChartValueType.DateTime,
        CustomProperties = "PriceUpColor=Red,PriceDownColor=Green",
        Color = Color.Black,
        BorderColor = Color.Black
    };
    var volumeSeries = new Series("Volume")
    {
        ChartType = SeriesChartType.Column,
        ChartArea = "VolumeArea",
        XValueType = ChartValueType.Double
    };
    chart1.Series.Add(klineSeries);
    chart1.Series.Add(volumeSeries);
}

在图表系列中每个数据系列有几个核心的属性:

  • ChartType指定系列的表现形式。
  • ChartArea指定该系列属于的图表区域。
  • XValueType指定X轴的数据类型。

还有一些不同图表系列的专属属性CustomProperties,比如在Candlestick 蜡烛图中,可以指定阳线颜色,阴线颜色,等等。

配色与样式


图表的配色和样式也很重要,这里采用深色主题,移除不必要的元素,比如图例,X轴网格线,并使用柔和的颜色减少视觉疲劳,这里需要注意的一点是,K线阴线阳线的颜色与下面成交量柱状图的渲染颜色应该保持一致。

首先定义一些颜色常量,以方便统一管理:

// 在类顶部定义颜色常量,方便统一管理
private static readonly Color ChartBackgroundColor = Color.FromArgb(32, 42, 52);
private static readonly Color GridLineColor = Color.FromArgb(64, 74, 84);
private static readonly Color AxisLineColor = Color.FromArgb(96, 106, 116);
private static readonly Color LabelColor = Color.Gainsboro;
private static readonly Color InfoLabelBackgroundColor = Color.FromArgb(42, 52, 62);

然后提取一个方法,专门用来设置颜色样式:

private void ApplyChartStyles()
{

    chart1.Legends.Clear();
    chart1.BackColor = ChartBackgroundColor;

    // 更新信息标签的颜色
    infoLabel.BackColor = InfoLabelBackgroundColor;
    infoLabel.ForeColor = LabelColor;             // 使用新的柔和文字颜色
    infoLabel.Padding = new Padding(3);
    infoLabel.AutoSize = true;

    // 配置价格区域
    var priceArea = chart1.ChartAreas["PriceArea"];
    priceArea.BackColor = Color.Transparent;
    // -- Y1轴 (价格)
    var axisY1 = priceArea.AxisY;
    axisY1.LabelStyle.ForeColor = LabelColor;
    axisY1.MajorGrid.LineColor = GridLineColor;
    axisY1.LineColor = AxisLineColor;
    axisY1.MajorTickMark.Enabled = false;
    axisY1.IsMarksNextToAxis = true;

    // -- Y2轴 (涨跌幅)
    var axisY2 = priceArea.AxisY2;
    axisY2.Enabled = AxisEnabled.True;
    axisY2.LabelStyle.ForeColor = LabelColor;
    axisY2.MajorGrid.Enabled = false;
    axisY2.LineColor = AxisLineColor;
    axisY2.MajorTickMark.Enabled = false;
    axisY2.LabelStyle.Format = "F2";
    axisY2.IsMarksNextToAxis = true;

    // 配置成交量区域
    var volumeArea = chart1.ChartAreas["VolumeArea"];
    volumeArea.BackColor = Color.Transparent;
    volumeArea.InnerPlotPosition = new ElementPosition(0, 0, 100, 80);
    var volumeAxisY = volumeArea.AxisY;
    volumeAxisY.LabelStyle.Enabled = true;
    volumeAxisY.LabelStyle.ForeColor = LabelColor;
    volumeAxisY.MajorGrid.LineColor = GridLineColor;
    volumeAxisY.LineColor = AxisLineColor;
    volumeAxisY.MajorTickMark.Enabled = false;
    volumeAxisY.IsMarksNextToAxis = true;

    // 通用X轴配置
    foreach (var area in chart1.ChartAreas)
    {
        var axisX = area.AxisX;
        axisX.ScrollBar.Enabled = false;
        axisX.LabelStyle.ForeColor = LabelColor;
        axisX.MajorGrid.Enabled = false; // X轴网格线已禁用
        axisX.LineColor = AxisLineColor;
        axisX.MajorTickMark.Enabled = false;
        axisX.LabelAutoFitStyle = LabelAutoFitStyles.None;
        axisX.LabelStyle.Angle = 0;
        axisX.LabelStyle.IsStaggered = false;
        axisX.LabelStyle.Font = new Font("Segoe UI", 8f); // 使用更现代的字体
        axisX.LabelStyle.Enabled = (area.Name == "VolumeArea");
    }
}

二、实现精准布局


默认情况下,图表会根据内容进行动态调整其绘图区占整个图表的范围,比如它会考虑Y轴和X轴的标签所占用的宽度来动态调整绘图区的大小。

▲MSChat中的各种元素及含义

如果只显示单个K线图,则问题似乎不大,但如果要实现诸如多股同列:即把多个K线图放在一个界面上对比显示,则如果不精确控制图表布局,则会因为不同股票的价格区间范围不同,而导致多个图表之间的不对齐问题。

▲MSChart的自动布局,会导致不同的图表之间,显得“不对齐”,比如左中这个图表与其上下不对齐。

要对ChartArea进行精准布局,最重要的一个属性是InnerPlotPosition,它和Position属性一样,都是ElementPosition类型,它们的区别如下:

//
// Summary:
//     Initializes a new instance of the System.Windows.Forms.DataVisualization.Charting.ElementPosition
//     class with the specified x, y, width and height parameters.
// Parameters:
//   x:
//     The X position of the top-left corner of the chart control.
//   y:
//     The Y position of the top-left corner of the chart control.
//   width:
//     The width of the chart element.
//   height:
//     The height of the chart element.
public ElementPosition(float x, float y, float width, float height)
{
    _auto = false;
    _x = x;
    _y = y;
    _width = width;
    _height = height;
}
  • Position,它是对整个ChartArea的定义,可以把它想象成一幅带“画框”的完整画作。它包括了所有内容:绘图区、坐标轴、标签、刻度线等等。它使用相对坐标系,参照是父级Chart控件。它用于布局多个图表区域,比如在上面的PriceArea和VolumeArea分别设置为了{ 0,0,100,75 } 和 { 0,75,100,25 },实现了价格图在上方占75%,成交量图在下方占25%的布局。它的作用相当于整体留白,定义了ChartArea与控件边缘的空间,在效果上相当于CSS中的Margin,即外边距
  • InnerPlotPosition,它是指针对绘图区域的定义,可以把它想象成画作中的“画布”部分,也就是X轴和Y轴围成的矩形。它也使用相对坐标,参照是它所属的ChartArea。它用于为坐标轴元素留出空间,在ChartArea和InnerPlotPosition的边界之间的区域,专门用来绘制坐标轴标签、刻度线和轴坐标。由InnerPlotPosition产生的空白区域,在效果是相当于CSS中的Padding,即内边距

但是,ChartArea.Position 和 InnerPlotPosition 都只接受百分比。比如第一个参数x表示ChartArea的左边框距Chart的左边框的百分比距离,这个百分比的基准是整个Chart的宽度,如果Chart宽度较小,则在的一定的百分比下,这个距离很小,如果窗体宽度很大,则这个宽度就会很大。在一个高分辨率的宽屏显示器上,5%的边距可能会浪费掉数百像素的宝贵空间。

解决方法是,动态的将期望的固定像素边距换算成实时的百分比。并在图表的大小发生变化时重新计算,图表大小的变化可以注册图表的Resize事件。在布局上,给上下左右留一些空间给Y轴和X轴标签:

private const float TopPaddingPixels = 20;
private const float BottomPaddingPixels = 20;
private const float LeftPaddingPixels = 50;
private const float RightPaddingPixels = 50;

设置的方法如下:

private void SetInnerPlotPositionByPixels()
{
    if (chart1.ChartAreas.Count == 0) return;
    try
    {
        ChartArea priceArea = chart1.ChartAreas["PriceArea"];
        float chartAreaPixelWidth = chart1.ClientSize.Width * (priceArea.Position.Width / 100f);
        float chartAreaPixelHeight = chart1.ClientSize.Height * (priceArea.Position.Height / 100f);
        if (chartAreaPixelWidth <= 0 || chartAreaPixelHeight <= 0) return;

        float leftMarginPercent = (LeftPaddingPixels / chartAreaPixelWidth) * 100f;
        float rightMarginPercent = (RightPaddingPixels / chartAreaPixelWidth) * 100f;
        float topMarginPercent = (TopPaddingPixels / chartAreaPixelHeight) * 100f;
        float bottomMarginPercent = (BottomPaddingPixels / chartAreaPixelHeight) * 100f;
        float innerPlotWidth = 100f - leftMarginPercent - rightMarginPercent;
        float innerPlotHeight = 100f - topMarginPercent - bottomMarginPercent;

        if (innerPlotWidth > 0 && innerPlotHeight > 0)
        {
            var newPosition = new ElementPosition(leftMarginPercent, topMarginPercent, innerPlotWidth, innerPlotHeight);
            priceArea.InnerPlotPosition = newPosition;
            chart1.ChartAreas["VolumeArea"].InnerPlotPosition.X = newPosition.X;
            chart1.ChartAreas["VolumeArea"].InnerPlotPosition.Width = newPosition.Width;
        }
    }
    catch { /* 忽略异常 */ }
}

这个方法,在初始化布局后以及图形大小发生变化时调用:

// 订阅尺寸变化事件,用于动态像素边距
this.Resize += (s, args) =>
{
    // 1. 首先,根据新尺寸设置好绘图区的像素边距
    SetInnerPlotPositionByPixels();

    // 2. 然后,在新的布局下,动态更新X轴的标签
    .....
};

也可以提取出一个独立的方法,用来只接受以像素为单位的参数:

/// <summary>
/// 设置ChartArea的内边距,边距以像素为单位
/// </summary>
/// <param name="chartArea"></param>
/// <param name="leftPaddingPixel"></param>
/// <param name="topPaddingPixels"></param>
/// <param name="rightPaddingPixels"></param>
/// <param name="bottomPaddingPixels"></param>
private void SetChartAreaMargin(ChartArea chartArea, float leftPaddingPixel, float topPaddingPixels, float rightPaddingPixels, float bottomPaddingPixels)
{
    chartArea.InnerPlotPosition.Auto = false;
    // 1. 获取 ChartArea 的实际像素尺寸
    float chartAreaPixelWidth = this.ClientSize.Width * (chartArea.Position.Width / 100f);
    float chartAreaPixelHeight = this.ClientSize.Height * (chartArea.Position.Height / 100f);

    // 如果图表过小,则不进行计算以防止错误
    if (chartAreaPixelWidth <= 0 || chartAreaPixelHeight <= 0) return;

    // 2. 将固定的像素边距转换为百分比
    float leftMarginPercent = (leftPaddingPixel / chartAreaPixelWidth) * 100f;
    float rightMarginPercent = (rightPaddingPixels / chartAreaPixelWidth) * 100f;
    float topMarginPercent = (topPaddingPixels / chartAreaPixelHeight) * 100f;
    float bottomMarginPercent = (bottomPaddingPixels / chartAreaPixelHeight) * 100f;

    // 3. 计算 InnerPlotPosition 的 X, Y, Width, Height 百分比
    float innerPlotX = leftMarginPercent;
    float innerPlotY = topMarginPercent;
    float innerPlotWidth = 100f - leftMarginPercent - rightMarginPercent;
    float innerPlotHeight = 100f - topMarginPercent - bottomMarginPercent;

    // 4. 应用计算出的新位置(并进行有效性检查)
    if (innerPlotWidth > 0 && innerPlotHeight > 0 && innerPlotWidth <= 100 && innerPlotHeight <= 100)
    {
        chartArea.InnerPlotPosition.Auto = false;
        chartArea.InnerPlotPosition.X = innerPlotX;
        chartArea.InnerPlotPosition.Y = innerPlotY;
        chartArea.InnerPlotPosition.Width = innerPlotWidth;
        chartArea.InnerPlotPosition.Height = innerPlotHeight;
    }
}

三、动态坐标轴


直接使用MSChart自动产生的坐标轴和标签在K线显示上会存在一些,比如非连续的时间和不简洁的Y轴价格标签和成交量标签。

处理非连续时间轴


直接使用DateTime作为X轴,会在中午休市时段留下一大段空白。解决方案就是“索引为核,标签为皮”。我们内部使用连续的整数索引(0,1,2...)作为X值,消除了时间断层;同时,我们动态地创建自定义标签,将索引位置映射到真实的时间文本。

private void UpdateXAxisLabels()
{
    // 在设计器模式下或尚未加载时退出
    if (chart1.ChartAreas.Count == 0 || this.ParentForm == null) return;

    var priceArea = chart1.ChartAreas["PriceArea"];
    var volumeArea = chart1.ChartAreas["VolumeArea"];
    var priceAxisX = priceArea.AxisX;
    var volumeAxisX = volumeArea.AxisX;

    priceAxisX.CustomLabels.Clear();
    volumeAxisX.CustomLabels.Clear();

    // 1. 测量一个标签所需的最小像素宽度 (包含一些边距)
    var labelFont = priceAxisX.LabelStyle.Font;
    int minLabelWidth = TextRenderer.MeasureText("00:00", labelFont).Width + 15;

    // 2. 计算绘图区域的实际像素宽度
    float plotPixelWidth = chart1.ClientSize.Width * (priceArea.InnerPlotPosition.Width / 100f);
    if (plotPixelWidth <= 0 || minLabelWidth <= 0) return;

    // 3. 决策:根据可用空间决定标签的间隔 (步长)
    int maxLabelCount = (int)(plotPixelWidth / minLabelWidth);
    if (maxLabelCount <= 0) return;

    int totalMinutes = (int)_overallXMax + 1;
    int minStep = totalMinutes / maxLabelCount;

    int niceStep; // "整洁"的步长,单位是分钟(索引)
    if (minStep <= 20) niceStep = 15;
    else if (minStep <= 45) niceStep = 30;
    else if (minStep <= 90) niceStep = 60;
    else niceStep = 120;

    // 4. 绘制:只生成并绘制被选中的标签
    DateTime tradingDay = chart1.Series["KLine"].Points.Count > 0 ?
        ((KLineData)chart1.Series["KLine"].Points[0].Tag).Date.Date :
        DateTime.Today;

    for (int index = 0; index <= _overallXMax; index++)
    {
        // 只在 "整洁" 步长的倍数位置上,并且是关键时间点(如开盘、收盘)才创建标签
        bool isKeyTime = (index == 0 || index == 120 || index == 121 || index == 240);
        if (index % niceStep == 0 || isKeyTime)
        {
            var time = IndexToTime(index, tradingDay);
            var labelText = time.ToString(@"HH:mm");

            var label = new CustomLabel(index - 4.0, index + 4.0, labelText, 0, LabelMarkStyle.None);

            priceAxisX.CustomLabels.Add(label);
            volumeAxisX.CustomLabels.Add(label.Clone());
        }
    }
}

/// <summary>
/// 将交易分钟的索引转换为实际时间
/// (总共241个分钟点: 上午121个, 下午120个)
/// </summary>
private DateTime IndexToTime(int index, DateTime tradingDay)
{
    if (index < 121) // 上午 09:30 - 11:30
    {
        return tradingDay.Date.AddHours(9).AddMinutes(30).AddMinutes(index);
    }
    else // 下午 13:00 - 15:00
    {
        return tradingDay.Date.AddHours(13).AddMinutes(index - 121);
    }
}

/// <summary>
/// 将实际时间转换为交易分钟的索引
/// </summary>
private int TimeToIndex(DateTime time)
{
    if (time.TimeOfDay <= new TimeSpan(11, 30, 0)) // 上午
    {
        return (int)(time.TimeOfDay - new TimeSpan(9, 30, 0)).TotalMinutes;
    }
    else // 下午
    {
        return 121 + (int)(time.TimeOfDay - new TimeSpan(13, 0, 0)).TotalMinutes;
    }
}

智能Y轴刻度和成交量格式化


固定的Y轴刻度无法适应不同的价格,比如有些低于10元价格的股票的价格变动单位是0.01元,而有些高价股比如大于100甚至1000元的股价变动范围可能是以1元为单位变动。另外Y轴上的辅助线MajorGrid也需要人为控制,至少有几根标线,太多了会导致标线过多喧宾夺主,太少了不具备参考价值。另外,默认生成的Y轴标签的小数点保留位数是固定的,对于小于100元的股票,Y轴标签保留2为小数没问题,但是对于大于100元的股票,我们希望辅助线尽量停留在整数,Y轴标签也显示为整数。MSChart默认自定义产生的Y轴标签会调整,但是他会固定的保留2位小数,即使数字是整数,后面也会存在.00的小数位:

▲ MSChart的坐标轴自动设定存在诸多问题,比如辅助线不够“整”,高价股的小数点“多余”,网格辅助线条数过于随意等

自动生成的标签会存在一些不够优雅的问题:

  • Y轴辅助线(MajorGrid)的条数过于随意,有的辅助线条数多,有的少
  • Y轴价格标签的间隔值不够整,对于高价股(大于100块)我们希望价格是整数,而不需小数位,因为过多的标签宽度会挤占图表绘图区。
  • Y轴标签的其实不够合理,我们希望尽可能的整,且间隔如果有条件,尽可能是1,2,5,10这种。

要解决以上问题,实现更优雅的辅助线以及标签样式,就需要根据可见价格范围,自动计算最优的间隔,并智能切换整数/小数标签。解决办法是,根据视图范围内K线的最大值和最小值(价格和成交量),动态计算出最简洁的间隔,比如 1,2,5,10。核心方法如下:

private double CalculateNiceInterval(double range, int targetSteps = 8)
{
    if (range <= 0) return 1;
    double rawInterval = range / targetSteps;
    double magnitude = Math.Pow(10, Math.Floor(Math.Log10(rawInterval)));
    double residual = rawInterval / magnitude;
    double niceResidual;
    if (residual > 5) niceResidual = 10;
    else if (residual > 2) niceResidual = 5;
    else if (residual > 1) niceResidual = 2;
    else niceResidual = 1;
    return niceResidual * magnitude;
}

当K线的可视范围发生变化时,调用以下方法,更新Y轴的间隔并同时设置最大最小值。

private void UpdateYAxisViewForNewXRange(double? newXMin = null, double? newXSize = null)
{
    var priceArea = chart1.ChartAreas["PriceArea"];
    var volumeArea = chart1.ChartAreas["VolumeArea"];
    var klineSeries = chart1.Series["KLine"];
    var volumeSeries = chart1.Series["Volume"];

    double xViewMin = newXMin ?? priceArea.AxisX.ScaleView.ViewMinimum;
    double xViewMax = (newXMin + newXSize) ?? priceArea.AxisX.ScaleView.ViewMaximum;

    double yMinInView = double.MaxValue;
    double yMaxInView = double.MinValue;
    double maxVolumeInView = 0; // 【新增】用于记录视野内的最大成交量
    bool foundPoints = false;

    int startIndex = Math.Max(0, (int)Math.Floor(xViewMin));
    int endIndex = Math.Min(klineSeries.Points.Count - 1, (int)Math.Ceiling(xViewMax));

    for (int i = startIndex; i <= endIndex; i++)
    {
        var klinePoint = klineSeries.Points[i];
        var volumePoint = volumeSeries.Points[i];
        if (klinePoint.YValues.Length < 2) continue;

        // 查找价格高低点
        double high = klinePoint.YValues[0];
        double low = klinePoint.YValues[1];
        if (high > yMaxInView) yMaxInView = high;
        if (low < yMinInView) yMinInView = low;

        // 【新增】查找最大成交量
        if (volumePoint.YValues[0] > maxVolumeInView)
        {
            maxVolumeInView = volumePoint.YValues[0];
        }
        foundPoints = true;
    }

    // 【成交量区域空白问题修复】
    // 动态设置成交量区域Y轴的最大值,增加20%的顶部边距
    if (foundPoints)
    {
        var volumeAxisY = volumeArea.AxisY;
        volumeAxisY.Minimum = 0;
        if (maxVolumeInView > 0)
        {
            double niceVolumeInterval = CalculateNiceInterval(maxVolumeInView, 3); // 成交量区网格线可以更稀疏
            volumeAxisY.Interval = niceVolumeInterval;
            volumeAxisY.Maximum = Math.Ceiling(maxVolumeInView / niceVolumeInterval) * niceVolumeInterval;
        }
        else
        {
            volumeAxisY.Maximum = 100; // 默认值
        }
        UpdateYAxisViewForRange(yMinInView, yMaxInView);
    }
}

private void UpdateYAxisViewForRange(double yMin, double yMax)
{
    double viewMin = yMin * 0.997;
    double viewMax = yMax * 1.003;
    if (Math.Abs(viewMax - viewMin) < 0.00001)
    {
        viewMin = yMin - 0.05;
        viewMax = yMax + 0.05;
    }

    double viewRange = viewMax - viewMin;
    double niceInterval = CalculateNiceInterval(viewRange);
    string labelFormat = (niceInterval < 1.0) ? "0.00" : "0";
    if (niceInterval >= 1.0) niceInterval = Math.Round(niceInterval);

    double finalViewMin = Math.Floor(viewMin / niceInterval) * niceInterval;
    double finalViewMax = Math.Ceiling(viewMax / niceInterval) * niceInterval;
    if (finalViewMax - finalViewMin < niceInterval)
    {
        finalViewMax = finalViewMin + niceInterval;
    }

    ApplyYAxisView(finalViewMin, finalViewMax, niceInterval, labelFormat);
}

private void ApplyYAxisView(double viewMin, double viewMax, double interval, string format)
{
    if (viewMin >= viewMax) return;
    var axisY = chart1.ChartAreas["PriceArea"].AxisY;
    _isUpdatingView = true;
    try
    {
        axisY.LabelStyle.Format = format;
        axisY.Interval = interval;
        axisY.Minimum = viewMin;
        axisY.Maximum = viewMax;
        axisY.ScaleView.Zoom(viewMin, viewMax);
        //在更新完Y1轴后,立即同步Y2轴
        SynchronizeY2Axis();
    }
    finally
    {
        _isUpdatingView = false;
    }
}

成交量的Y轴默认标签也存在问题,比如巨大的成交量数字难以阅读。对于成交量的Y轴,可以使用Chart.Customize事件,在标签绘制前将其文本从数字格式化为"xx万"或“xx亿”的字符串。

private void Chart1_Customize(object sender, EventArgs e)
{
    var volumeAxisY = chart1.ChartAreas["VolumeArea"].AxisY;

    // 在Customize事件触发时,自动生成的标签会被临时放在CustomLabels集合中
    // 我们只关心成交量Y轴的标签
    foreach (var label in volumeAxisY.CustomLabels)
    {
        try
        {
            // 1. 将标签文本解析回数值
            double value = double.Parse(label.Text);

            // 2. 根据数值应用自定义的“万/亿”格式
            if (value >= 100000000) // 亿
            {
                label.Text = (value / 100000000).ToString("F2") + "亿";
            }
            else if (value >= 10000) // 万
            {
                // 使用F0确保整数万,如果需要小数万,可改为F1或F2
                label.Text = (value / 10000).ToString("F0") + "万";
            }
            else
            {
                label.Text = value.ToString("F0");
            }
        }
        catch
        {
            // 如果标签文本不是数字,则忽略,保持原样
        }
    }
}

四、交互式缩放与平移


一般的K线图都是支持键盘的上下、左右键实现缩放与平移,也支持使用鼠标选中进行放大。但这里很容易出现K线的视觉位置与Y轴网格线的不一致,以及可能会出现图表直接消失的严重问题。这其中的根本原因在于ScaleView.Zoom设定的“不规则”视图范围,与Axis.Interval设定的“规则”刻度间隔之间存在冲突,从而导致图表渲染引擎的标尺计算失效。

▲ MSChart中K线价格与辅助线“不一致”,13:15分的K线最高值为364.98,它并没有超过365,但在辅助线上,显示它已经超过了价格为365的那条辅助线

交互式的缩放一般以最后一根K线为基准,进行“右侧锚定”缩放。按住键盘上/下键不放时,以标准键盘重复率进行连续缩放。平移一般通过键盘上的左右键进行,按一次将视图向左或向右平移一根K线。

实现缩放和平移的办法是是捕获键盘事件:

protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
    if (keyData == Keys.Up || keyData == Keys.Down || keyData == Keys.Left || keyData == Keys.Right)
    {

        var axisX = chart1.ChartAreas["PriceArea"].AxisX;
        if (!axisX.ScaleView.IsZoomed) axisX.ScaleView.Zoom(_overallXMin, _overallXMax);

        // --- 缩放逻辑 (上下键) ---
        if (keyData == Keys.Up || keyData == Keys.Down)
        {
            double viewMinX = axisX.ScaleView.ViewMinimum;
            double viewMaxX = axisX.ScaleView.ViewMaximum;
            double viewSizeX = viewMaxX - viewMinX;

            if (keyData == Keys.Up) // 放大
            {
                viewSizeX = Math.Max(MinVisiblePoints, viewSizeX - KeyboardZoomStep);
            }
            else // 缩小
            {
                viewSizeX += KeyboardZoomStep;
            }

            // 锚定到最右侧K线
            viewMaxX = _overallXMax;
            viewMinX = viewMaxX - viewSizeX;

            if (viewMinX < _overallXMin) viewMinX = _overallXMin;

            // 【重要】对于缩放,我们仍然直接调用Zoom,并手动更新Y轴
            axisX.ScaleView.Zoom(viewMinX, viewMaxX);
            UpdateYAxisViewForNewXRange();
        }
        // --- 平移逻辑 (左右键) ---
        else if (keyData == Keys.Left || keyData == Keys.Right)
        {
            double currentPosition = axisX.ScaleView.Position; // 获取当前视图的起始位置
            double newPosition = currentPosition;

            if (keyData == Keys.Left) // K线向左移动 -> 视图向右移动
            {
                newPosition += KeyboardPanStep;
            }
            else // K线向右移动 -> 视图向左移动
            {
                newPosition -= KeyboardPanStep;
            }

            // Scroll方法会自动处理边界,无需手动检查
            // 调用Scroll会触发AxisViewChanging事件,该事件会自动更新Y轴
            axisX.ScaleView.Scroll(newPosition);
            UpdateYAxisViewForNewXRange();
        }
        return true;
    }
    return base.ProcessCmdKey(ref msg, keyData);
}

对于缩放,它是通过调用X轴和Y轴的ScaleView.Zoom方法实现;平移则通过调用X轴的ScaleView的Scroll方法实现。需要注意的是,不管是缩放或平移,都要调用Y轴的ScaleView.Zoom方法。在Y轴上,还需要同时设置Minimum和Maximum方法。

如果需要支持鼠标选中放大,则需要设置:

priceArea.CursorX.IsUserEnabled = true;
priceArea.CursorX.IsUserSelectionEnabled = true;

然后还需要注册 AxisViewChanging 事件:

private void Chart1_AxisViewChanging(object sender, ViewEventArgs e)
{
    if (_isUpdatingView) return;
    if (e.Axis.AxisName == AxisName.X && e.ChartArea.Name == "PriceArea")
    {
        UpdateYAxisViewForNewXRange(e.NewPosition, e.NewSize);
    }
    else if (e.Axis.AxisName == AxisName.Y && e.ChartArea.Name == "PriceArea")
    {
        _isUpdatingView = true;
        try
        {
            double proposedMin = e.NewPosition;
            double proposedSize = e.NewSize;
            if (proposedSize <= 0) return;

            double niceInterval = CalculateNiceInterval(proposedSize);
            string labelFormat = (niceInterval < 1.0) ? "0.00" : "0";
            if (niceInterval >= 1.0) niceInterval = Math.Round(niceInterval);

            double finalViewMin = Math.Floor(proposedMin / niceInterval) * niceInterval;
            double finalViewMax = Math.Ceiling((proposedMin + proposedSize) / niceInterval) * niceInterval;
            if (finalViewMax - finalViewMin < niceInterval) finalViewMax = finalViewMin + niceInterval;

            e.NewPosition = finalViewMin;
            e.NewSize = finalViewMax - finalViewMin;
            e.Axis.Interval = niceInterval;
            e.Axis.LabelStyle.Format = labelFormat;
        }
        finally
        {
            _isUpdatingView = false;
        }
    }
}

五、Y2轴同步显示涨跌幅


在一些K线图中,左侧的Y轴的MajorGrid辅助线会显示价格,对应的右侧的Y轴会显示辅助线价格相对于昨收的涨跌幅。因为随着缩放或平移,Y轴的最大最小值以及间隔会动态发生改变,这就要求右侧Y轴的涨跌幅标签也需要跟随着动态变更。起初我的做法是Y2轴与Y轴一样,会动态的根据当前视线范围内的K线的最大最小值,然后对昨收计算涨跌幅,然后计算Y2轴的最佳辅助线条数,然后计算Y2轴的最大最小值以及缩放范围。但是因为这里面可能涉及到小数点精度问题,他不可能做得到跟Y轴一样完美对齐。

后来我想到的一个解决办法就是,把Y2轴复刻Y轴,他们两个完全一样,只是Y2轴不显示MajorGrid,我们只需要Y2轴的标签。计算Y轴的最大最小值以及缩放参数时,也一并设置Y2到这些值。只是在最后格式化标签样式时,就像成交量格式化为"××万",“××亿”那样,在那个地方把在Y2上动态生成的标签值,对昨收价计算涨跌幅,然后重写回去。这样就两个轴就会完美同步。下面这个方法就是把Y轴的设置,同步到Y2轴。

/// <summary>
/// 核心同步方法:根据Y1轴的视图,计算并设置Y2轴的范围
/// </summary>
private void SynchronizeY2Axis()
{
    var priceArea = chart1.ChartAreas["PriceArea"];
    var axisY1 = priceArea.AxisY;
    var axisY2 = priceArea.AxisY2;

    if (!axisY1.ScaleView.IsZoomed || _referencePrice <= 0)
    {
        axisY2.Enabled = AxisEnabled.False;
        return;
    }
    axisY2.Enabled = AxisEnabled.True;

    // 【关键】将Y2轴的结构完全复制Y1轴,确保物理位置一致
    axisY2.Minimum = axisY1.Minimum;
    axisY2.Maximum = axisY1.Maximum;
    axisY2.Interval = axisY1.Interval;
}

现在,我们有了两个一模一样的Y轴,要想在Y2轴上显示涨跌幅,只需要把Y2轴的标签进行格式化,让他对昨收价计算涨跌幅即可。

要实现这个功能,有两个事件可以使用,首先优先应该要考虑的是Chart的FormatNumber事件,它的实现如下:

private void Chart1_FormatNumber(object sender, FormatNumberEventArgs e)
{
    if (e.ElementType == ChartElementType.AxisLabels && (sender == chart1.ChartAreas["VolumeArea"].AxisY))
    {
        long v = (long)e.Value;
        if (Math.Abs(v) >= 100000000)
        {
            e.LocalizedValue = (v / 100000000).ToString() + "亿";
        }
        else if (Math.Abs(v) >= 10000000)
        {
            e.LocalizedValue = (v / 10000000).ToString() + "千万";
        }
        else if (Math.Abs(v) >= 10000)
        {
            e.LocalizedValue = (v / 10000).ToString() + "万";
        }
    }
    else if (e.ElementType == ChartElementType.AxisLabels && (sender == chart1.ChartAreas["PriceArea"].AxisY2))
    {
        if (_referencePrice > 0)
        {
            double price = (double)e.Value;
            if (_referencePrice != 0)
            {
                double percent = (price - _referencePrice) / _referencePrice;
                e.LocalizedValue = percent.ToString("P2");
            }
        }
    }
}

非常的优雅。

当然,也可以使用Chart的Customize事件,这个就没有前面那个事件优雅了。

private void Chart1_Customize(object sender, EventArgs e)
{
    var volumeAxisY = chart1.ChartAreas["VolumeArea"].AxisY;

    // 在Customize事件触发时,自动生成的标签会被临时放在CustomLabels集合中
    // 我们只关心成交量Y轴的标签
    foreach (var label in volumeAxisY.CustomLabels)
    {
        try
        {
            // 1. 将标签文本解析回数值
            double value = double.Parse(label.Text);

            // 2. 根据数值应用自定义的“万/亿”格式
            if (value >= 100000000) // 亿
            {
                label.Text = (value / 100000000).ToString("F2") + "亿";
            }
            else if (value >= 10000) // 万
            {
                // 使用F0确保整数万,如果需要小数万,可改为F1或F2
                label.Text = (value / 10000).ToString("F0") + "万";
            }
            else
            {
                label.Text = value.ToString("F0");
            }
        }
        catch
        {
            // 如果标签文本不是数字,则忽略,保持原样
        }
    }

    // --- 2. 处理涨跌幅Y2轴标签 ---
    if (_referencePrice > 0)
    {
        var priceAxisY2 = chart1.ChartAreas["PriceArea"].AxisY2;
        foreach (var label in priceAxisY2.CustomLabels)
        {
            try
            {
                double priceValue = double.Parse(label.Text);
                double percent = (priceValue - _referencePrice) / _referencePrice;
                label.Text = percent.ToString("P2");
            }
            catch { /* 忽略转换失败 */ }
        }
    }
}

需要特别注意的是,如果使用Customize事件,那么CustomLables中的label的Text都是格式化好了的数据,比如如果设置Y2轴的显示格式为P2,那么label.Text就已经是百分比的格式,这样把他转为double时就会报错。所以如果使用Customize事件,务必把Y2的显示格式设置为F2。

// -- Y2轴 (涨跌幅)
var axisY2 = priceArea.AxisY2;
axisY2.Enabled = AxisEnabled.True;
axisY2.LabelStyle.ForeColor = LabelColor;
axisY2.MajorGrid.Enabled = false;
axisY2.LineColor = AxisLineColor;
axisY2.MajorTickMark.Enabled = false;
axisY2.LabelStyle.Format = "F2";//"P2";
axisY2.IsMarksNextToAxis = true;

这也是Customize来处理这一问题不够优雅的原因,而FormatNumber事件里面的e.Value则是没有格式化前的原始数据,它与Y2的LabelStyle.Format这里的设置没有任何关系,所以这里认为FormatNumber这一事件才是处理这类显示转换的最优雅的方式。

▲ 最终效果图

六、总结


本文使用MSChart实现了一个类似专业金融软件的K线图查看控件,它支持平滑准确的交互式缩放:键盘上下键缩放,左右键平移,鼠标选中缩放;实现了对绘图区的像素级的精确定位以使得在类似多股同列场景下各股票K线图能保持一致;也实现了智能的坐标轴刻度间隔、智能的坐标轴标签显示,以及双Y轴值和对应百分比同步,希望本文对您使用MSChart来绘制专业级的蜡烛图有所帮助。

 

参考: