JSON to LINQ to JSON

JsonLinqJson

It is about 9:30pm and I am sitting on the train home. It has been quite tough organizing my life in the last months. This is not the promised follow-up on OpenGL. There has been no time to read any book.

Anyway, this post shows how to run LINQ queries on JSON. The use can be quite broad. Imagine a shell command executing your LINQ on any JSON file. You can query into depth and build complex return tree structures with 2 or more levels. LINQ itself is highly flexible. And – just to give it the right flair – it returns results in the good old JSON format.

You could even go a bit further and insert more than just a LINQ query. In theory, you could add any C# code at run-time. The changes to this program would be minuscule.

I have sufficiently commented the code. There is a minimum overhead to get the job done.

 

How it works:

  1. The JSON data is imported using the JavaScriptSerializer, which is part of the System.Web.Extensions library reference. We try to force the result into a Dictionary<string, object>. Do not lazily use ‘object’. Some type information would get lost. I played with this and got eg. arrays instead of ArrayLists.
  2. The resulting structure is then used to build classes. These are converted to legible C# source code.
  3. The LINQ command is inserted. You can see the result in the TextBox titled ‘C# source code output’.
  4. A compiler instance is created and some library references are added. We compile the generated source code at run-time and then execute the binary.
  5. A standard JSON object is returned, rudimentary formatted and displayed as the final result.

Btw. I have inserted a horizontal and a vertical GridSplitter into the WPF XAML. You can easily change the size of the TextBoxes while playing with this tool. A few JSON examples were also added.

<Window         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"         xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" x:Class="LinqJson.MainWindow"   Title="JSON LINQ JSON" Height="Auto" Width="876" d:DesignHeight="695">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="1*"/>
      <RowDefinition Height="43*"/>
      <RowDefinition Height="30"/>
      <RowDefinition Height="150*"/>
      <RowDefinition Height="30"/>
      <RowDefinition Height="150*"/>
      <RowDefinition Height="10*"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="10*"/>
      <ColumnDefinition Width="250*"/>
      <ColumnDefinition Width="10"/>
      <ColumnDefinition Width="250*"/>
      <ColumnDefinition Width="10*"/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    <Button Content="convert" Width="50" Height="30" Click="Button_Click" Grid.Row="1" HorizontalAlignment="Left" Grid.Column="1" Margin="0,8,0,7"/>

    <StackPanel Grid.Column="1" Grid.Row="2" VerticalAlignment="Bottom" HorizontalAlignment="Left" Orientation="Horizontal" Height="27" >
      <Label Content="JSON input" VerticalAlignment="Bottom" HorizontalAlignment="Left" Margin="0,1" />
      <ComboBox Width="240" Margin="50,3,3,3" SelectionChanged="ComboBox_SelectionChanged" VerticalAlignment="Bottom">
        <ComboBoxItem  Name="ex1">Example 1</ComboBoxItem>
        <ComboBoxItem  Name="ex2">Example 2</ComboBoxItem>
        <ComboBoxItem  Name="ex3">Example 3</ComboBoxItem>
        <ComboBoxItem  Name="ex4">Example 4</ComboBoxItem>
      </ComboBox>
    </StackPanel>
    <TextBox  x:Name="JsonIn" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Grid.Column="1" Grid.Row="3"/>

    <GridSplitter Grid.Column="2" Grid.RowSpan="999" HorizontalAlignment="Stretch" Width="10" Background="Transparent" ResizeBehavior="PreviousAndNext"/>
    <GridSplitter Grid.Row="4" Grid.ColumnSpan="999" VerticalAlignment="Top" HorizontalAlignment="Stretch"  Height="10" Background="Transparent" ResizeBehavior="PreviousAndNext" />

    <Label Content="JSON output" Grid.Column="3" Grid.Row="2"  VerticalAlignment="Bottom" />
    <TextBox x:Name="JsonOut" VerticalAlignment="Stretch" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Grid.Column="3"  Grid.Row="3"  />

    <Label Content="C# source code output" Grid.Column="3" Grid.Row="4"  VerticalAlignment="Bottom" />
    <TextBox x:Name="CSharpSourceCode" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Grid.Column="3" Grid.Row="5" />
    <Label Content="LINQ input" Grid.Column="1" Grid.Row="4"  VerticalAlignment="Bottom"/>
    <TextBox x:Name="LinqIn" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Grid.Column="1" Grid.Row="5" />

  </Grid>

</Window>
using Microsoft.CSharp;
using System;
using System.CodeDom.Compiler;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using System.Windows;
using System.Windows.Controls;

namespace LinqJson {
  public partial class MainWindow : Window {

    public MainWindow() {
      InitializeComponent();
    } // constructor

    private void Button_Click(object sender, RoutedEventArgs e) {

      // --------------------------------------------------
      // get the JSON input
      // --------------------------------------------------

      string lJsonIn = JsonIn.Text;

      // --------------------------------------------------
      // construct the C# source code (class hierarchy)
      // --------------------------------------------------

      Converter lConverter = new Converter();
      Dictionary<string, object> lTree = lConverter.JsonToDictionary(lJsonIn);
      lConverter.DictionariesToClasses("root", 0, lTree);

      var lStringBuilder = new StringBuilder();
      lConverter.BuildClasses(lStringBuilder);
      string lCSharpSourceCode = lConverter.GetUsings() + lStringBuilder.ToString();

      // --------------------------------------------------
      // add the LINQ command to the source code
      // --------------------------------------------------

      string lLinq = LinqIn.Text;
      string lEntryPoint = "\n\n";
      lEntryPoint += "public class baseClass {\n";
      lEntryPoint += "  public static object executeLinq(string xJson) {\n";
      lEntryPoint += "    xJson = xJson.Trim();";
      lEntryPoint += "    if (xJson[0] == '[') xJson = \"{home: \" + xJson + \"}\";";
      lEntryPoint += "    var lSerializer = new JavaScriptSerializer();\n";
      lEntryPoint += "    var root = lSerializer.Deserialize<Class_root>(xJson);\n";
      lEntryPoint += "    var lResult = " + lLinq.Replace("\n", "\n        ") + ";\n";
      lEntryPoint += "    return lSerializer.Serialize(lResult);\n";
      lEntryPoint += "  }\n";
      lEntryPoint += "}\n";
      lCSharpSourceCode += lEntryPoint;

      // --------------------------------------------------
      // display the source code
      // --------------------------------------------------

      CSharpSourceCode.Text = lCSharpSourceCode;

      // --------------------------------------------------
      // compile the source code
      // --------------------------------------------------

      var lProviderOptions = new Dictionary<string, string>();
      lProviderOptions.Add("CompilerVersion", "v4.0");
      var lCSharpCodeProvider = new CSharpCodeProvider(lProviderOptions);
      var lCompilerParameters = new CompilerParameters();

      lCompilerParameters.ReferencedAssemblies.Add("System.dll");
      lCompilerParameters.ReferencedAssemblies.Add("System.Core.dll");
      lCompilerParameters.ReferencedAssemblies.Add("System.Data.Linq.dll");
      lCompilerParameters.ReferencedAssemblies.Add("System.Threading.dll");
      lCompilerParameters.ReferencedAssemblies.Add("System.Web.Extensions.dll");
      lCompilerParameters.ReferencedAssemblies.Add("System.Xml.Linq.dll");
      lCompilerParameters.GenerateInMemory = true;
      lCompilerParameters.GenerateExecutable = false;  // not required, we don't have a Main() method
      lCompilerParameters.IncludeDebugInformation = true;
      var lCompilerResults = lCSharpCodeProvider.CompileAssemblyFromSource(lCompilerParameters, lCSharpSourceCode);

      if (lCompilerResults.Errors.HasErrors) {
        var lError = new StringBuilder();

        foreach (CompilerError lCompilerError in lCompilerResults.Errors) {
          lError.AppendLine(lCompilerError.ErrorNumber + " => " + lCompilerError.ErrorText + Environment.NewLine);
        }

        JsonOut.TextWrapping = TextWrapping.Wrap;
        JsonOut.Text = lError.ToString();
        return;
      }
      JsonOut.TextWrapping = TextWrapping.NoWrap;

      // --------------------------------------------------
      // execute the compiled code
      // --------------------------------------------------

      Assembly lAssembly = lCompilerResults.CompiledAssembly;
      Type lProgram = lAssembly.GetType("baseClass");
      MethodInfo lMethod = lProgram.GetMethod("executeLinq");

      object lQueryResult = lMethod.Invoke(null, new object[] { lJsonIn });   // returns a JSON string object

      // --------------------------------------------------
      // rudimentary JSON output formatting
      // --------------------------------------------------

      string lJsonOut = lQueryResult.ToString();
      lJsonOut = lJsonOut.Replace(",", ",\n");
      lJsonOut = lJsonOut.Replace(",{", ",{\n");
      lJsonOut = lJsonOut.Replace("]", "]\n");
      JsonOut.Text = lJsonOut;
    } //

    private void ComboBox_SelectionChanged(object xSender, SelectionChangedEventArgs e) {
      var lComboBox = xSender as ComboBox;

      switch (lComboBox.SelectedIndex) {
        case 0:
          JsonIn.Text = "{\n \"number\": 108.541,\n \"datetime\": \"1975-03-13T10:30:00\" ,\n \"serialnumber\": \"SN1234\",\n \"more\": {\n  \"field1\": 123,\n  \"field2\": \"hello\"\n },\n \"array\": [\n  {\"x\": 2.0},\n  {\"x\": 3.0},\n  {\"x\": 4.0}\n ]\n}";
          LinqIn.Text = "from a in root.array\nwhere a.x > 2.0M\nselect a";
          break;
        case 1:
          JsonIn.Text = "[ 1, 9, 5, 7, 1, 4 ]";
          LinqIn.Text = "from a in root.home\nwhere ((a == 4) || (a == 1))\nselect a";
          break;
        case 2:
          JsonIn.Text = "{myLuckyNumbers: [ 1, 9, 5, 26, 7, 1, 4 ]}";
          LinqIn.Text = "from x in root.myLuckyNumbers\nwhere x % 2 == 0\nselect new { simple=x, square=x*x, text=\"Field\"+x.ToString(), classInClass=new {veryOdd=x/7.0, lol=\":D\"}}";
          break;
        case 3:
          string s;
          s = "{\"mainMenu\": {\n";
          s += "  \"info\": \"Great tool.\",\n";
          s += "  \"value\": 100.00,\n";
          s += "  \"menu\": {\n";
          s += "  \"subMenu\": [\n";
          s += "    {\"text\": \"New\", \"onclick\": \"NewObject()\"},\n";
          s += "    {\"text\": \"Open\", \"onclick\": \"Load()\"},\n";
          s += "    {\"text\": \"Close\", \"onclick\": \"ByeBye()\"},\n";
          s += "    {\"text\": \"NNN\", \"onclick\": \"Useless()\"}\n";
          s += "  ]}\n";
          s += "}}\n";
          JsonIn.Text = s;
          LinqIn.Text = "root.mainMenu.menu.subMenu.Where(t => t.text.StartsWith(\"N\")).Last();";

          break;
        default:
          break;
      }
    } //

  } // class
} // namespace
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Script.Serialization;  // requires reference to System.Web.Extensions, used by the JavaScriptSerializer

namespace LinqJson {
  public class Converter {

    private Dictionary<string, object> _Classes = new Dictionary<string, object>();

    public string GetUsings() {
      string s;
      s = "using System;\n";
      s += "using System.Collections.Generic;\n";
      s += "using System.Linq;\nusing System.Text;\n";
      s += "using System.Threading.Tasks;\n";
      s += "using System.Collections;\n";
      s += "using System.Web.Script.Serialization;\n\n";
      return s;
    } //

    public Dictionary<string, object> JsonToDictionary(string xJson) {
      xJson = xJson.Trim();
      if (xJson[0] == '[') xJson = "{home: " + xJson + "}";  // nameless arrays cannot be converted to dictionaries

      var lJavaScriptSerializer = new JavaScriptSerializer();
      try { return lJavaScriptSerializer.Deserialize<Dictionary<string, object>>(xJson); }
      catch (Exception) { return null; }
    } //

    public void BuildClasses(StringBuilder xStringBuilder) {
      foreach (var lClass in _Classes) {
        Dictionary<string, object> lMembers = lClass.Value as Dictionary<string, object>;
        if (lMembers == null) continue;
        if (lMembers.Count <= 0) continue;

        xStringBuilder.Append("public class Class_");
        xStringBuilder.Append(lClass.Key);
        xStringBuilder.AppendLine(" {");

        foreach (var lMember in lMembers) {
          object lValue = lMember.Value;
          string lKey = lMember.Key;
          Type lType = (lValue == null) ? typeof(object) : lMember.Value.GetType();

          xStringBuilder.Append(new String(' ', 2));
          xStringBuilder.Append("public ");

          if (lType.IsValueType || (lValue is string)) {
            xStringBuilder.Append(lType.Name);
            xStringBuilder.Append(" ");
            xStringBuilder.Append(lKey);
            xStringBuilder.AppendLine(";");
          }
          else if (lValue is Dictionary<string, object>) {
            xStringBuilder.Append("Class_");
            xStringBuilder.Append(lKey);
            xStringBuilder.Append(" ");
            xStringBuilder.Append(lKey);
            xStringBuilder.AppendLine(";");
          }
          else if (lValue is ArrayList) {
            ArrayList lArrayList = lValue as ArrayList;
            var lMemberType = ArrayListType(lArrayList);   // differentiate between the contents of the list
            if (lMemberType.IsValueType || (lMemberType.Name == "String")) {
              //xStringBuilder.Append(lMemberType.Name.Replace("`2")); // Dictionaries use name "Dictionary`2"
              xStringBuilder.Append(" List<");               xStringBuilder.Append(lMemberType.Name);               xStringBuilder.Append("> ");
              xStringBuilder.Append(lKey);
              xStringBuilder.AppendLine(";");
            }
            else {
              // a class
              xStringBuilder.Append("List<Class_" + lKey + "> ");
              xStringBuilder.Append(lKey);
              xStringBuilder.AppendLine(";");
            }
          }
        }
        xStringBuilder.AppendLine(" }");
        xStringBuilder.AppendLine();
      }
    } //

    public void DictionariesToClasses(string xRootName, int xIndent, object xObject) {
      if (xObject == null) return;
      Type lType = xObject.GetType();

      if (lType.IsValueType || (xObject is string)) return;
      var lDictionary = xObject as Dictionary<string, object>;
      if (lDictionary != null) {
        object lObj;
        if (!_Classes.TryGetValue(xRootName, out lObj)) {
          _Classes.Add(xRootName, lDictionary);
        }
        else {
          foreach (var lKeyValuePair in lDictionary) {
            // This is a weakness of the program.
            // Two JSON objects must not use the same name.
            // We would have to compare the JSON objects and determine wether to create multiple or just one C# class.
            _Classes[lKeyValuePair.Key] = lKeyValuePair.Value;  // object type will be overridden !!!!!!
          }
          return;
        }

        foreach (var lKeyValuePair in lDictionary) {
          DictionariesToClasses(lKeyValuePair.Key, xIndent, lKeyValuePair.Value);
        }
        return;
      }

      var lArrayList = xObject as ArrayList;
      if (lArrayList != null) {
        object lObj;
        if (!_Classes.TryGetValue(xRootName, out lObj)) {
          lDictionary = new Dictionary<string, object>();
          _Classes.Add(xRootName, lDictionary);
        }
        else lDictionary = lObj as Dictionary<string, object>;

        var lElementType = ArrayListType(lArrayList);
        if (lElementType == typeof(Dictionary<string, object>)) {
          var lList = lArrayList.Cast<Dictionary<string, object>>().ToList(); // upgrade our object to have stronger types
          foreach (var lDict in lList) {
            foreach (var lKeyValuePair in lDict) {
              lDictionary[lKeyValuePair.Key] = lKeyValuePair.Value;  // object type will be overridden !!!!!!
            }
          }
          foreach (var lKeyValuePair in lDictionary) {
            DictionariesToClasses(lKeyValuePair.Key, xIndent, lKeyValuePair.Value);
          }
        }
        return;
      }
    } //

    private void Append(StringBuilder xStringbuilder, int xIndent, string xString) {
      xStringbuilder.Append(new String(' ', xIndent));
      xStringbuilder.Append(xString);
    } //

    private void Newline(StringBuilder xStringbuilder) {
      xStringbuilder.AppendLine();
    } //

    private Type ArrayListType(ArrayList xArrayList) {
      var lTypes = new Dictionary<string, Type>();
      Type lType;
      string lTypeName;

      foreach (object o in xArrayList) {
        if (o == null) lType = typeof(object);
        else lType = o.GetType();
        lTypeName = lType.Name;
        if (!lTypes.ContainsKey(lTypeName)) lTypes.Add(lTypeName, lType);
      }

      if (lTypes.Count == 1) return lTypes.Values.First();  // distinct
      return typeof(object);
    } //

  } // class
} // namespace

OpenTK, entering the C# 3D world

I was amazed how short the source code can be to show complex moving objects. It does take a bit of research and the OpenTK library though. There are a lot of very good YouTube tutorials out there. Not necessarily about OpenTK, but plenty about OpenGL, which is what OpenTK is using.

I asked my daughters to draw some Minecraft style avatars with a simple icon editor. They had their fun and I got something to display in 3D. I stored three icon files (Freya.ico, Merlin.ico and Steve.ico) in a subfolder called ‘Resources’.

OpenTk

Change the file properties. ‘Build Action’ should be set to ‘None’, because you don’t have to compile these files. And you don’t want to copy the files each time you run the compiler. Simply set ‘Copy to Output Directory’ to ‘Copy if newer’.

OpenTk2

The next step is to install and reference the OpenTK library (OpenTK, OpenTK.Compatibility and OpenTK.GLControl). Be aware that there is no real WPF support for OpenTK. You only host a Winform control within WPF. Therefore you should also reference “WindowsFormsIntegration”.
You can also open a pure OpenTK window. But it is impossible to add further WPF controls to the same window then. We are far away from programming games, so it is always a good option to leave the backdoor open for further WPF. You might want to add sliders to set angles and object positions.

 

OpenTk3

 

Sorry for posting less for a while. I am working like a dog – leaving home at 6am and returning around 10pm during the week.

 

<Window x:Class="OpenTkControl.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:WinF="clr-namespace:System.Windows.Forms;assembly=System.Windows.Forms"
        xmlns:OpenTK="clr-namespace:OpenTK;assembly=OpenTK.GLControl"
        Title="OpenTK Demo" Height="600" Width="800">
  <DockPanel LastChildFill="True">
    <WindowsFormsHost  x:Name="WinFormsContainer" Background="Transparent" DockPanel.Dock="Top"  >
      <OpenTK:GLControl x:Name="OpenTkControl" 
                        Paint="OpenTkControl_Paint" Dock="Fill" />
    </WindowsFormsHost>
  </DockPanel>
</Window>
using OpenTK;
using OpenTK.Graphics.OpenGL;
using System;
using System.Drawing;
using System.Windows;
using System.Windows.Threading;

namespace OpenTkControl {
  public partial class MainWindow : Window {

    private DispatcherTimer _Timer;
    private DateTime _ProgramStartTime;

    public MainWindow() {
      InitializeComponent();

      _ProgramStartTime = DateTime.Now;

      _Timer = new DispatcherTimer(DispatcherPriority.Send);
      _Timer.IsEnabled = true;
      _Timer.Interval = new TimeSpan(0, 0, 0, 0, 30);
      _Timer.Tick += OnTimer;
      _Timer.Start();
    } // constructor

    void OnTimer(object sender, EventArgs e) {
      OpenTkControl.Invalidate();
    } //

    private void OpenTkControl_Paint(object sender, System.Windows.Forms.PaintEventArgs e) {
      GLControl lControl = OpenTkControl;

      // Reset the depth and color buffer.
      // We want to render a new world. We do not want to continue with a previous rendering.
      GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

      // Create a projection matrix transforming camera space to raster space. (google for "view frustum")
      // Which is like: Press the 3D world and make it flat like a pancake, so that it does fit on the 2D screen.
      //                All points between a distance of 1 and 1000 will be taken into account.
      float lAngleView = 1.2f;              // y direction (in radians)
      float lAspectRatio = 4f / 3f;         // width / height
      float lDistanceToNearClipPlane = 1f;
      float lDistanceToFarClipPlane = 1000f;
      Matrix4 lPerspective = Matrix4.CreatePerspectiveFieldOfView(lAngleView, lAspectRatio, lDistanceToNearClipPlane, lDistanceToFarClipPlane);
      GL.MatrixMode(MatrixMode.Projection); GL.LoadIdentity(); GL.LoadMatrix(ref lPerspective);

      // camera setup
      Vector3 lCameraLocation = new Vector3(100f, 10f, 0f);
      Vector3 lCameraLookingAt = new Vector3(0f, 0f, 0f);     // look at the center of the coordinate system
      Vector3 lCameraWhatIsUpside = new Vector3(0f, 1f, 0f);  // classical way to hold a camera
      Matrix4 lCamera = Matrix4.LookAt(lCameraLocation, lCameraLookingAt, lCameraWhatIsUpside);
      GL.MatrixMode(MatrixMode.Modelview); GL.LoadIdentity(); GL.LoadMatrix(ref lCamera);

      // this is the size on the screen
      GL.Viewport(0, 0, lControl.Width, lControl.Height);

      // only draw the nearest pixels and not pixels that are actually hidden by other pixels 
      GL.Enable(EnableCap.DepthTest);
      GL.DepthFunc(DepthFunction.Less);

      // set time dependent variables to generate movements
      double lTotalMillis = DateTime.Now.Subtract(_ProgramStartTime).TotalMilliseconds;
      double lTime1 = (lTotalMillis % 10000.0) / 10000.0; // between 0 and 1
      double lTime2 = (lTotalMillis % 2000.0) / 2000.0;   // between 0 and 1
      double lTimeRadians = lTime2 * 2.0 * Math.PI;
      float lJump = (float)(-20.0 + 10.0 * Math.Sin(lTimeRadians));
      float lRadius = -40f;

      // add the comet
      DrawComet(lTotalMillis);

      // render the floor
      GL.Rotate(360.0 * lTime1, 0.0, 1.0, 0.5);  // rotate around y axis and half as much around z axis
      DrawFloor();

      // render objects
      // from where we are; now rotate the objects into the opposite direction
      GL.Rotate(-lTime1 * 360.0 * 2.0, 0.0, 1.0, 0.0); DrawAvatar("Merlin", -30f, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Freya", lJump, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Steve", -30f, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Merlin", lJump, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Freya", -30f, lRadius);
      GL.Rotate(60.0, 0.0, 1.0, 0.0); DrawAvatar("Steve", lJump, lRadius);

      // render the cube in the center
      //GL.Rotate(360f * lTime2, 0f, 0f, 0f); // <= this kind of rotation lets the box bounce and change its size
      DrawCube(Color.SteelBlue, Color.DarkBlue, 0f, -25f, 0f, 8f, false);

      OpenTK.Graphics.GraphicsContext.CurrentContext.VSync = true; // caps GPU frame rate
      lControl.SwapBuffers();  // display our newly generated buffer with all objects
    } //

    private void DrawAvatar(string xName, float yShift, float zShift) {
      Icon lIcon = new Icon("Resources/" + xName + ".ico");
      Bitmap lBitmap = lIcon.ToBitmap();
      int lWidth = lBitmap.Width; float lHalfWidth = lWidth / 2f;
      int lHeight = lBitmap.Height; float lHalfHeight = lHeight;
      for (int y = 0; y < lHeight; y++) {
        for (int x = 0; x < lWidth; x++) {
          Color lColor = lBitmap.GetPixel(x, y);
          if (lColor.A != 0) DrawCube(lColor, lColor, (float)x - lHalfWidth, lHeight + yShift - (float)y, (float)zShift, 1f, true);
        }
      }
    } //

    private void DrawFloor() {
      for (int x = -100; x < 100; x += 10) {
        for (int z = -100 + (x % 10 == 0 ? 5 : 0); z < 100; z += 10) {
          DrawCube(Color.White, Color.Gray, x, -30f, z, 5f, false);
        }
      }
    } //

    private void DrawComet(double xTotalMillis) {
      xTotalMillis = (xTotalMillis % 7000.0) / 7000.0; // between 0 and 1

      GL.PushMatrix();
      GL.LoadIdentity();
      GL.Translate(xTotalMillis * 30f - 40f , 40f,  400f * xTotalMillis - 400f);
      GL.Rotate(360f * xTotalMillis * 3f, 1f, 1f, 1f);
      DrawTetrahedron(Color.Orange, Color.OrangeRed, 0f, 0f, 0f, 8f);
      GL.Rotate(180f, 1f, 0f, 0f);
      DrawTetrahedron(Color.Orange, Color.OrangeRed, 0f, 0f, 0f, 8f);
      GL.PopMatrix();
    } //

    private void DrawCube(System.Drawing.Color xColor, System.Drawing.Color xColor2, float X, float Y, float Z, float xWidth, bool xHasDarkBack) {
      float lHalfWidth = xWidth / 2f;
      float lTop = Y + lHalfWidth;
      float lBottom = Y - lHalfWidth;
      float lLeft = X - lHalfWidth;
      float lRight = X + lHalfWidth;
      float lFront = Z + lHalfWidth;
      float lRear = Z - lHalfWidth;

      GL.Begin(PrimitiveType.Quads);

      Color lColor; if (xHasDarkBack) lColor = Color.DarkGray; else lColor = xColor;
      Color lColor2; if (xHasDarkBack) lColor2 = Color.DarkGray; else lColor2 = xColor2;

      Action lPointFrontTopLeft = () => { GL.Color3(xColor); GL.Vertex3(lLeft, lTop, lFront); };
      Action lPointFrontTopRight = () => { GL.Color3(xColor2); GL.Vertex3(lRight, lTop, lFront); };
      Action lPointFrontBottomLeft = () => { GL.Color3(xColor2); GL.Vertex3(lLeft, lBottom, lFront); };
      Action lPointFrontBottomRight = () => { GL.Color3(xColor2); GL.Vertex3(lRight, lBottom, lFront); };
      Action lPointRearTopLeft = () => { GL.Color3(lColor); GL.Vertex3(lLeft, lTop, lRear); };
      Action lPointRearTopRight = () => { GL.Color3(lColor2); GL.Vertex3(lRight, lTop, lRear); };
      Action lPointRearBottomLeft = () => { GL.Color3(lColor2); GL.Vertex3(lLeft, lBottom, lRear); };
      Action lPointRearBottomRight = () => { GL.Color3(lColor2); GL.Vertex3(lRight, lBottom, lRear); };

      // front square
      lPointFrontTopLeft(); lPointFrontTopRight(); lPointFrontBottomRight(); lPointFrontBottomLeft();

      // rear square
      lPointRearTopLeft(); lPointRearTopRight(); lPointRearBottomRight(); lPointRearBottomLeft();

      // top square
      lPointFrontTopLeft(); lPointFrontTopRight(); lPointRearTopRight(); lPointRearTopLeft();

      // bottom square
      lPointFrontBottomLeft(); lPointFrontBottomRight(); lPointRearBottomRight(); lPointRearBottomLeft();

      // left square
      lPointFrontTopLeft(); lPointRearTopLeft(); lPointRearBottomLeft(); lPointFrontBottomLeft();

      // right square
      lPointFrontTopRight(); lPointRearTopRight(); lPointRearBottomRight(); lPointFrontBottomRight();

      GL.End();
    } //

    private void DrawTetrahedron(System.Drawing.Color xColor, System.Drawing.Color xColor2, float X, float Y, float Z, float xSideLength) {
      float lDistMidToVertex = (float)Math.Sqrt(6.0) / 4f * xSideLength;
      float lDistMidToFloor = (float)Math.Sqrt(6.0) / 12f * xSideLength;
      float lHeight = (float)Math.Sqrt(2.0 / 3.0) * xSideLength; // = lDistMidToVertex + lDistMidToEdge
      float lTop = Y + lDistMidToVertex;
      float lBottom = Y - lDistMidToFloor;
      float lRight = X + xSideLength / 2f;
      float lLeft = X - xSideLength / 2f;
      float lRear = Z - (float) (xSideLength * Math.Sqrt(3.0) / 3.0);
      float lFront = Z + (float)(xSideLength * Math.Sqrt(3.0) / 6.0);

      GL.Begin(PrimitiveType.Triangles);

      Action lPointTop = () => { GL.Color3(xColor); GL.Vertex3(X, lTop, Z); };
      Action lPointFrontBottomLeft = () => { GL.Color3(xColor2); GL.Vertex3(lLeft, lBottom, lFront); };
      Action lPointFrontBottomRight = () => { GL.Color3(xColor); GL.Vertex3(lRight, lBottom, lFront); };
      Action lPointRear = () => { GL.Color3(xColor2); GL.Vertex3(X, lBottom, lRear); };

      // front triangle
      lPointTop(); lPointFrontBottomLeft(); lPointFrontBottomRight();

      // left triangle
      lPointTop(); lPointFrontBottomLeft(); lPointRear();

      // right triangle
      lPointTop(); lPointFrontBottomRight(); lPointRear();

      // bottom triangle
      lPointFrontBottomLeft(); lPointFrontBottomRight(); lPointRear();

      GL.End();
    } //

  } // class
} // namespace

 

Slogan / News Ticker / Scrolling Text in WPF (Part 2)

 

This is the follow-up of my last post.

I promised to enhance the scroll behavior. Here it is. You can influence the text in various ways. When the zoom factor turns negative you can even see upside down characters. Also the drift can turn negative; if so the wave moves to the left, but its peaks drift to the right.
Weirdly enough it is a lot of fun playing with the sliders and watching the wave change. Of course, we could still create far more complex movements. This source code here is providing the full basics. The rest is up to you.

What you can influence:

  • text zoom  (negative numbers turn the text upside down)
  • scroll speed
  • wave amplitude
  • wave drift
  • wave length

I had to slightly change the way the vectors are stored to enable zooming. The y-zero-line now is in the middle of the characters and not at the top anymore. Anything above that line is negative, anything below positive. Hence the vectors can be multiplied with a zoom factor without the need to recalculate the position in the WPF control.

<Window x:Class="Ticker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Ticker"
        Height="250"
        Width="500"
        Loaded="Window_Loaded">
  <DockPanel x:Name="MyDockPanel"
             SizeChanged="MyImage_SizeChanged"
             LastChildFill="True">
    <Grid DockPanel.Dock="Top">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="110"/>
        <ColumnDefinition Width="50*"/>
        <ColumnDefinition Width="110"/>
        <ColumnDefinition Width="50*"/>
      </Grid.ColumnDefinitions>
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
      </Grid.RowDefinitions>

      <Label Content="calculation speed" Grid.Row="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <TextBox Name="Info"
               Grid.Row="0" Grid.Column="1"/>

      <Label Content="zoom" Grid.Row="0" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="-1.0" Maximum="1.0" Value="1.0"
              ValueChanged="Slider_Zoom_ValueChanged"
              Grid.Row="0" Grid.Column="3" />

      <Label Content="scroll speed" Grid.Row="1" Grid.Column="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.5" Maximum="8.0" Value="2.0"
              ValueChanged="Slider_ScrollSpeed_ValueChanged"
              Grid.Row="1" Grid.Column="1" />

      <Label Content="wave amplitude" Grid.Row="1" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.0" Maximum="100.0" Value="50.0"
              ValueChanged="Slider_Amplitude_ValueChanged"
              Grid.Row="1" Grid.Column="3" />

      <Label Content="wave drift" Grid.Row="2" Grid.Column="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="-10.0" Maximum="10.0" Value="5.0"
              ValueChanged="Slider_Drift_ValueChanged"
              Grid.Row="2" Grid.Column="1" />

      <Label Content="wave length" Grid.Row="2" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.0" Maximum="2.0" Value="0.5"
              ValueChanged="Slider_WaveLength_ValueChanged"
              Grid.Row="2" Grid.Column="3" />
    </Grid>
    <Image Name="MyImage"
           DockPanel.Dock="Top"
           Stretch="None"
           Width="{Binding ActualWidth, ElementName=MyDockPanel}"/>
  </DockPanel>
</Window>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using System.Windows.Threading;

namespace Ticker {

  public partial class MainWindow : Window {

    private Thread _Thread;
    private TextEngine _TextEngine = new TextEngine();
    private float _WidthInPixels;
    private double _Speed = 1.0; // number of pixels to shift per iteration
    private AutoResetEvent _AutoResetEvent = new AutoResetEvent(true);
    private BitmapImage _BitmapImage = null;
    private string _ElapsedTime = string.Empty;

    public MainWindow() {
      InitializeComponent();
    } //  constructor

    private void Window_Loaded(object sender, EventArgs e) {
      DataContext = this;

      _Thread = new Thread(Loop);
      _Thread.Name = "MainLoop";
      _Thread.IsBackground = true;
      _Thread.Priority = ThreadPriority.AboveNormal;
      _Thread.Start();

      TimeSpan lInterval = new TimeSpan(0, 0, 0, 0, 50); // run each 50 ms
      EventHandler lHandler = new EventHandler(OnTime);
      DispatcherTimer lDispatcherTimer = new DispatcherTimer(lInterval, DispatcherPriority.Send, lHandler, this.Dispatcher);
    } //

    private void MyImage_SizeChanged(object sender, SizeChangedEventArgs e) {
      DockPanel lDockPanel = sender as DockPanel;
      if (lDockPanel == null) return;
      using (var lGraphics = Graphics.FromHwnd(IntPtr.Zero)) {
        _WidthInPixels = (float)(e.NewSize.Width * lGraphics.DpiX / 96.0);
      }
    } //

    public void OnTime(object XSender, EventArgs e) {
      BitmapImage lBitmapImage = _BitmapImage;
      if (lBitmapImage == null) return;

      MyImage.Source = lBitmapImage;
      Line.adjustDrift();
      Info.Text = _ElapsedTime;
      _AutoResetEvent.Set();
    } //

    private void Loop() {
      float lEnd = 0f;
      int lSectionFrom = 0;
      int lSectionTo = 0;
      Stopwatch lStopwatch = new Stopwatch();

      while (true) {
        _AutoResetEvent.WaitOne();
        lStopwatch.Restart();

        float lWidthInPixel = _WidthInPixels; // copy the value to avoid changes during the calculation
        if (lWidthInPixel <= 0.0) continue;
        List<Line> lSection = _TextEngine.getVectorSection(ref lSectionFrom, ref lSectionTo, lEnd, lWidthInPixel);

        // This value determines the speed.
        // Even numbers give better results due to the rounding error nature of bitmaps.
        // Odd numbers create jitter. Luckily humans have bad eyes, they cannot perceive it.
        lEnd += (float)_Speed;

        if (lSection == null) {
          // end reached, reset text
          lSectionFrom = 0;
          lSectionTo = 0;
          lEnd = 0f;
        }
        else {
          Bitmap lBitmap = _TextEngine.VectorsToBitmap(lSection, lWidthInPixel);
          _BitmapImage = _TextEngine.BitmapToImageSource(lBitmap);
        }

        _ElapsedTime = lStopwatch.ElapsedMilliseconds.ToString("#,##0");
        //lStopwatch.Stop();
      }
    } //

    private void Slider_ScrollSpeed_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { _Speed = e.NewValue; }
    private void Slider_Amplitude_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Amplitude = (float)e.NewValue; }
    private void Slider_Drift_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Drift = (float)e.NewValue; }
    private void Slider_WaveLength_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.WaveLength = (float)e.NewValue; }
    private void Slider_Zoom_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Zoom = (float)e.NewValue; }

  } // class
} // namespace
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Windows.Media.Imaging;

namespace Ticker {
  public class TextEngine {

    private const float cFontHeight = 40f;
    private const string cText = @"Die Gedanken sind frei, wer kann sie erraten, sie fliegen vorbei wie nächtliche Schatten. Kein Mensch kann sie wissen, kein Jäger erschießen mit Pulver und Blei: Die Gedanken sind frei! Ich denke was ich will und was mich beglücket, doch alles in der Still', und wie es sich schicket. Mein Wunsch und Begehren kann niemand verwehren, es bleibet dabei: Die Gedanken sind frei! Und sperrt man mich ein im finsteren Kerker, das alles sind rein vergebliche Werke. Denn meine Gedanken zerreißen die Schranken und Mauern entzwei: Die Gedanken sind frei! Drum will ich auf immer den Sorgen entsagen und will mich auch nimmer mit Grillen mehr plagen. Man kann ja im Herzen stets lachen und scherzen und denken dabei: Die Gedanken sind frei! Ich liebe den Wein, mein Mädchen vor allen, sie tut mir allein am besten gefallen. Ich sitz nicht alleine bei meinem Glas Weine, mein Mädchen dabei: Die Gedanken sind frei!";
    private List<Line> _TextAsVectorChain = new List<Line>();
    private Font _Font = new Font("Arial", cFontHeight, System.Drawing.FontStyle.Bold, GraphicsUnit.Pixel);

    public TextEngine() {
      // convert the entire text to vectors
      float lPosition = 0;
      Dictionary<char, List<Line>> lVectorCache = new Dictionary<char, List<Line>>();
      char[] lChars = cText.ToCharArray();

      foreach (char lChar in lChars) {
        if (lChar == ' ') lPosition += 10; // distance for an empty space character
        else {
          List<Line> lOneCharVectors;

          if (!lVectorCache.TryGetValue(lChar, out lOneCharVectors)) {
            Bitmap lBitmap = CharToBitmap(lChar);
            lOneCharVectors = BitmapToVectors(lBitmap);
            lVectorCache.Add(lChar, lOneCharVectors);
          }

          float lNewPosition = lPosition;
          foreach (Line lLine in lOneCharVectors) {
            Line lClone = lLine.Clone();
            lClone.X += lPosition;
            lNewPosition = lClone.X;
            _TextAsVectorChain.Add(lClone);
          }
          lPosition = lNewPosition + 4; // 4 == space between two characters
        }

      }
    } // constructor

    // Convert a bitmap to an ImageSource.
    // We can then display the result in the WPF Image element.
    public BitmapImage BitmapToImageSource(Bitmap xBitmap) {
      using (MemoryStream lMemoryStream = new MemoryStream()) {
        xBitmap.Save(lMemoryStream, System.Drawing.Imaging.ImageFormat.Bmp);
        lMemoryStream.Position = 0;
        BitmapImage lBitmapImage = new BitmapImage();
        lBitmapImage.BeginInit();
        lBitmapImage.StreamSource = lMemoryStream;
        lBitmapImage.CacheOption = BitmapCacheOption.OnLoad;
        lBitmapImage.EndInit();
        lBitmapImage.Freeze();
        return lBitmapImage;
      }
    } //

    // draw a single character into a bitmap
    private Bitmap _Bitmap = null;
    public Bitmap CharToBitmap(char xChar) {
      _Bitmap = new Bitmap((int)(cFontHeight * 1.25f), (int)(cFontHeight * 1.25f));
      using (Graphics lGraphics = Graphics.FromImage(_Bitmap)) {
        lGraphics.Clear(Color.White);
        lGraphics.DrawString(xChar.ToString(), _Font, Brushes.Black, 0f, 0f);
      }
      return _Bitmap;
    } //

    // Replicate the characters now by reading the vectors and drawing lines.
    Pen lPen = new Pen(Color.Black, 2f);
    public Bitmap VectorsToBitmap(List<Line> xLines, float xBitmapWidth) {
      int lHeight = (int)cFontHeight + (int)Math.Abs(Line.Amplitude);
      Bitmap lBitmap = new Bitmap((int)xBitmapWidth, lHeight);
      lHeight /= 2;   // half height, rounded down
      using (Graphics lGraphics = Graphics.FromImage(lBitmap)) {
        lGraphics.Clear(Color.White);
        foreach (Line lLine in xLines) {
          lGraphics.DrawLine(lPen, lLine.X, lLine.Y1 + lHeight, lLine.X, lLine.Y2 + lHeight);
        }
      }
      return lBitmap;
    } //

    // Convert a single character to vectors.
    private List<Line> BitmapToVectors(Bitmap xBitmap) {
      int lXCoordinateOfFirstPixel = -1;
      List<Line> lList = new List<Line>();

      for (int x = 0, lWidth = xBitmap.Width; x < lWidth; x++) {
        Line lVector = null;
        for (int y = 0, lHeight = xBitmap.Height; y < lHeight; y++) {
          Color lColor = xBitmap.GetPixel(x, y);
          bool lIsWhite = lColor.B == 255;
          if (lIsWhite) {
            if (lVector != null) {
              lList.Add(lVector);
              lVector = null;
            }
          }
          else {
            int lHalfHeight = xBitmap.Height / 2;
            if (lVector == null) {
              if (lXCoordinateOfFirstPixel < 0) lXCoordinateOfFirstPixel = x;  // to always start at zero for our vectors
              lVector = new Line { X = x - lXCoordinateOfFirstPixel, Y1 = y - lHalfHeight, Y2 = y - lHalfHeight };
            }
            else lVector.Y2 = y - lHalfHeight;
          }
        }
      }

      return lList;
    } //

    // The text was converted to vectors.
    // Now we cut out the sequence we need for the display.
    internal List<Line> getVectorSection(ref int xSectionFrom, ref int xSectionTo, float xEnd, float xWidth) {
      int lCount = _TextAsVectorChain.Count;
      float lStart = xEnd - xWidth;

      // find the right section
      do {
        xSectionTo++;
        if (xSectionTo >= lCount) { xSectionTo = lCount - 1; break; }
        if (xEnd < _TextAsVectorChain[xSectionTo].X) break;
      } while (true);

      do {
        if (lStart < 0) break; // to allow empty spaces at the beginning of the slogan
        if (xSectionFrom >= lCount) return null;
        if (lStart < _TextAsVectorChain[xSectionFrom].X) break;
        xSectionFrom++;
      } while (true);

      // clone that section
      List<Line> lList = new List<Line>();
      for (int x = xSectionFrom; x <= xSectionTo; x++) {
        Line lClone = _TextAsVectorChain[x].Clone();
        lClone.X -= lStart; // adjust the X-axis
        lClone.RecalcYShift();
        lList.Add(lClone);
      }

      return lList;
    } //

  } // class
} // namespace
using System;

namespace Ticker {
  public class Line {
    private float _Y1, _Y1Shift;
    private float _Y2, _Y2Shift;
    private static float _XDrift = 0f;

    public float X { get; set; }
    public static float Amplitude { get; set; }    // This is the additional height needed for the vertical swing
    public static float Drift { get; set; }        // How fast does the wave crest/trough move?
    public static float WaveLength { get; set; }   // How wide is the wave?
    public static float Zoom { get; set; }         // Character zoom factor around the horizontal middle.

    public static void adjustDrift() { _XDrift += Drift; }

    public float Y1 {
      get { return _Y1 * Zoom + _Y1Shift; }
      set { _Y1 = value; }
    } //

    public float Y2 {
      get { return _Y2 * Zoom + _Y2Shift; }
      set { _Y2 = value; }
    } //

    public void RecalcYShift() {
      double d = (double)(WaveLength * (X + _XDrift));
      d = Math.IEEERemainder(d / 50.0, 1.99999999999);
      float lAngle = (float)Math.Sin(Math.PI * d);          // 0.0 <= d < 2.0  therefore -1.0 <= lAngle <= 1.0
      float lShift = Amplitude / 2.0f * lAngle;
      _Y1Shift = lShift;
      _Y2Shift = lShift;
    } //

    public Line Clone() {
      Line lLine = new Line();
      lLine.X = X;
      lLine.Y1 = _Y1;
      lLine.Y2 = _Y2;
      return lLine;
    } //

  } // class
} // namespace

Slogan / News Ticker / Scrolling Text in WPF

Ticker

Oh, I used to write so called intros with scrolling text slogans at the bottom on the Commodore Amiga. The hardware was pretty much unalterable and you could easily work with the Blitter and Copper. The vertical blank beam on the monitor was my timer. These days are over. No more Assembler. And who cares about high and low byte these days?

I have to admit that using C# in combination with WPF is not the best approach for moving text around. You should directly talk to graphic cards for such. Anyway, it started with a short benchmark test and ended up with this example program. If you program properly, then you can make WPF do the job without any flickering.

You might wonder why my approach is quite indirect this time. I am not simply rendering text to the screen and then change its position. This program is slightly more complex. The reason is that I am planning a follow-up with more complex movements soon. I am planning to show you how to scroll text along the screen in a sinus curve while the curve itself behaves like a wave. Maybe I will add some topsy-turvy stuff as well. Hehe, it is not written yet.

This post is providing the basics to get prepared for the next step. Today, the text simply moves from the right to the left. And no worries about the end of the text. It is dealt with properly.
You can change the speed with the slider while the text field returns the calculation time in milliseconds. You can see that we still have some timing leeway. There are no issues with our frequency, which is 20 updates per second. The screen itself obviously updates more often. Its frequency has little to do with the one for showing new BitmapImages. Our thread loop takes less than 20 milliseconds.

How does the program work?

  • A loop is running on an independent thread. This loop executes each time right after our WPF update. This well chosen moment avoids timing conflicts efficiently. The next BitmapImage is ready to be shown before the next WPF GUI update.
  • At the beginning of the program we convert the entire text to vectors. The orientation of all vectors is vertical. Each x-value can consist of several vertical vectors. For instance the character “E” has 1 or 3 vertical vectors. The first vector is a straight vertical black line from the top to the bottom. When you are in the middle of the character, then you have three black and two white areas. We only convert the black parts. Imagine a vertical cut right through the middle from the top to the bottom.
  • The conversion from “character to bitmap to vector” is NOT conducted in real-time. This is done during the program initialization as mentioned above. Only then we can run at full steam afterwards.
  • We only change the precalculated vectors and render the bitmap in real-time – nothing else.
  • These vectors are then drawn to bitmaps. These bitmaps are not dependent on the Dispatcher thread. We are still running at maximum speed. Before we can change the source for the Image control, we have to convert the Bitmap class to an ImageSource.
  • This ImageSource is then assigned to the Image control during a DispatcherTimer event.

Once again the program was kept to a minimum length. Maybe I should have added some more classes. I hope I found the right length for this example program in the end.

This code was not straight forward. There had to be a learning curve … as usual. First, I tried some direct WPF binding. The timing was nearly out of control. So I gave up after a while. But I had some nice blog ideas in the meantime. We should enter the 3D world at some point, don’t you think so?
The 3D approach would most likely the best approach for our scrolling text as well. I expect the efficiency to be extreme. There are so many games with millions of mesh triangles rendered within milliseconds – this cannot be bad stuff. We could even use 3D features for showing 2D text.

This will surely require some profound research on my side. Don’t expect anything anytime soon. I am definitely not omniscient.

<Window x:Class="Ticker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Ticker" 
        Height="130"
        Width="400"
        Loaded="Window_Loaded">
  <StackPanel x:Name="MyStackPanel"
              SizeChanged="MyImage_SizeChanged">
    <DockPanel LastChildFill="True">
      <Slider Width="300"  DockPanel.Dock="Right" 
              Minimum="0.5" Maximum="8.0"
              Value="2.0"
              ValueChanged="Slider_ValueChanged" />
      <TextBox Name="Info"  DockPanel.Dock="Right"/>
    </DockPanel>
    <Image Name="MyImage" 
           Stretch="None"
           Height="60" 
           Width="{Binding ActualWidth, ElementName=MyStackPanel}"/>
  </StackPanel>
</Window>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using System.Windows.Threading;

namespace Ticker {

  public partial class MainWindow : Window {

    private Thread _Thread;
    private TextEngine _TextEngine = new TextEngine();
    private float _WidthInPixels;
    private double _Speed = 1.0; // number of pixels to shift per iteration
    private AutoResetEvent _AutoResetEvent = new AutoResetEvent(true);
    private BitmapImage _BitmapImage = null;
    private string _ElapsedTime = string.Empty;

    public MainWindow() {
      InitializeComponent();
    } //  constructor

    private void Window_Loaded(object sender, EventArgs e) {
      DataContext = this;

      _Thread = new Thread(Loop);
      _Thread.Name = "MainLoop";
      _Thread.IsBackground = true;
      _Thread.Priority = ThreadPriority.AboveNormal;
      _Thread.Start();

      TimeSpan lInterval = new TimeSpan(0, 0, 0, 0, 50); // run each 50 ms
      EventHandler lHandler = new EventHandler(OnTime);
      DispatcherTimer lDispatcherTimer = new DispatcherTimer(lInterval, DispatcherPriority.Send, lHandler, this.Dispatcher);
    } //

    private void MyImage_SizeChanged(object sender, SizeChangedEventArgs e) {
      StackPanel lStackPanel = sender as StackPanel;
      if (lStackPanel == null) return;
      using (var lGraphics = Graphics.FromHwnd(IntPtr.Zero)) {
        _WidthInPixels = (float)(e.NewSize.Width * lGraphics.DpiX / 96.0);
      }
    } //

    public void OnTime(object XSender, EventArgs e) {
      BitmapImage lBitmapImage = _BitmapImage;
      if (lBitmapImage == null) return;

      MyImage.Source = lBitmapImage;
      Info.Text = _ElapsedTime;
      _AutoResetEvent.Set();
    } //

    private void Loop() {
      float lEnd = 0f;
      int lSectionFrom = 0;
      int lSectionTo = 0;
      Stopwatch lStopwatch = new Stopwatch();

      while (true) {
        _AutoResetEvent.WaitOne();
        lStopwatch.Restart();

        float lWidthInPixel = _WidthInPixels; // copy the value to avoid changes during the calculation
        if (lWidthInPixel <= 0.0) continue;
        List<Line> lSection = _TextEngine.getVectorSection(ref lSectionFrom, ref lSectionTo, lEnd, lWidthInPixel);

        // This value determines the speed. 
        // Even numbers give better results due to the rounding error nature of bitmaps. 
        // Odd numbers create jitter. Luckily humans have bad eyes, they cannot perceive it.
        lEnd += (float)_Speed;

        if (lSection == null) {
          // end reached, reset text
          lSectionFrom = 0;
          lSectionTo = 0;
          lEnd = 0f;
        }
        else {
          Bitmap lBitmap = _TextEngine.VectorsToBitmap(lSection, lWidthInPixel);
          _BitmapImage = _TextEngine.BitmapToImageSource(lBitmap);
        }

        _ElapsedTime = lStopwatch.ElapsedMilliseconds.ToString("#,##0");
        lStopwatch.Stop();
      }
    } //

    private void Slider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) {
      _Speed = e.NewValue;
    } //

  } // class
} // namespace
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Windows.Media.Imaging;

namespace Ticker {
  public class TextEngine {

    private const string cText = @"Die Gedanken sind frei, wer kann sie erraten, sie fliegen vorbei wie nächtliche Schatten. Kein Mensch kann sie wissen, kein Jäger erschießen mit Pulver und Blei: Die Gedanken sind frei! Ich denke was ich will und was mich beglücket, doch alles in der Still', und wie es sich schicket. Mein Wunsch und Begehren kann niemand verwehren, es bleibet dabei: Die Gedanken sind frei! Und sperrt man mich ein im finsteren Kerker, das alles sind rein vergebliche Werke. Denn meine Gedanken zerreißen die Schranken und Mauern entzwei: Die Gedanken sind frei! Drum will ich auf immer den Sorgen entsagen und will mich auch nimmer mit Grillen mehr plagen. Man kann ja im Herzen stets lachen und scherzen und denken dabei: Die Gedanken sind frei! Ich liebe den Wein, mein Mädchen vor allen, sie tut mir allein am besten gefallen. Ich sitz nicht alleine bei meinem Glas Weine, mein Mädchen dabei: Die Gedanken sind frei!";
    private List<Line> _TextAsVectorChain = new List<Line>();

    public TextEngine() {
      // convert the entire text to vectors
      float lPosition = 0;
      Dictionary<char, List<Line>> lVectorCache = new Dictionary<char, List<Line>>();
      char[] lChars = cText.ToCharArray();

      foreach (char lChar in lChars) {
        if (lChar == ' ') lPosition += 10; // distance for an empty space character
        else {
          List<Line> lOneCharVectors;

          if (!lVectorCache.TryGetValue(lChar, out lOneCharVectors)) {
            Bitmap lBitmap = CharToBitmap(lChar);
            lOneCharVectors = BitmapToVectors(lBitmap);
            lVectorCache.Add(lChar, lOneCharVectors);
          }

          float lNewPosition = lPosition;
          foreach (Line lLine in lOneCharVectors) {
            Line lClone = lLine.Clone();
            lClone.X += lPosition;
            lNewPosition = lClone.X;
            _TextAsVectorChain.Add(lClone);
          }
          lPosition = lNewPosition + 4; // 4 == space between two characters
        }

      }
    } // constructor


    // Convert a bitmap to an ImageSource.
    // We can then display the result in the WPF Image element.
    public BitmapImage BitmapToImageSource(Bitmap xBitmap) {
      using (MemoryStream lMemoryStream = new MemoryStream()) {
        xBitmap.Save(lMemoryStream, System.Drawing.Imaging.ImageFormat.Bmp);
        lMemoryStream.Position = 0;
        BitmapImage lBitmapImage = new BitmapImage();
        lBitmapImage.BeginInit();
        lBitmapImage.StreamSource = lMemoryStream;
        lBitmapImage.CacheOption = BitmapCacheOption.OnLoad;
        lBitmapImage.EndInit();
        lBitmapImage.Freeze();
        return lBitmapImage;
      }
    } //

    // draw a single character into a bitmap
    private Font _Font = null;
    private Bitmap _Bitmap = null;
    public Bitmap CharToBitmap(char xChar) {
      if (_Font == null) {
        _Font = new Font("Arial", 40.0f, System.Drawing.FontStyle.Bold, GraphicsUnit.Pixel);
        _Bitmap = new Bitmap(60, 70);
      }
      using (Graphics lGraphics = Graphics.FromImage(_Bitmap)) {
        lGraphics.Clear(Color.White);
        lGraphics.DrawString(xChar.ToString(), _Font, Brushes.Black, 0f, 0f);
      }
      return _Bitmap;
    } //

    // Replicate the characters now by reading the vectors and drawing lines.
    Pen lPen = new Pen(Color.Black, 2f);
    public Bitmap VectorsToBitmap(List<Line> xLines, float xBitmapWidth) {
      if (_Font == null) { _Font = new Font("Arial", 40.0f, System.Drawing.FontStyle.Bold, GraphicsUnit.Pixel); }
      Bitmap lBitmap = new Bitmap((int)xBitmapWidth, 60);
      using (Graphics lGraphics = Graphics.FromImage(lBitmap)) {
        lGraphics.Clear(Color.White);
        foreach (Line lLine in xLines) {
          lGraphics.DrawLine(lPen, lLine.X, lLine.Y1, lLine.X, lLine.Y2);
        }
      }
      return lBitmap;
    } //

    // Convert a single character to vectors.
    private List<Line> BitmapToVectors(Bitmap xBitmap) {
      int lXCoordinateOfFirstPixel = -1;
      List<Line> lList = new List<Line>();

      for (int x = 0, lWidth = xBitmap.Width; x < lWidth; x++) {
        Line lVector = null;
        for (int y = 0, lHeight = xBitmap.Height; y < lHeight; y++) {
          Color lColor = xBitmap.GetPixel(x, y);
          bool lIsWhite = lColor.B == 255;
          if (lIsWhite) {
            if (lVector != null) {
              lList.Add(lVector);
              lVector = null;
            }
          }
          else {
            if (lVector == null) {
              if (lXCoordinateOfFirstPixel < 0) lXCoordinateOfFirstPixel = x;  // to always start at zero for our vectors
              lVector = new Line { X = x - lXCoordinateOfFirstPixel, Y1 = y, Y2 = y };
            }
            else lVector.Y2 = y;
          }
        }
      }

      return lList;
    } //


    // The text was converted to vectors.
    // Now we cut out the sequence we need for the display.
    internal List<Line> getVectorSection(ref int xSectionFrom, ref int xSectionTo, float xEnd, float xWidth) {
      int lCount = _TextAsVectorChain.Count;
      float lStart = xEnd - xWidth;

      // find the right section
      do {
        xSectionTo++;
        if (xSectionTo >= lCount) { xSectionTo = lCount - 1; break; }
        if (xEnd < _TextAsVectorChain[xSectionTo].X) break;
      } while (true);

      do {
        if (lStart < 0) break; // to allow empty spaces at the beginning of the slogan
        if (xSectionFrom >= lCount) return null;
        if (lStart < _TextAsVectorChain[xSectionFrom].X) break;
        xSectionFrom++;
      } while (true);


      // clone that section
      List<Line> lList = new List<Line>();
      for (int x = xSectionFrom; x <= xSectionTo; x++) {
        Line lClone = _TextAsVectorChain[x].Clone();
        lClone.X -= lStart; // adjust the X-axis
        lList.Add(lClone);
      }

      return lList;
    } //

  } // class
} // namespace
namespace Ticker {
  public class Line {
    public float X { get; set; }
    public float Y1 { get; set; }
    public float Y2 { get; set; }

    public Line Clone() {
      Line lLine = new Line();
      lLine.X = X;
      lLine.Y1 = Y1;
      lLine.Y2 = Y2;
      return lLine;
    } //

  } // class
} // namespace

WPF TimeLine Custom Control

TimeLine2

 

This is a custom WPF control and not a UserForm or a standard control with some extra properties in XAML. It is a proper class, which you would usually place in a library.

All Controls inherit from the FrameworkElement class. Some inherit from the Control Class, which is derived from the FrameworkElement class. In this case here I am deriving our custom control from the Canvas class, which in turn is derived from the Panel class and inherently FrameworkElement class. Once again I am not re-inventing the wheel and use existing functionality. Therefore the code remains pretty short. I am basically overriding the OnRender() event of the Canvas class.

This effects the design-time and run-time, which means you can see immediate results while you add the control in the XAML/Blend editor and of course when your C# application is running.
Just in case you are interested in further reading for design-time enhancements: WpfTutorial

The code in the MainWindow is not required. This is just a demonstration on how to change the display at run-time. A timer is used to make the change obvious. The code execution would be too fast otherwise. You wouldn’t see the first TimeLine graph, just the second. The method SetTimeSeries() triggers the change by invalidating the Canvas control (method InvalidateVisual()).

You can see a similar initialization in the TimeLineControl constructor. This data can be seen at design-time. You could also code that part in the XAML code, but this is not the purpose here. The control should not need any additional XAML in its ‘distributable’ version. Implement the TimeLineControl as it is. That is the beauty of overriding OnRender() rather than using WPF templates.

Design-time data display:
TimeLine1

The inner control paddings and other settings were hard-coded. You can change these. Hard-coding made the example easier to understand – no redundant code to confuse you. Feel free to replace this part with flexible user settings or calculated values.

 

&lt;Window
        xmlns=&quot;http://schemas.microsoft.com/winfx/2006/xaml/presentation&quot;
        xmlns:x=&quot;http://schemas.microsoft.com/winfx/2006/xaml&quot;
        xmlns:d=&quot;http://schemas.microsoft.com/expression/blend/2008&quot;
        xmlns:mc=&quot;http://schemas.openxmlformats.org/markup-compatibility/2006&quot; mc:Ignorable=&quot;d&quot; x:Class=&quot;TimeLine.MainWindow&quot;
        xmlns:local=&quot;clr-namespace:TimeLine&quot;
  Title=&quot;TimeLineControl&quot; Height=&quot;130&quot; Width=&quot;525&quot;&gt;
  &lt;DockPanel&gt;
    &lt;local:TimeLineControl x:Name=&quot;MyTimeLineControl&quot; /&gt;
  &lt;/DockPanel&gt;
&lt;/Window&gt;

 

using System;
using System.Collections.Generic;
using System.Timers;
using System.Windows;
using System.Windows.Documents;

namespace TimeLine {

  public partial class MainWindow : Window {
    public MainWindow() {
      InitializeComponent();

      Timer lTimer = new Timer(3000.0);
      lTimer.Elapsed += Timer_Elapsed;
      lTimer.Start();
    } // constructor

    void Timer_Elapsed(object xSender, ElapsedEventArgs e) {
      Timer lTimer = xSender as Timer;
      if (lTimer == null) return;
      lTimer.Stop();

      // demo: how to change the TimeLine
      List&lt;TimeEvent&gt; lList = new List&lt;TimeEvent&gt;();
      AddEvent(lList, new DateTime(2015, 03, 01), &quot;&quot;);
      AddEvent(lList, new DateTime(2015, 03, 06), &quot;exD Brown-Forman Corp&quot;);
      AddEvent(lList, new DateTime(2015, 03, 10), &quot;exD UniFirst Corp&quot;);
      AddEvent(lList, new DateTime(2015, 03, 11), &quot;exD Worthington Industries Inc&quot;);
      AddEvent(lList, new DateTime(2015, 03, 12), &quot;exD Garmin Ltd&quot;);
      AddEvent(lList, new DateTime(2015, 03, 18), &quot;exD Republic Bank Corp&quot;);
      AddEvent(lList, new DateTime(2015, 03, 23), &quot;exD STMicroelectronics NV&quot;);
      AddEvent(lList, new DateTime(2015, 03, 31), &quot;&quot;);
      MyTimeLineControl.SetTimeSeries(lList);
    } // constructor

    private void AddEvent(List&lt;TimeEvent&gt; xList, DateTime xDate, string xText) {
      TimeEvent lEvent = new TimeEvent();
      lEvent.Date = xDate;
      lEvent.TextRed = xDate.ToString(&quot;dd MMMyy&quot;);
      lEvent.TextBlack = xText;
      xList.Add(lEvent);
    } //

  } // class
} // namespace

 

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;

namespace TimeLine {
  public class TimeLineControl : Canvas {

    private IEnumerable&lt;TimeEvent&gt; _TimeSeries;

    public TimeLineControl() {
      Background = Brushes.White;
      List&lt;TimeEvent&gt; lList = new List&lt;TimeEvent&gt;();
      lList.Add(new TimeEvent { Date = DateTime.Today, TextRed = &quot;:(&quot;, TextBlack = &quot;Today&quot; });
      lList.Add(new TimeEvent { Date = DateTime.Today.AddDays(1), TextRed = &quot;&quot;, TextBlack = &quot;Tomorrow&quot; });
      lList.Add(new TimeEvent { Date = DateTime.Today.AddDays(7), TextRed = &quot;&quot;, TextBlack = &quot;Next week&quot; });
      lList.Add(new TimeEvent { Date = DateTime.Today.AddDays(14), TextRed = &quot;&quot;, TextBlack = &quot;Fortnight&quot; });
      lList.Add(new TimeEvent { Date = DateTime.Today.AddMonths(1), TextRed = &quot;:)&quot;, TextBlack = &quot;NextMonth&quot; });
      _TimeSeries = lList;
    } // constructor

    public void SetTimeSeries(IEnumerable&lt;TimeEvent&gt; xList) {
      if (!Dispatcher.CheckAccess()) {
        Dispatcher.Invoke(() =&gt; SetTimeSeries(xList));
        return;
      }
      _TimeSeries = xList;
      InvalidateVisual();
    } //

    // some hardcoding to keep the example code simple
    const double cPaddingLeft = 15.0;
    const double cPaddingRight = 50.0;
    const double cPaddingBottom = 17.0;
    const double cMarkerLength = 10.0;
    const double cTextOffset = 5.0;   // distance to rotation point
    const double cTextSpace = 7.0;    // distance between the red and the black text
    const double cAngle = 45.0;       // rotation angle  (0.0 &lt;= cAngle &lt;= 180.0)

    protected override void OnRender(DrawingContext xDrawingContext) {
      base.OnRender(xDrawingContext);

      double lLeft = cPaddingLeft;
      double lRight = ActualWidth - cPaddingRight;
      double lBottom = ActualHeight - cPaddingBottom;
      double lWidth = ActualWidth - cPaddingLeft - cPaddingRight;

      Point p1 = new Point(lLeft, lBottom);
      Point p2 = new Point(lRight, lBottom);

      // draw the X-Axis
      Pen lPen = new Pen(Brushes.Black, 3.0);
      lPen.DashStyle = DashStyles.Solid;
      xDrawingContext.DrawLine(lPen, p1, p2);

      // determine range
      DateTime lMin = _TimeSeries.Min(x =&gt; x.Date);
      DateTime lMax = _TimeSeries.Max(x =&gt; x.Date);
      double lDateRange = lMax.Subtract(lMin).TotalDays;

      foreach (TimeEvent t in _TimeSeries) {
        double lRelativeX = t.Date.Subtract(lMin).TotalDays / lDateRange;
        double lAbsoluteX = lRelativeX * lWidth + lLeft;   // convert to canvas coordinates

        // draw the X-Axis marker
        p1 = new Point(lAbsoluteX, lBottom);
        p2 = new Point(lAbsoluteX, lBottom - cMarkerLength);
        xDrawingContext.DrawLine(lPen, p1, p2);

        // write the text with a 45 degrees angle
        Point lRotationCenter = p2;
        double lTextWidth = DrawText(xDrawingContext, t.TextRed, lRotationCenter, cTextOffset, Brushes.Red); // red text
        DrawText(xDrawingContext, t.TextBlack, lRotationCenter, lTextWidth + cTextOffset + cTextSpace, Brushes.Black); // black text
      }
    } //

    /// &lt;returns&gt;the width of the text&lt;/returns&gt;
    private double DrawText(DrawingContext xDrawingContext, string xText, Point xPoint, double xOffset, SolidColorBrush xBrush) {
      Typeface lTypeface = new Typeface(&quot;Arial&quot;);
      CultureInfo lCultureInfo = CultureInfo.CurrentCulture;
      FormattedText lText = new FormattedText(xText, lCultureInfo, FlowDirection.LeftToRight, lTypeface, 10.0, xBrush);
      RotateTransform lRotateTransform = new RotateTransform(-cAngle, xPoint.X, xPoint.Y);
      xDrawingContext.PushTransform(lRotateTransform);
      xDrawingContext.DrawText(lText, new Point(xPoint.X + xOffset, xPoint.Y - lText.Height / 2.0));
      xDrawingContext.Pop();
      //return new Point(xPoint.X + lText.Height / 2.0 * Math.Sin(cAngle) + lText.Width * Math.Cos(cAngle) , xPoint.Y - lText.Width * Math.Sin(cAngle));
      return lText.Width;
    } //

    private void DrawLine(double x1, double x2, double y1, double y2) {
      Line lLine = new Line();
      lLine.Stroke = Brushes.LightSteelBlue;
      lLine.X1 = x1;
      lLine.X2 = x2;
      lLine.Y1 = y1;
      lLine.Y2 = y2;
      lLine.HorizontalAlignment = HorizontalAlignment.Left;
      lLine.VerticalAlignment = VerticalAlignment.Center;
      lLine.StrokeThickness = 2;
      Children.Add(lLine);
    } //

  } // class
} // namespace
using System;

namespace TimeLine {
  public class TimeEvent {
    public DateTime Date;
    public string TextRed;
    public string TextBlack;
  } // class
} // namespace

Clipboard to Text To Speech (TTS)

ClipboardTTSApp

 

Since two years I am using Text To Speech (TTS). The quality has improved a lot. The spoken text can be understood and the pronunciations have reached good levels; not perfect though. Since Windows 7 there is no need to pay for a professional voice anymore. The Microsoft Windows system voices are sufficient.
First, I wanted to build my own add-on for the Firefox browser. But I quickly realised that there are too many constraints. I am using a nice tool on my Samsung Note 4 to listen to web site texts on a daily basis. That works out very well.

Nevertheless, this tool here is for home PCs.
ClipboardTTS monitors the windows clipboard and displays the current text in a WPF TextBox. In case the CheckBox “Auto” is checked the application starts speaking the text immediately. You can also generate WAV files. Adding this feature only took a few extra lines, otherwise it would not have been worth it. There are only a few use cases.

The Clipboard class offered in .Net does not provide events to monitor clipboard changes. We therefore have to use the old fashioned 32bit Windows functions. Therefore the codes section starts with imports from the windows “user32.dll”. With these you can subscribe to updates. The event WinProc notifies the application about changes. Most of these messages are disregarded in this application. We are only interested in two types of them. The first one is the Windows clipboard chain WM_CHANGECBCHAIN. You have to store the following window of the system clipboard chain, because we must forward messages to that one. This is a weird technology, but who knows what it is good for. For sure it simplifies suppressing messages without the need for any cancellation flag.
WM_DRAWCLIPBOARD is the other type we are interested in. This message tells you that the clipboard context has changed. Have a look at the C# code, you’ll quickly understand.
You could argue that the .Net Clipboard class is not needed, after all the user32.dll can do everything we need. Well, I think we should include as much .Net as possible. This is the right way to stick to the future.

Don’t forget to reference the System.Speech library in Visual Studio.

 

SystemSpeech

 

The application itself is pretty short. This is due to two facts. Windows is offering good SAPI voices and acceptable .Net support to use these voices.
http://msdn.microsoft.com/en-us/library/ee125077%28v=vs.85%29.aspx

I don’t see the point to implement an add-on for Firefox to follow the multi-platform approach. Do I have to re-invent the wheel? And check out the existing add-ons. You can hardly understand the spoken text. Some of these add-ons offer multi-language support. Yeah, but come on! You cannot understand a word. We are not in the 1990s anymore. Computers have learnt speaking very well. What is the language support good for if you cannot understand anything?

 

<Window x:Class="ClipboardTTS.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="ClipboardTTS" Height="140" Width="263"
        Topmost="True"
        Loaded="Window_Loaded"
        Closed="Window_Closed">
  <DockPanel LastChildFill="True">
    <DockPanel DockPanel.Dock="Top" LastChildFill="False">
      <CheckBox Name="Checkbox_OnOff" DockPanel.Dock="Left" Content="Auto" Margin="5" ToolTip="Speak as soon as the clipboard text changes"/>
      <Button Content="Say it"  DockPanel.Dock="Right" Click="Button_SayIt_Click" Width="50" Margin="5" ToolTip="Start/Stop speaking"/>
      <Button Name="Button_Save" Content="Save"  DockPanel.Dock="Right" Click="Button_Save_Click" Width="50" Margin="5" ToolTip="Create WAV sound file"/>
    </DockPanel>
    <ComboBox Name="ComboBox_Voices" DockPanel.Dock="Top"  SelectionChanged="ComboBox_Voices_SelectionChanged" ToolTip="Voice"/>
    <Slider Name="Slider_Volumne" DockPanel.Dock="Top" Minimum="0" Maximum="100" Value="50" ValueChanged="Slider_Volumne_ValueChanged" ToolTip="Volume" />
    <TextBox Name="TextBox_Clipboard" TextChanged="TextBox_Clipboard_TextChanged" >
      hello world !
    </TextBox>
  </DockPanel>
</Window>

 

using System;
using System.Collections.ObjectModel;
using System.Runtime.InteropServices;
using System.Speech.Synthesis;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;

namespace ClipboardTTS {
  public partial class MainWindow : Window {

    private const int WM_DRAWCLIPBOARD = 0x0308; // change notifications
    private const int WM_CHANGECBCHAIN = 0x030D; // another window is removed from the clipboard viewer chain
    private const int WM_CLIPBOARDUPDATE = 0x031D; // clipboard changed contents

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern IntPtr SetClipboardViewer(IntPtr xHWndNewViewer);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern bool ChangeClipboardChain(IntPtr xHWndRemove, IntPtr xHWndNewNext);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern IntPtr SendMessage(IntPtr xHWnd, int xMessage, IntPtr xWParam, IntPtr xLParam);

    private IntPtr _HWndNextViewer; // next window
    private HwndSource _HWndSource; // this window
    private string _Text = string.Empty;
    private SpeechSynthesizer _SpeechSynthesizer = new SpeechSynthesizer();

    public MainWindow() {
      InitializeComponent();
    } // constructor

    private void StartListeningToClipboard() {
      WindowInteropHelper lWindowInteropHelper = new WindowInteropHelper(this);
      _HWndSource = HwndSource.FromHwnd(lWindowInteropHelper.Handle);
      _HWndSource.AddHook(WinProc);
      _HWndNextViewer = SetClipboardViewer(_HWndSource.Handle);   // set this window as a viewer
    } //

    private void StopListeningToClipboard() {
      ChangeClipboardChain(_HWndSource.Handle, _HWndNextViewer); // remove from cliboard viewer chain
      _HWndNextViewer = IntPtr.Zero;
      _HWndSource.RemoveHook(WinProc);
    } //

    private void SayIt(string xText) {
      if (string.IsNullOrWhiteSpace(xText)) return;
      _SpeechSynthesizer.Volume = (int)Slider_Volumne.Value;
      _SpeechSynthesizer.SpeakAsync(xText);
    } //

    private IntPtr WinProc(IntPtr xHwnd, int xMessageType, IntPtr xWParam, IntPtr xLParam, ref bool xHandled) {
      switch (xMessageType) {
        case WM_CHANGECBCHAIN:
          if (xWParam == _HWndNextViewer) _HWndNextViewer = xLParam;
          else if (_HWndNextViewer != IntPtr.Zero) SendMessage(_HWndNextViewer, xMessageType, xWParam, xLParam);
          break;

        case WM_DRAWCLIPBOARD:
          SendMessage(_HWndNextViewer, xMessageType, xWParam, xLParam);

          processWinProcMessage();
          break;
      }

      return IntPtr.Zero;
    } //

    private void processWinProcMessage() {
      if (!Dispatcher.CheckAccess()) {
        Dispatcher.Invoke(processWinProcMessage);
        return;
      }

      if (!Clipboard.ContainsText()) return;
      string lPreviousText = _Text;
      _Text = Clipboard.GetText();
      if (_Text.Equals(lPreviousText)) return; // do not play the same text again
      InsertTextIntoTextBox(_Text);
      if (Checkbox_OnOff.IsChecked.Value) SayIt(_Text);
    } //

    private void InsertTextIntoTextBox(string xText) {
      if (!TextBox_Clipboard.Dispatcher.CheckAccess()) {
        TextBox_Clipboard.Dispatcher.Invoke(() => InsertTextIntoTextBox(xText));
        return;
      }
      TextBox_Clipboard.Text = xText;
    } //

    private void Button_SayIt_Click(object xSender, RoutedEventArgs e) {
      if (_SpeechSynthesizer.State == SynthesizerState.Speaking) {
        _SpeechSynthesizer.SpeakAsyncCancelAll();
        return;
      }
      SayIt(TextBox_Clipboard.Text);
    } //

    private void TextBox_Clipboard_TextChanged(object xSender, System.Windows.Controls.TextChangedEventArgs e) {
      _Text = TextBox_Clipboard.Text;
    } //

    private void Window_Loaded(object xSender, RoutedEventArgs e) {
      ReadOnlyCollection<InstalledVoice> lVoices = _SpeechSynthesizer.GetInstalledVoices();
      if (lVoices.Count < 1) return;

      foreach (InstalledVoice xVoice in lVoices) {
        ComboBox_Voices.Items.Add(xVoice.VoiceInfo.Name);
      }
      ComboBox_Voices.SelectedIndex = 0;

      StartListeningToClipboard();
    } //

    private void Window_Closed(object xSender, EventArgs e) {
      StopListeningToClipboard();
    } //

    private void ComboBox_Voices_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) {
      string xVoice = ComboBox_Voices.SelectedItem as string;
      if (string.IsNullOrWhiteSpace(xVoice)) return;
      _SpeechSynthesizer.SelectVoice(xVoice);
    } //

    private void Slider_Volumne_ValueChanged(object xSender, RoutedPropertyChangedEventArgs<double> e) {
      _SpeechSynthesizer.Volume = (int)Slider_Volumne.Value;
    } //

    private Brush _OldButtonBrush = SystemColors.ControlBrush;
    private void Button_Save_Click(object xSender, RoutedEventArgs e) {
      _OldButtonBrush = Button_Save.Background;
      Button_Save.Background = Brushes.Salmon;
      Microsoft.Win32.SaveFileDialog lDialog = new Microsoft.Win32.SaveFileDialog();
      string lPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
      lDialog.InitialDirectory = lPath;
      lDialog.FileOk += FileDialog_FileOk;
      lDialog.Filter = "All Files|*.*|WAV (*.wav)|*.wav";
      lDialog.FilterIndex = 2;
      lDialog.ShowDialog();
    } //

    void FileDialog_FileOk(object xSender, System.ComponentModel.CancelEventArgs e) {
      Microsoft.Win32.SaveFileDialog lDialog = xSender as Microsoft.Win32.SaveFileDialog;
      if (lDialog == null) return;

      if (!Dispatcher.CheckAccess()) {
        Dispatcher.Invoke(() => FileDialog_FileOk(xSender, e));
        return;
      }

      try {
        string lPathAndFile = lDialog.FileName;
        _SpeechSynthesizer.SetOutputToWaveFile(lPathAndFile);
        _SpeechSynthesizer.SpeakCompleted += SpeechSynthesizer_SpeakCompleted;
        SayIt(TextBox_Clipboard.Text);
        Button_Save.Background = _OldButtonBrush;
      }
      catch (Exception ex) { MessageBox.Show(ex.Message); }
    } //

    void SpeechSynthesizer_SpeakCompleted(object sender, SpeakCompletedEventArgs e) {
      _SpeechSynthesizer.SetOutputToDefaultAudioDevice();
      _SpeechSynthesizer.SpeakCompleted -= SpeechSynthesizer_SpeakCompleted;
    } //

  } // class
} // namespace

WPF Charts (Part 6)

ScrollAndZoom

 

Scrolling day

Sometimes charts are too big, axes look like ugly clusters and details vanish in the limitedness of limits. You can flip labels by 90 degrees, but this won’t always solve the problem.

There is a simple way for beginners to zoom a graph. Use the ScrollViewer control and set the size of the inner context to something larger than the control. This works fine. It is just looks slightly irritating. The axes are disappearing from the visible area as soon as you start scrolling. This is demonstrated in the left graph.

A more advanced approach can be found in the right graph. There are 4 ScrollBars. The inner ScrollBars are moving the visible graph area. The outer ScrollBars are determining the zoom level. This can be achieved by setting the minimum and maximum properties of the corresponding axis. Take care to never cross these values. The minimum must be less than the maximum. Therefore, when you for instance increase the X-axis position, you should first set the new maximum and then the new minimum. Don’t do it the other way around.

My first try to scroll charts involved value converters. You would bind the minimum and then automatically set the maximum. This approach does not work. You quickly get exceptions where the minimum is larger than the maximum. But events can deal with this easily.

 

<Window x:Class="Demo.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:Demo"
    xmlns:datavisualization="clr-namespace:System.Windows.Controls.DataVisualization;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    xmlns:chart="clr-namespace:System.Windows.Controls.DataVisualization.Charting;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    Title="Demo Window"      
    Loaded ="Window_Loaded">

  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="1*"/>
      <ColumnDefinition Width="1*"/>
    </Grid.ColumnDefinitions>

    <ScrollViewer HorizontalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible" 
                Grid.Column="0">
      <chart:Chart Name="myChart1"
            Width="1000" Height="600">

        <chart:Chart.LegendStyle>
          <Style TargetType="datavisualization:Legend">
            <Setter Property="Width" Value="0" />
          </Style>
        </chart:Chart.LegendStyle>

        <chart:Chart.Axes>
          <chart:LinearAxis Name="YAxis1" Orientation="Y" ShowGridLines="False" />
          <chart:LinearAxis Name="XAxis1" Orientation="X" ShowGridLines="False"/>
        </chart:Chart.Axes>

        <chart:LineSeries Name="MyLineSeries1" ItemsSource="{Binding}"
                        IndependentValueBinding="{Binding X}"
                        DependentValueBinding="{Binding Y}">
          <chart:LineSeries.DataPointStyle>
            <Style TargetType="{x:Type chart:LineDataPoint}">
              <Setter Property="Background" Value="Black" />
              <Setter Property="Height" Value="0"/>
              <Setter Property="Width" Value="0"/>
            </Style>
          </chart:LineSeries.DataPointStyle>
        </chart:LineSeries>
      </chart:Chart>
    </ScrollViewer>

    <Grid Grid.Column="1">
      <DockPanel LastChildFill="True" >
        <ScrollBar Name="HBarZoom" 
                   Height="20" DockPanel.Dock="Bottom" Orientation="Horizontal" 
                   BorderBrush="Black"
                   Value="50" Minimum="1" Maximum="100"  
                   Margin="0,0,40,0"
                   ValueChanged="HBar_ValueChanged"/>
        <ScrollBar Name="HBar" 
                   Height="20" DockPanel.Dock="Bottom" Orientation="Horizontal" 
                   BorderBrush="Black"
                   Value="250" Minimum="0" Maximum="100"  
                   Margin="0,0,40,0"
                   ValueChanged="HBar_ValueChanged"/>
        <ScrollBar Name="VBarZoom" 
                   Width="20" DockPanel.Dock="Right" Orientation="Vertical"
                   BorderBrush="Black"
                   Value="50" Minimum="1" Maximum="100" 
                   ValueChanged="VBar_ValueChanged">
          <ScrollBar.LayoutTransform>
            <RotateTransform Angle="180"/>
          </ScrollBar.LayoutTransform>
        </ScrollBar>
        <ScrollBar Name="VBar" 
                   Width="20" DockPanel.Dock="Right" Orientation="Vertical" 
                   Value="1" Minimum="0" Maximum="1000000" 
                   BorderBrush="Black"
                   ValueChanged="VBar_ValueChanged">
          <ScrollBar.LayoutTransform>
            <RotateTransform Angle="180"/>
          </ScrollBar.LayoutTransform>
        </ScrollBar>
        <chart:Chart Name="myChart2" DockPanel.Dock="Bottom">

          <chart:Chart.LegendStyle>
            <Style TargetType="datavisualization:Legend">
              <Setter Property="Width" Value="0" />
            </Style>
          </chart:Chart.LegendStyle>

          <chart:Chart.Axes>
            <chart:LinearAxis Name="YAxis2" Orientation="Y" ShowGridLines="False" />
            <chart:LinearAxis Name="XAxis2" Orientation="X" ShowGridLines="False" />
          </chart:Chart.Axes>

          <chart:LineSeries Name="MyLineSeries2" ItemsSource="{Binding}"
                        IndependentValueBinding="{Binding X}"
                        DependentValueBinding="{Binding Y}">
            <chart:LineSeries.DataPointStyle>
              <Style TargetType="{x:Type chart:LineDataPoint}">
                <Setter Property="Background" Value="Black" />
                <Setter Property="Height" Value="0"/>
                <Setter Property="Width" Value="0"/>
              </Style>
            </chart:LineSeries.DataPointStyle>
          </chart:LineSeries>
        </chart:Chart>

      </DockPanel>

    </Grid>
  </Grid>

</Window>

 

using System;
using System.Collections.Generic;
using System.Windows;
using System.Linq;

namespace Demo {

  public partial class MainWindow : Window {
    private double _YMin, _YMax;
    private int _XMin = 0, _XMax = 2000;

    public MainWindow() {
      InitializeComponent();
    } // constructor

    private void Window_Loaded(object xSender, RoutedEventArgs e) {
      // add arbitrary LineSeries points
      List<Point> lPoints = new List<Point>();
      Random lRandom = new Random();
      double y = 1.0;
      double lYMin = double.MaxValue;
      double lYMax = double.MinValue;

      for (int i = _XMin; i < _XMax; i++) {
        double lChange = lRandom.NextDouble() - 0.5;

        y += lChange / 100.0;
        if (y > lYMax) lYMax = y;
        if (y < lYMin) lYMin = y;
        Point lPoint = new Point((double)i, y);
        lPoints.Add(lPoint);
      }

      _YMax = lYMax;
      _YMin = lYMin;
      VBar.Maximum = lYMax;
      VBar.Minimum = lYMin;
      HBar.Minimum = _XMin;
      HBar.Maximum = _XMax;

      // we clone the list to avoid trouble (deep copy)
      List<Point> lPoints2 = (from p in lPoints select new Point(p.X, p.Y)).ToList();

      MyLineSeries1.ItemsSource = lPoints;
      MyLineSeries2.ItemsSource = lPoints2;
    } //

    private void HBar_ValueChanged(object xSender, RoutedPropertyChangedEventArgs<double> e) {
      if (XAxis2 == null) return;

      double? lMax = XAxis2.Maximum; if (lMax == null) lMax = XAxis2.ActualMaximum;
      //double? lMin = XAxis2.Minimum; if (lMin == null) lMin = XAxis2.ActualMinimum;
      double lZoom = (_XMax - _XMin) * HBarZoom.Value / 100.0 / 2.0;
      double lValue = HBar.Value; // We do not use e.NewValue, because this event is called from many sources.

      if (lValue > lMax) {
        XAxis2.Maximum = Math.Max(lValue + lZoom, _XMax);        // widen the range first!
        XAxis2.Minimum = Math.Min(XAxis2.Maximum.Value - lZoom, _XMin);        // now we can tighten the range
        return;
      }
      XAxis2.Minimum = Math.Max(lValue - lZoom, _XMin);          // widen
      XAxis2.Maximum = Math.Min(XAxis2.Minimum.Value + lZoom, _XMax);          // tighten

      e.Handled = true;
    } //

    private void VBar_ValueChanged(object xSender, RoutedPropertyChangedEventArgs<double> e) {
      if (XAxis2 == null) return;

      double? lMax = YAxis2.Maximum; if (lMax == null) lMax = YAxis2.ActualMaximum;
      //double? lMin = YAxis2.Minimum; if (lMin == null) lMin = YAxis2.ActualMinimum;
      double lZoom = (_YMax - _YMin) * VBarZoom.Value / 100.0 / 2.0;
      double lValue = VBar.Value; // We do not use e.NewValue, because this event is called from many sources.

      if (lValue > lMax) {
        YAxis2.Maximum = Math.Min(lValue + lZoom, _YMax);
        YAxis2.Minimum = Math.Max(YAxis2.Maximum.Value - lZoom, _YMin);
        return;
      }
      YAxis2.Minimum = Math.Max(lValue - lZoom, _YMin);
      YAxis2.Maximum = Math.Min(YAxis2.Minimum.Value + lZoom, _YMax);

      e.Handled = true;
    } // 

  } // class
} // namespace

WPF Charts (Part 5)

Benchmark

Benchmarks

When people measure the round-trip of something, they often only show time differences. This chart shows the start time and its completion time, both relative to the theoretical/ideal start time. There are twenty groups with 40 measurements each. The top line of each group (red line) shows the first measurement. Further measurements are placed consecutively below.

We now get a precise idea. When was a task started, when did it end? This can be very important for some hard-core programmers.

 

What is implemented in this post?

  • Labels are replaced by a simple IValueConverter text.
  • The MicroTimer class, which was introduced in my post “Timer Finetuning, Each Microsecond Counts”, got new methods to convert between DateTime ticks and Stopwatch ticks by averaging 100 measurements and eliminating the imprecision of the DateTime class.
  • The Dispatcher class is used actively. The Console window of the previous examples was thread-safe, but now we need to switch to the right thread – the dispatcher thread.
  • All line series are added at runtime.
  • A Datagrid displays simple log information. I prefer Datagrids, because they are easier to deal with than Listviews. There are many features that Listviews are not natively supporting.
  • Hiding the chart legend.
  • The first MicroTimer event is not used and hence discarded. This has to do with the runtime compilation, which slows down the first run and makes it unpredictable in terms of micro timing.
  • X-Axis in milliseconds, Y-Axis in whatever you like (text).

We are coming to an end with my Chart posts. One more and then I will slowly delve into some Excel techniques.

 

<Window x:Class="Demo.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:Demo"
    xmlns:datavisualization="clr-namespace:System.Windows.Controls.DataVisualization;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    xmlns:chart="clr-namespace:System.Windows.Controls.DataVisualization.Charting;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    Title="Demo Window"      
    Loaded ="Window_Loaded">

  <Window.Resources>
    <local:MyLabelConverter x:Key="MyLabelConverter" />
  </Window.Resources>
 
  <DockPanel LastChildFill="True">
    <DataGrid Name="InfoText"
              AutoGenerateColumns="True"
              CanUserAddRows="False" CanUserReorderColumns="True" CanUserResizeColumns="True" CanUserResizeRows="False"
              SelectionUnit="Cell" SelectionMode="Extended"
              Height="100" DockPanel.Dock="Top"/>
    <chart:Chart Name="myChart"
            DockPanel.Dock="Top"
            Width="Auto" Height="Auto"
            MinWidth="400" MinHeight="300">

      <chart:Chart.LegendStyle>
        <Style TargetType="datavisualization:Legend">
          <Setter Property="Width" Value="0" />
        </Style>
      </chart:Chart.LegendStyle>

      <chart:Chart.Axes>
        <chart:LinearAxis Name="SharedYAxis" Orientation="Y" ShowGridLines="False" >
          <chart:LinearAxis.AxisLabelStyle>
            <Style TargetType="chart:AxisLabel">
              <Setter Property="Template">
                <Setter.Value>
                  <ControlTemplate TargetType="chart:AxisLabel">
                    <TextBlock Text="{Binding Converter={StaticResource MyLabelConverter}}" />
                  </ControlTemplate>
                </Setter.Value>
              </Setter>
            </Style>
          </chart:LinearAxis.AxisLabelStyle>

        </chart:LinearAxis>
        <chart:LinearAxis Name="SharedXAxis" Orientation="X" ShowGridLines="True">

          <!--rotate the X-Axis labels -->
          <chart:LinearAxis.AxisLabelStyle>
            <Style TargetType="chart:NumericAxisLabel">
              <Setter Property="Template">
                <Setter.Value>
                  <ControlTemplate TargetType="chart:NumericAxisLabel">
                    <TextBlock Text="{TemplateBinding FormattedContent}">
                      <TextBlock.LayoutTransform>
                        <RotateTransform Angle="90" CenterX = "40" CenterY = "30"/>
                      </TextBlock.LayoutTransform>
                    </TextBlock>
                  </ControlTemplate>
                </Setter.Value>
              </Setter>
            </Style>
          </chart:LinearAxis.AxisLabelStyle>

        </chart:LinearAxis>
      </chart:Chart.Axes>
    </chart:Chart>

  </DockPanel>
</Window>

 

using System;

namespace Demo {

  public class CurvePoint {
    public double CurveId { get; set; }  // Y value
    public double Time { get; set; }     // X value

    public CurvePoint(double xCurveId, double xTime) {
      CurveId = xCurveId;
      Time = xTime;
    } // constructor
  } // class

} // namespace

 

using System;

namespace Demo {
  public class InfoText {
    public string TimeStamp { get; private set; }
    public string Text { get; private set; }

    public InfoText(string xText) {
      TimeStamp = DateTime.Now.ToString("HH:mm:ss_fff");
      Text = xText;
    } //

  } // classs
} // namespace

 

using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Input;

namespace Demo {

  public partial class MainWindow : Window {

    private Model _Model;
    private ViewModel _ViewModel;

    public MainWindow() {
      InitializeComponent();
    } // constructor

    private void Window_Loaded(object sender, RoutedEventArgs e) {
      _ViewModel = new ViewModel(this);
      DataContext = _ViewModel;
      _Model = new Model(_ViewModel);
    } // 

  } // class
} // namespace

 

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;

namespace Demo {
  public class MicroTimer {

    private readonly Queue<long> _TickTimeTable;
    private readonly Thread _Thread;
    private readonly long _MaxDelayInTicks;  // do not run if the delay was too long
    private long _NextWakeUpTickTime;

    public delegate void dOnMicroTimer(int xSenderThreadId, long xWakeUpTimeInTicks, long xDelayInTicks);
    public event dOnMicroTimer OnMicroTimer;
    public event dOnMicroTimer OnMicroTimerSkipped;

    public delegate void dQuickNote(int xSenderThreadId);
    public event dQuickNote OnMicroTimerStart;
    public event dQuickNote OnMicroTimerStop;

    public MicroTimer(Queue<long> xTickTimeTable, long xMaxDelayInTicks) {
      _TickTimeTable = xTickTimeTable;
      _Thread = new Thread(new ThreadStart(Loop));
      _Thread.Priority = ThreadPriority.Highest;
      _Thread.Name = "TimerLoop";
      _Thread.IsBackground = true;
      _MaxDelayInTicks = xMaxDelayInTicks;
    } //

    public int Start() {
      if ((_Thread.ThreadState & System.Threading.ThreadState.Unstarted) == 0) return -1;
      _Thread.Start();
      return _Thread.ManagedThreadId;
    } //

    public void Stop() {
      _Thread.Interrupt();
    } //

    private void Loop() {
      dQuickNote lOnStart = OnMicroTimerStart;
      if (lOnStart != null) lOnStart(_Thread.ManagedThreadId);

      try {
        while (true) {
          if (_TickTimeTable.Count < 1) break;
          _NextWakeUpTickTime = _TickTimeTable.Dequeue();
          long lMilliseconds = _NextWakeUpTickTime - Stopwatch.GetTimestamp();
          if (lMilliseconds < 0L) continue;
          lMilliseconds = (lMilliseconds * 1000) / Stopwatch.Frequency;
          lMilliseconds -= 50;  // we want to wake up earlier and spend the last time using SpinWait
          Thread.Sleep((int)lMilliseconds);

          while (Stopwatch.GetTimestamp() < _NextWakeUpTickTime) {
            Thread.SpinWait(10);
          }
          long lWakeUpTimeInTicks = Stopwatch.GetTimestamp();
          long lDelay = lWakeUpTimeInTicks - _NextWakeUpTickTime;
          if (lDelay < _MaxDelayInTicks) {
            dOnMicroTimer lHandler = OnMicroTimer;
            if (lHandler == null) continue;
            lHandler(_Thread.ManagedThreadId, lWakeUpTimeInTicks, lDelay);
          }
          else {
            dOnMicroTimer lHandler = OnMicroTimerSkipped;
            if (lHandler == null) continue;
            lHandler(_Thread.ManagedThreadId, lWakeUpTimeInTicks, lDelay);
          }
        }
      }
      catch (ThreadInterruptedException) { }
      catch (Exception) { Console.WriteLine("Exiting timer thread."); }

      dQuickNote lOnStop = OnMicroTimerStop;
      if (lOnStop != null) lOnStop(_Thread.ManagedThreadId);
    } //

    public static double getDateTimeToStopwatchTickRatio() {
      const double cMeasurements = 100.0;
      double lTotalMilliseconds = 0.0;
      double lTotalTicks = 0.0;

      // averaging to interpolate the inprecision of the DateTime class
      // note: DateTime ticks are not Stopwatch ticks!
      for (double i = 0; i < cMeasurements + 0.001; i++) {
        DateTime lDateTime = DateTime.Now;
        long lTicks = Stopwatch.GetTimestamp();
        TimeSpan lTimeSpan = lDateTime.TimeOfDay;
        lTotalMilliseconds += lTimeSpan.TotalMilliseconds / cMeasurements;
        lTotalTicks += lTicks / cMeasurements;
        Thread.Sleep(5);
      }

      return lTotalTicks / lTotalMilliseconds;
    } //

    public static long convertTimeToTicks(DateTime xDateTime, double xRatio) {
      return (long)(xDateTime.TimeOfDay.TotalMilliseconds * xRatio);
    } //

    public static DateTime convertTicksToTime(long xTicks, double xRatio) {
      DateTime lToday = DateTime.Today;
      lToday = lToday.AddMilliseconds(xTicks / xRatio);
      return lToday;
    } //

  } // class
} // namespace

 

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace Demo {
  public class Model {
    private const int cNumTasks = 20;
    private const int cNumTriggers = 41; // we discard the first measurement
    private ViewModel _ViewModel;
    private double _DateTimeToStopwatchTickRatio;

    public Model(ViewModel xViewModel) {
      _DateTimeToStopwatchTickRatio = MicroTimer.getDateTimeToStopwatchTickRatio();
      _ViewModel = xViewModel;
      StartTimer(cNumTriggers);
    } // constructor

    private void StartTimer(int xNumTriggers) {
      DateTime lDateTime = DateTime.Now.AddSeconds(3.0); // an arbitrary ideal start time

      long lStartTimeInStopwatchTicks = MicroTimer.convertTimeToTicks(lDateTime, _DateTimeToStopwatchTickRatio);
      double lStartInXSeconds = (lStartTimeInStopwatchTicks - Stopwatch.GetTimestamp()) / Stopwatch.Frequency;
      //_ViewModel.InfoBoxText =  lStartInXSeconds + " secs" ;

      long[] lSchedule = new long[xNumTriggers];
      long lTwoSecsInTicks = 2 * Stopwatch.Frequency;
      for (int i = 0; i < xNumTriggers; i++) {
        lSchedule[i] = lStartTimeInStopwatchTicks;
        lStartTimeInStopwatchTicks += lTwoSecsInTicks;
      }

      long lMaxDelay = (5L * Stopwatch.Frequency) / 1000L; // 5 ms
      MicroTimer lMicroTimer = new MicroTimer(new Queue<long>(lSchedule), lMaxDelay);
      lMicroTimer.OnMicroTimer += OnMicroTimer;
      lMicroTimer.OnMicroTimerStop += OnMicroTimerStop;
      lMicroTimer.OnMicroTimerSkipped += OnMicroTimerSkipped;
      lMicroTimer.Start();
    } //

    private void OnMicroTimerSkipped(int xSenderThreadId, long xWakeUpTimeInTicks, long xDelayInTicks) {
      _ViewModel.AddInfoText("OnMicroTimerSkipped event raised");
    } //

    private void OnMicroTimerStop(int xSenderThreadId) {
      _ViewModel.AddInfoText("OnMicroTimerStop event raised");
    } //

    private double _NumberOfMicroTimerCalls;
    private bool _Once = true;
    private void OnMicroTimer(int xSenderThreadId, long xWakeUpTimeInTicks, long xDelayInTicks) {
      _ViewModel.AddInfoText("OnMicroTimer event raised");
      CurvePoint[][] lPoints = new CurvePoint[cNumTasks][];
      ParallelLoopResult lResult = Parallel.For(0, cNumTasks, (xInt) => DoSomething(xInt, xWakeUpTimeInTicks, lPoints));

      // the first measurement is not precise, so we discard it
      if (_Once) {
        _Once = false;
        return;
      }

      double lCallNo = _NumberOfMicroTimerCalls++;
      lCallNo /= cNumTriggers;

      _ViewModel.AddNewCurves(lPoints, lCallNo);
    } //

    private void DoSomething(int xId, long xWakeUpTimeInTicks, CurvePoint[][] xPoints) {
      // time sensitive stuff first
      double lFromTicks = Stopwatch.GetTimestamp();
      Thread.Sleep(10);
      double lToTicks = Stopwatch.GetTimestamp();

      // time insensitive stuff
      double lFromMillisecs = (lFromTicks - xWakeUpTimeInTicks) / Stopwatch.Frequency * 1000.0;
      double lToMillisecs = (lToTicks - xWakeUpTimeInTicks) / Stopwatch.Frequency * 1000.0;
      CurvePoint lFrom = new CurvePoint(xId, lFromMillisecs);
      CurvePoint lTo = new CurvePoint(xId, lToMillisecs);
      xPoints[xId] = new CurvePoint[] { lFrom, lTo };
    } //

  } // class
} // namespace

 

using System;
using System.Windows.Data;

namespace Demo {
  public class MyLabelConverter : IValueConverter {

    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
      if (value is double) return "Label " + ((double)value).ToString("0");
      return "#N/A";
    } //

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
      throw new NotImplementedException();
    } //

  } // class
} // namespace
using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.DataVisualization.Charting;
using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Shapes;

namespace Demo {

  public class ViewModel : DependencyObject {
    private readonly MainWindow _MainWindow;
    public readonly ObservableCollection<InfoText> Messages = new ObservableCollection<InfoText>();

    public ViewModel(MainWindow xMainWindow) {
      _MainWindow = xMainWindow;
      _MainWindow.InfoText.ItemsSource = Messages;
    } // constructor

    public void AddNewCurves(CurvePoint[][] xCurvePoints, double xYShift) {
      if (!Dispatcher.CheckAccess()) {
        Action lAction = () => { AddNewCurves(xCurvePoints, xYShift); };
        App.Current.Dispatcher.BeginInvoke(lAction);
        return;
      }

      // find the Y axis
      LinearAxis lYAxis = null;
      foreach (IAxis lAxis in _MainWindow.myChart.Axes) {
        if (lAxis.Orientation != AxisOrientation.Y) continue;
        lYAxis = lAxis as LinearAxis;
      }

      int lCount = xCurvePoints.GetUpperBound(0);
      LineSeries[] lLineSeries = new LineSeries[lCount];
      for (int x = 0, n = lCount; x < n; x++) {
        LineSeries lLine = new LineSeries();
        lLineSeries[x] = lLine;
        lLine.DependentRangeAxis = lYAxis;

        lLine.Title = "manually added curve";
        lLine.SetBinding(LineSeries.ItemsSourceProperty, new Binding());

        lLine.IndependentValueBinding = new Binding("Time");
        lLine.DependentValueBinding = new Binding("CurveId");

        Style lLineStyle = new Style(typeof(Polyline));
        //lLineStyle.Setters.Add(new Setter(Polyline.StrokeStartLineCapProperty, PenLineCap.Flat));
        //lLineStyle.Setters.Add(new Setter(Polyline.StrokeEndLineCapProperty, PenLineCap.Triangle));

        Style lPointStyle = new Style(typeof(DataPoint));

        if (xYShift == 0.0) {
          lLineStyle.Setters.Add(new Setter(Polyline.StrokeThicknessProperty, 3.0));
          lPointStyle.Setters.Add(new Setter(DataPoint.WidthProperty, 0.0));
          lPointStyle.Setters.Add(new Setter(DataPoint.BackgroundProperty, new SolidColorBrush(Colors.LightGreen)));
        }
        else {
          lLineStyle.Setters.Add(new Setter(Polyline.StrokeThicknessProperty, 1.0));
          lPointStyle.Setters.Add(new Setter(DataPoint.WidthProperty, 0.0));
          lPointStyle.Setters.Add(new Setter(DataPoint.BackgroundProperty, new SolidColorBrush(Colors.Black)));
        }
        lLine.PolylineStyle = lLineStyle;
        lLine.DataPointStyle = lPointStyle;

        xCurvePoints[x][0].CurveId -= xYShift;
        xCurvePoints[x][1].CurveId -= xYShift;
        lLine.ItemsSource = xCurvePoints[x];
        _MainWindow.myChart.Series.Add(lLine);
      }
    } //

    public void AddInfoText(string xText) {
      InfoText lInfoText = new InfoText(xText);
      Action lAction = () => {
        Messages.Add(lInfoText);

        // and scroll to the end
        if (_MainWindow.InfoText.Items.Count <= 0) return;
        Decorator lDecorator = VisualTreeHelper.GetChild(_MainWindow.InfoText, 0) as Decorator;
        if (lDecorator == null) return;
        ScrollViewer lScrollViewer = lDecorator.Child as ScrollViewer;
        if (lScrollViewer == null) return;
        lScrollViewer.ScrollToEnd();
      };
      App.Current.Dispatcher.BeginInvoke(lAction);
    } //

  } // class
} // namespace

WPF Charts (Part 4)

Zoom

 

This post is about zooming.

You can draw a transparent rectangle, which will then determine the coordinates. To achieve this a WPF Canvas is used. It is invisible and is located directly above the chart. You cannot draw custom rectangles into the chart directly. Overlaying is no issue in WPF. The bubbling/tunneling does a fabulous job and avoids disabling any overlaid object. See Routed Events.

First I was planning a real-time zoom with two fingers. Touch-Screens are quite common these days. Performance issues quickly forced me to think about it all over again. In this example you can zoom by using the mouse or two fingers. There is no real-time zoom. You draw the rectangle and when you are done, then the coordinates on the chart are determined. Before this point in time it is just a plain rectangle object on a canvas with no real  link to the chart. The escape button resets the axes/unzooms the chart.

I encapsulated the required functionality into a Zoom class. Yes, it maybe should have been in the ViewModel. I got the idea that it was neither fish nor meat – somewhere in between: “fieat”. Whatever, the Zoom class itself is clean and easy to understand.

There are some throttles in the code. They are definitely needed. The amount of touch events can easily become a “no-go” factor. You don’t have to process them all. Your eyes cannot perceive the vast amount of updates anyway. And WPF does not update the screen fast enough to show each value.

Oh, and one more issue here. I chose to not create a separate class to store the finger positions and rather go for a Tuple, which stores two values in an immutable way. The Tuple itself is declared only once. You find the declaration in the usings of the Zoom class.

using myTuple = System.Tuple<int, System.Windows.Point>;

 

In theory you can simultaneously have a lot of fingers on the screen; 10 if I am not mistaken😉
You would store these in a dictionary. In this example I only concentrate on 2 fingers.

Personally, I prefer the mouse solution. The touch events are nice, but the performance is jerky. It seems that you would get better result by using only one finger and dragging it like a mouse pointer.

For learning purposes I am displaying the finger manipulation values in today’s example. Uncomment them or uncomment the rectangle code to play with it properly. You might realise that the vertical zoom factor equals the horizontal zoom factor. Call it a bug or a Microsoft flaw. Yes, this is annoying. Anyway, I guess these people don’t do things without any reason. So their sins are forgiven.

 

 

<Window x:Class="Demo.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:Demo"
    xmlns:datavisualization="clr-namespace:System.Windows.Controls.DataVisualization;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    xmlns:chart="clr-namespace:System.Windows.Controls.DataVisualization.Charting;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    Title="Demo Window"
    PreviewKeyDown="OnKeyDown"        
    Loaded ="Window_Loaded">

  <DockPanel LastChildFill="True">
    <TextBox Name="InfoBox" Text="{Binding InfoBoxText, Mode=OneWay}" Height="Auto" DockPanel.Dock="Top"/>
    <Canvas DockPanel.Dock="Top" IsHitTestVisible="True" 
            MouseDown="OnMouseLeftButtonDown" 
            MouseLeftButtonUp="OnMouseLeftButtonUp"
            MouseMove="OnMouseMove"
            IsManipulationEnabled="True" 
            ManipulationDelta="OnManipulationDelta"
            TouchDown="OnTouchDown"
            TouchMove="OnTouchMove" 
            TouchUp="OnTouchUp"
            Width="Auto" Height="Auto" MinWidth="400" MinHeight="300">
      <chart:Chart Name="myChart" Title="2014"
                Width="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Canvas}}, Path=ActualWidth}"
                Height="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Canvas}}, Path=ActualHeight}">

        <chart:LineSeries Title="Volkswagen" 
                          ItemsSource="{Binding Points}" 
                          IndependentValueBinding="{Binding Date}" 
                          DependentValueBinding="{Binding PriceVW}" 
                          MouseMove="OnMouseMove">
          <chart:LineSeries.DependentRangeAxis>
            <chart:LinearAxis Orientation="Y" 
                              Title="Volkswagen" 
                              ShowGridLines="True" />
          </chart:LineSeries.DependentRangeAxis>
          <chart:LineSeries.DataPointStyle>
            <Style TargetType="{x:Type chart:LineDataPoint}">
              <Setter Property="Background" Value="Red" />
              <Setter Property="Height" Value="0"/>
              <Setter Property="Width" Value="0"/>
            </Style>
          </chart:LineSeries.DataPointStyle>
        </chart:LineSeries>

        <chart:LineSeries Title="Daimler"
                          ItemsSource="{Binding Points}"
                          IndependentValueBinding="{Binding Date}"
                          DependentValueBinding="{Binding PriceDaimler}">
          <chart:LineSeries.DependentRangeAxis>
            <chart:LinearAxis Orientation="Y" Title="Daimler" />
          </chart:LineSeries.DependentRangeAxis>
          <chart:LineSeries.DataPointStyle>
            <Style TargetType="{x:Type chart:LineDataPoint}">
              <Setter Property="Background" Value="Green" />
              <Setter Property="Height" Value="0"/>
              <Setter Property="Width" Value="0"/>
            </Style>
          </chart:LineSeries.DataPointStyle>
        </chart:LineSeries>

        <chart:Chart.Axes>
          <chart:DateTimeAxis Name="SharedXAxis"
                              Orientation="X" 
                              Title="shared X-Axis"
                              ShowGridLines="True">

            <!--rotate the X-Axis labels -->
            <chart:DateTimeAxis.AxisLabelStyle>
              <Style TargetType="chart:DateTimeAxisLabel">
                <Setter Property="Template">
                  <Setter.Value>
                    <ControlTemplate TargetType="chart:DateTimeAxisLabel">
                      <TextBlock Text="{TemplateBinding FormattedContent}">
                        <TextBlock.LayoutTransform>
                          <RotateTransform Angle="90" CenterX = "40" CenterY = "30"/>
                        </TextBlock.LayoutTransform>
                      </TextBlock>
                    </ControlTemplate>
                  </Setter.Value>
                </Setter>
              </Style>
            </chart:DateTimeAxis.AxisLabelStyle>

          </chart:DateTimeAxis>
        </chart:Chart.Axes>
      </chart:Chart>

    </Canvas>
  </DockPanel>
</Window>

 

using System;
using System.Windows;
using System.Windows.Controls.DataVisualization.Charting;

namespace Demo {

  public class AxisPointLinear : AxisPoint {
    public readonly LinearAxis Axis;
    public readonly double Min;
    public readonly double Max;  // larger than Min;
    public readonly double Range;
    public readonly double MouseAxisValueAbsolute;

    public AxisPointLinear(Chart xChart, LinearAxis xAxis, Point xPoint, double xMin, double xMax)
      : base(xChart, xAxis, xPoint) {
      Min = xMin;
      Max = xMax;
      Range = xMax - xMin;
      Axis = xAxis;
      MouseAxisValueAbsolute = xMin + (MouseAxisValueRelative * Range);
    } // constructor

    public override string ToString() {
      string s = "Mouse: ";
      s += MouseAxisValueRelative.ToString("0.000%");
      s += "  =>  ";
      s += MouseAxisValueAbsolute.ToString("#,##0.000");
      s += " EUR for ";
      s += Axis.Orientation;
      s += "-Axis ";
      s += Axis.Title;
      return s;
    } //

  } // class

  public class AxisPointDateTime : AxisPoint {
    public readonly DateTimeAxis Axis;
    public readonly DateTime Min;
    public readonly DateTime Max;  // larger than Min;
    public readonly TimeSpan Range;
    public readonly DateTime MouseAxisValueAbsolute;

    public AxisPointDateTime(Chart xChart, DateTimeAxis xAxis, Point xPoint, DateTime xMin, DateTime xMax)
      : base(xChart, xAxis, xPoint) {
      Min = xMin;
      Max = xMax;
      Range = xMax - xMin;
      Axis = xAxis;
      MouseAxisValueAbsolute = xMin.AddMinutes(MouseAxisValueRelative * Range.TotalMinutes);
    } // constructor

    public override string ToString() {
      string s = "Mouse: ";
      s += MouseAxisValueRelative.ToString("0.000%");
      s += "  =>  ";
      s += MouseAxisValueAbsolute.ToString("dd MMM yyyy");
      s += " for ";
      s += Axis.Orientation;
      s += "-Axis ";
      s += Axis.Title;
      return s;
    } //

  } // class

  public class AxisPointFactory {
    public static AxisPoint getAxisPoint(Chart xChart, RangeAxis xAxis, Point xPoint) {
      if (xAxis == null) return null;

      if (xAxis is LinearAxis) {
        // some redundant basic checks
        LinearAxis lAxis = xAxis as LinearAxis;
        double? lMin;
        double? lMax;
        lMin = lAxis.ActualMinimum;
        lMax = lAxis.ActualMaximum;

        if ((!lMin.HasValue) || (!lMax.HasValue)) return null;
        if (lMin.Value >= lMax.Value) return null;

        return new AxisPointLinear(xChart, lAxis, xPoint, lMin.Value, lMax.Value);
      }
      if (xAxis is DateTimeAxis) {
        // some redundant basic checks
        DateTimeAxis lAxis = xAxis as DateTimeAxis;
        DateTime? lMin;
        DateTime? lMax;
        lMin = lAxis.ActualMinimum;
        lMax = lAxis.ActualMaximum;

        if ((!lMin.HasValue) || (!lMax.HasValue)) return null;
        if (lMin.Value >= lMax.Value) return null;

        return new AxisPointDateTime(xChart, lAxis, xPoint, lMin.Value, lMax.Value);
      }

      throw new Exception("Axis type not supported yet.");
    } //
  } // class

  public abstract class AxisPoint {
    public readonly Chart Chart;
    public readonly Point MouseAbsoluteLocation;
    public readonly double MouseAxisValueRelative;  // a number between 0% and 100%
    public readonly double Length;  // object pixel display units, larger than zero

    public AxisPoint(Chart xChart, RangeAxis xAxis, Point xPoint) {
      if (xAxis.Orientation == AxisOrientation.X) Length = xAxis.ActualWidth;
      else Length = xAxis.ActualHeight;

      if (Length <= 0) throw new Exception("Chart object length is zero or less.");

      MouseAbsoluteLocation = xChart.TranslatePoint(xPoint, xAxis);
      if (xAxis.Orientation == AxisOrientation.X) MouseAxisValueRelative = MouseAbsoluteLocation.X / Length;
      else MouseAxisValueRelative = 1.0 - (MouseAbsoluteLocation.Y / Length);

      if (MouseAxisValueRelative > 1.0) MouseAxisValueRelative = 1.0;
      else if (MouseAxisValueRelative < 0.0) MouseAxisValueRelative = 0.0;
    } // constructor

  } // class
} // namespace

 

using System;
using System.Windows;
using System.Windows.Input;

namespace Demo {

  public partial class MainWindow : Window {

    private Model _Model;
    private ViewModel _ViewModel;
    private Zoom _Zoom;

    public MainWindow() {
      InitializeComponent();
    } // constructor

    private void Window_Loaded(object sender, RoutedEventArgs e) {
      _Zoom = new Zoom(myChart);
      _ViewModel = new ViewModel(myChart);
      DataContext = _ViewModel;
      _Model = new Model(_ViewModel);
    } // 

    private void OnKeyDown(object xSender, KeyEventArgs e) { _Zoom.OnKeyDown(xSender, e); }

    private void OnMouseLeftButtonDown(object xSender, MouseButtonEventArgs e) { _Zoom.OnMouseLeftButtonDown(xSender, e); }
    private void OnMouseLeftButtonUp(object xSender, MouseButtonEventArgs e) { _Zoom.OnMouseLeftButtonUp(xSender, e); }
    private void OnMouseMove(object xSender, MouseEventArgs e) { _Zoom.OnMouseMove(xSender, e); }

    private void OnTouchDown(object xSender, TouchEventArgs e) { _Zoom.OnTouchDown(xSender, e); }
    private void OnTouchMove(object xSender, TouchEventArgs e) { _Zoom.OnTouchMove(xSender, e); }
    private void OnTouchUp(object xSender, TouchEventArgs e) { _Zoom.OnTouchUp(xSender, e); }

    DateTime _LastOnManipulationDelta;
    private void OnManipulationDelta(object sender, ManipulationDeltaEventArgs e) {
      if (DateTime.Now.Subtract(_LastOnManipulationDelta).TotalMilliseconds < 500) return;  // throttle
      _LastOnManipulationDelta = DateTime.Now;

      InfoBox.Text =
            "Expansion: " + e.CumulativeManipulation.Expansion.ToString() + Environment.NewLine +
            "Rotation: " + e.CumulativeManipulation.Rotation.ToString() + Environment.NewLine +
            "Scale: " + e.CumulativeManipulation.Scale.ToString() + Environment.NewLine +
            "Translation: " + e.CumulativeManipulation.Translation.ToString() + Environment.NewLine +
            "Exp " + e.CumulativeManipulation.Expansion.X + "/" + e.CumulativeManipulation.Expansion.Y;
    } //

  } // class
} // namespace

 

using System;
using System.Linq;
using System.Windows.Threading;

namespace Demo {
  public class Model {
    private ViewModel _ViewModel;

    public Model(ViewModel xViewModel) {
      _ViewModel = xViewModel;
      //DispatcherTimer lTimer = new DispatcherTimer();
      //lTimer.Interval = new TimeSpan(0, 0, 3);
      //lTimer.Tick += new EventHandler(Timer_Tick);
      //lTimer.Start();
    } // constructor

    //void Timer_Tick(object sender, EventArgs e) {
    //    Random r = new Random();
    //    PriceCluster lPriceCluster = _ViewModel.Points.Last();
    //    double lVW = lPriceCluster.PriceVW * (1 + ((2.0 * (r.NextDouble() - 0.5)) / 30.0));
    //    double lDaimler = lPriceCluster.PriceDaimler * (1 + ((2.0 * (r.NextDouble() - 0.5)) / 30.0));
    //    _ViewModel.AddPoint(lPriceCluster.Date.AddDays(1), lVW, lDaimler);
    //} //

  } // class
} // namespace

 

using System;

namespace Demo {

  public class PriceCluster {
    public DateTime Date { get; set; }
    public double PriceVW { get; set; }
    public double PriceDaimler { get; set; }

    public PriceCluster(DateTime xDate, double xPriceVW, double xPriceDaimler) {
      Date = xDate;
      PriceVW = xPriceVW;
      PriceDaimler = xPriceDaimler;
    } // constructor
  } // class

  public class PriceClusterSimple {
    public DateTime Date { get; set; }
    public double Price { get; set; }

    public PriceClusterSimple(DateTime xDate, double xPrice) {
      Date = xDate;
      Price = xPrice;
    } // constructor
  } // class

} // namespace

 

using System;
using System.Collections.ObjectModel;
using System.Windows.Controls.DataVisualization.Charting;
using System.Windows;
using System.Windows.Input;
using System.Collections.Generic;

namespace Demo {
  using myTuple = Tuple<AxisPointDateTime, AxisPointLinear>;
  using System.Windows.Threading;

  public class ViewModel : DependencyObject {
    private readonly Chart _Chart;

    public ReadOnlyObservableCollection<PriceCluster> Points { get; private set; }
    private ObservableCollection<PriceCluster> _Points = new ObservableCollection<PriceCluster>();

    public ViewModel(Chart xChart) {
      _Chart = xChart;

      AddPoint(new DateTime(2014, 04, 10), 67.29, 13.85);
      AddPoint(new DateTime(2014, 04, 11), 66.15, 13.66);
      AddPoint(new DateTime(2014, 04, 14), 66.22, 13.67);
      AddPoint(new DateTime(2014, 04, 15), 63.99, 13.49);
      AddPoint(new DateTime(2014, 04, 16), 65.32, 13.62);
      AddPoint(new DateTime(2014, 04, 17), 67.29, 13.73);
      AddPoint(new DateTime(2014, 04, 22), 68.72, 13.91);
      AddPoint(new DateTime(2014, 04, 23), 67.85, 13.84);
      AddPoint(new DateTime(2014, 04, 24), 67.75, 13.78);
      AddPoint(new DateTime(2014, 04, 25), 66.29, 13.60);
      AddPoint(new DateTime(2014, 04, 28), 66.99, 13.73);
      AddPoint(new DateTime(2014, 04, 29), 67.79, 13.91);
      AddPoint(new DateTime(2014, 04, 30), 66.73, 13.79);
      AddPoint(new DateTime(2014, 05, 02), 66.24, 13.10);
      AddPoint(new DateTime(2014, 05, 05), 65.90, 13.08);
      AddPoint(new DateTime(2014, 05, 06), 65.16, 13.04);
      AddPoint(new DateTime(2014, 05, 07), 64.80, 13.18);
      AddPoint(new DateTime(2014, 05, 08), 65.00, 13.45);
      AddPoint(new DateTime(2014, 05, 09), 64.52, 13.42);
      AddPoint(new DateTime(2014, 05, 12), 65.28, 13.58);
      AddPoint(new DateTime(2014, 05, 13), 66.48, 13.40);
      AddPoint(new DateTime(2014, 05, 14), 66.74, 13.26);
      AddPoint(new DateTime(2014, 05, 15), 66.00, 12.97);
      AddPoint(new DateTime(2014, 05, 16), 65.21, 13.08);
      AddPoint(new DateTime(2014, 05, 19), 66.02, 13.38);
      AddPoint(new DateTime(2014, 05, 20), 66.46, 13.42);
      AddPoint(new DateTime(2014, 05, 21), 67.15, 13.84);
      AddPoint(new DateTime(2014, 05, 22), 67.52, 13.84);
      AddPoint(new DateTime(2014, 05, 23), 68.14, 14.06);
      AddPoint(new DateTime(2014, 05, 26), 69.61, 14.17);
      AddPoint(new DateTime(2014, 05, 27), 69.56, 14.15);
      AddPoint(new DateTime(2014, 05, 28), 69.29, 14.17);
      AddPoint(new DateTime(2014, 05, 29), 69.65, 14.18);
      AddPoint(new DateTime(2014, 05, 30), 69.70, 14.29);
      AddPoint(new DateTime(2014, 06, 02), 69.32, 14.31);
      AddPoint(new DateTime(2014, 06, 03), 69.68, 14.32);
      AddPoint(new DateTime(2014, 06, 04), 69.31, 14.31);
      AddPoint(new DateTime(2014, 06, 05), 70.31, 14.34);
      AddPoint(new DateTime(2014, 06, 06), 70.24, 14.42);
      AddPoint(new DateTime(2014, 06, 09), 70.09, 14.42);
      AddPoint(new DateTime(2014, 06, 10), 70.08, 14.47);
      AddPoint(new DateTime(2014, 06, 11), 69.66, 14.30);
      AddPoint(new DateTime(2014, 06, 12), 69.49, 14.26);
      AddPoint(new DateTime(2014, 06, 13), 69.12, 14.42);
      AddPoint(new DateTime(2014, 06, 16), 69.05, 14.44);
      AddPoint(new DateTime(2014, 06, 17), 69.65, 14.43);
      AddPoint(new DateTime(2014, 06, 18), 69.62, 14.62);
      AddPoint(new DateTime(2014, 06, 19), 70.10, 14.93);
      AddPoint(new DateTime(2014, 06, 20), 70.08, 14.93);
      AddPoint(new DateTime(2014, 06, 23), 69.46, 14.97);
      AddPoint(new DateTime(2014, 06, 24), 69.04, 15.06);
      AddPoint(new DateTime(2014, 06, 25), 68.71, 14.89);
      AddPoint(new DateTime(2014, 06, 26), 68.14, 15.12);
      AddPoint(new DateTime(2014, 06, 27), 68.33, 15.17);
      AddPoint(new DateTime(2014, 06, 30), 68.40, 15.08);
      AddPoint(new DateTime(2014, 07, 01), 69.19, 15.21);
      AddPoint(new DateTime(2014, 07, 02), 69.72, 15.20);
      AddPoint(new DateTime(2014, 07, 03), 70.44, 15.31);
      AddPoint(new DateTime(2014, 07, 04), 70.44, 15.16);
      AddPoint(new DateTime(2014, 07, 07), 69.28, 14.95);
      AddPoint(new DateTime(2014, 07, 08), 68.15, 14.84);
      AddPoint(new DateTime(2014, 07, 09), 68.16, 14.73);
      AddPoint(new DateTime(2014, 07, 10), 67.05, 14.43);
      AddPoint(new DateTime(2014, 07, 11), 66.68, 14.50);
      AddPoint(new DateTime(2014, 07, 14), 67.61, 14.60);
      AddPoint(new DateTime(2014, 07, 15), 67.28, 14.70);
      AddPoint(new DateTime(2014, 07, 16), 67.77, 14.89);
      AddPoint(new DateTime(2014, 07, 17), 66.56, 14.53);
      AddPoint(new DateTime(2014, 07, 18), 65.40, 14.52);
      AddPoint(new DateTime(2014, 07, 21), 64.84, 14.49);
      AddPoint(new DateTime(2014, 07, 22), 66.09, 14.83);
      AddPoint(new DateTime(2014, 07, 23), 65.58, 14.74);
      AddPoint(new DateTime(2014, 07, 24), 66.30, 14.92);
      AddPoint(new DateTime(2014, 07, 25), 65.15, 14.65);
      AddPoint(new DateTime(2014, 07, 28), 63.08, 14.61);
      AddPoint(new DateTime(2014, 07, 29), 63.89, 14.71);
      AddPoint(new DateTime(2014, 07, 30), 63.07, 14.43);
      AddPoint(new DateTime(2014, 07, 31), 61.88, 14.13);
      AddPoint(new DateTime(2014, 08, 01), 60.85, 13.60);
      AddPoint(new DateTime(2014, 08, 04), 61.17, 13.58);
      AddPoint(new DateTime(2014, 08, 05), 60.43, 13.61);
      AddPoint(new DateTime(2014, 08, 06), 59.82, 13.40);
      AddPoint(new DateTime(2014, 08, 07), 58.95, 13.16);
      AddPoint(new DateTime(2014, 08, 08), 59.27, 13.16);
      AddPoint(new DateTime(2014, 08, 11), 60.71, 13.36);
      AddPoint(new DateTime(2014, 08, 12), 59.85, 13.17);
      AddPoint(new DateTime(2014, 08, 13), 60.66, 13.80);
      AddPoint(new DateTime(2014, 08, 14), 61.07, 13.77);
      AddPoint(new DateTime(2014, 08, 15), 59.71, 13.65);
      AddPoint(new DateTime(2014, 08, 18), 60.99, 13.72);
      AddPoint(new DateTime(2014, 08, 19), 61.60, 13.72);
      AddPoint(new DateTime(2014, 08, 20), 61.33, 13.82);
      AddPoint(new DateTime(2014, 08, 21), 62.20, 13.86);
      AddPoint(new DateTime(2014, 08, 22), 61.65, 13.70);
      AddPoint(new DateTime(2014, 08, 25), 62.88, 13.88);
      AddPoint(new DateTime(2014, 08, 26), 63.49, 13.87);
      AddPoint(new DateTime(2014, 08, 27), 63.15, 13.89);
      AddPoint(new DateTime(2014, 08, 28), 62.16, 13.77);
      AddPoint(new DateTime(2014, 08, 29), 62.24, 13.83);
      AddPoint(new DateTime(2014, 09, 01), 61.88, 13.92);
      AddPoint(new DateTime(2014, 09, 02), 61.82, 13.92);
      AddPoint(new DateTime(2014, 09, 03), 62.90, 14.17);
      AddPoint(new DateTime(2014, 09, 04), 64.14, 14.34);
      AddPoint(new DateTime(2014, 09, 05), 65.17, 14.40);

      Points = new ReadOnlyObservableCollection<PriceCluster>(_Points);
    } // constructor

    // only to be called from the dispatcher thread!
    public void AddPoint(DateTime xDate, double xPriceVW, double xPriceDaimler) {
      _Points.Add(new PriceCluster(xDate, xPriceVW, xPriceDaimler));
    } //


  } // class
} // namespace

 

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.DataVisualization.Charting;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using myTuple = System.Tuple<int, System.Windows.Point>;

namespace Demo {
  public class Zoom {

    private Rectangle _Rectangle = null;
    private myTuple _PointA = null;
    private myTuple _PointB = null; // only used for the fingers, not for the mouse
    private readonly Chart _Chart;

    public Zoom(Chart xChart) {
      _Chart = xChart;
    } //

    #region draw new rectangle on button or finger down

    public void OnMouseLeftButtonDown(object xSender, MouseButtonEventArgs e) {
      Canvas lCanvas = xSender as Canvas;
      if (lCanvas == null) return;
      Point lPointA = e.GetPosition(lCanvas);

      DrawNewRectangle(lCanvas, lPointA, -1);
    } //

    public void OnTouchDown(object xSender, TouchEventArgs e) {
      Canvas lCanvas = xSender as Canvas;
      if (lCanvas == null) return;

      TouchPoint lTouchPoint = e.GetTouchPoint(lCanvas);
      if (lTouchPoint == null) return;

      myTuple lTuple = _PointA;
      if (lTuple != null) {
        if (lTuple.Item1 == e.TouchDevice.Id) return; // this was finger 1, not going to happen anyway as it cannot touchdown twice
        Point lPointA = lTuple.Item2;

        // store second finger; we don't care about its ID, so it could also be finger 3, 4 or 5 ...
        Point lPointB = lTouchPoint.Position;
        _PointB = new myTuple(e.TouchDevice.Id, lPointB);
        RedrawRectangle(lPointA, lPointB);
        return;
      }

      // first finger
      DrawNewRectangle(lCanvas, lTouchPoint.Position, lTouchPoint.TouchDevice.Id);
      return;
    } //

    private void DrawNewRectangle(Canvas xCanvas, Point xPoint, int xPointId) {
      if (_Rectangle != null) return;

      _Rectangle = new Rectangle();

      Point lPointA = new Point(xPoint.X, xPoint.Y); // clone
      _PointA = new myTuple(xPointId, lPointA);
      xCanvas.Children.Add(_Rectangle);

      Canvas.SetLeft(_Rectangle, xPoint.X);
      Canvas.SetTop(_Rectangle, xPoint.Y);

      _Rectangle.Height = 1.0;
      _Rectangle.Width = 1.0;
      _Rectangle.Opacity = 0.3;
      _Rectangle.Fill = new SolidColorBrush(Colors.SteelBlue);
      _Rectangle.Stroke = new SolidColorBrush(Colors.DarkBlue);
      _Rectangle.StrokeDashArray = new DoubleCollection(new double[] { 0.5, 1.5 });
      _Rectangle.StrokeDashCap = PenLineCap.Round;
      _Rectangle.StrokeThickness = 2.0;
    } //
    #endregion


    #region resize rectangle on any movement

    public void OnMouseMove(object xSender, MouseEventArgs e) {
      if (!(xSender is Canvas)) return;
      myTuple lTuple = _PointA;
      if (lTuple == null) return;
      Point lPointA = lTuple.Item2;
      Point lPointB = e.GetPosition((Canvas)xSender);
      RedrawRectangle(lPointA, lPointB);
    } //

    DateTime _LastOnTouchMove;
    public void OnTouchMove(object xSender, TouchEventArgs e) {
      if (DateTime.Now.Subtract(_LastOnTouchMove).TotalMilliseconds < 300) return;  // throttle
      _LastOnTouchMove = DateTime.Now;

      Canvas lCanvas = xSender as Canvas;
      if (lCanvas == null) return;

      TouchPoint lTouchPoint = e.GetTouchPoint(lCanvas);
      if (lTouchPoint == null) return;

      myTuple lTuple = _PointA;
      if (lTuple == null) return;

      Point lPointA = lTuple.Item2;
      if (e.TouchDevice.Id == lTuple.Item1) {
        // this is the finger we were touching down first
        lPointA = lTouchPoint.Position;
        _PointA = new myTuple(e.TouchDevice.Id, lPointA);
      }

      lTuple = _PointB;
      if (lTuple == null) return; // no second finger

      Point lPointB = lTuple.Item2;
      if (e.TouchDevice.Id == lTuple.Item1) {
        // this was the second finger
        lPointB = lTouchPoint.Position;
        _PointB = new myTuple(e.TouchDevice.Id, lPointB);
      }

      RedrawRectangle(lPointA, lPointB);
    } //

    private void RedrawRectangle(Point xPointA, Point xPointB) {
      Rectangle lRectangle = _Rectangle;
      if (lRectangle == null) return;

      Canvas.SetTop(lRectangle, Math.Min(xPointA.Y, xPointB.Y));
      lRectangle.Height = Math.Abs(xPointA.Y - xPointB.Y);

      Canvas.SetLeft(lRectangle, Math.Min(xPointA.X, xPointB.X));
      lRectangle.Width = Math.Abs(xPointA.X - xPointB.X);
    } //

    #endregion

    #region remove rectangle and Zoom
    public void OnMouseLeftButtonUp(object xSender, MouseButtonEventArgs e) {
      Rectangle lRectangle = _Rectangle;
      RemoveRectangle(xSender);
      ProcessZoom(lRectangle);
    } //

    public void OnTouchUp(object xSender, TouchEventArgs e) {
      Rectangle lRectangle = _Rectangle;
      if (lRectangle == null) return; // do not process any results when the first finger was gone already
      RemoveRectangle(xSender);
      ProcessZoom(lRectangle);
    } //

    private void RemoveRectangle(object xSender) {
      Canvas lCanvas = xSender as Canvas;
      if (lCanvas == null) return;
      lCanvas.Children.Remove(_Rectangle);
      _PointA = null;
      _PointB = null;
      _Rectangle = null;
    } //

    public void ProcessZoom(Rectangle xRectangle) {
      if (xRectangle == null) return;
      Point lFrom = new Point(Canvas.GetLeft(xRectangle), Canvas.GetTop(xRectangle));
      Point lTo = new Point(lFrom.X + xRectangle.Width, lFrom.Y + xRectangle.Height);

      foreach (IAxis lAxis in _Chart.ActualAxes) {
        if (lAxis is LinearAxis) {
          LinearAxis lLinearAxis = lAxis as LinearAxis;
          AxisPointLinear a = AxisPointFactory.getAxisPoint(_Chart, lLinearAxis, lFrom) as AxisPointLinear;
          AxisPointLinear b = AxisPointFactory.getAxisPoint(_Chart, lLinearAxis, lTo) as AxisPointLinear;
          lLinearAxis.Minimum = Math.Min(a.MouseAxisValueAbsolute, b.MouseAxisValueAbsolute);
          lLinearAxis.Maximum = Math.Max(a.MouseAxisValueAbsolute, b.MouseAxisValueAbsolute);
          continue;
        }

        if (lAxis is DateTimeAxis) {
          DateTimeAxis lDateTimeAxis = lAxis as DateTimeAxis;
          AxisPointDateTime a = AxisPointFactory.getAxisPoint(_Chart, lDateTimeAxis, lFrom) as AxisPointDateTime;
          AxisPointDateTime b = AxisPointFactory.getAxisPoint(_Chart, lDateTimeAxis, lTo) as AxisPointDateTime;
          lDateTimeAxis.Minimum = a.MouseAxisValueAbsolute < b.MouseAxisValueAbsolute ? a.MouseAxisValueAbsolute : b.MouseAxisValueAbsolute;
          lDateTimeAxis.Maximum = a.MouseAxisValueAbsolute > b.MouseAxisValueAbsolute ? a.MouseAxisValueAbsolute : b.MouseAxisValueAbsolute;
          continue;
        }
      }
    } //
    #endregion

    #region reset Zoom
    public void OnKeyDown(object xSender, KeyEventArgs e) {
      if (e.Key != Key.Escape) return;

      ProcessZoomReset();
    } // 

    public void ProcessZoomReset() {
      foreach (IAxis lAxis in _Chart.ActualAxes) {
        if (lAxis is LinearAxis) {
          LinearAxis lLinearAxis = lAxis as LinearAxis;
          lLinearAxis.Minimum = null;
          lLinearAxis.Maximum = null;
          continue;
        }

        if (lAxis is DateTimeAxis) {
          DateTimeAxis lDateTimeAxis = lAxis as DateTimeAxis;
          lDateTimeAxis.Minimum = null;
          lDateTimeAxis.Maximum = null;
          continue;
        }
      }
    } //
    #endregion

  } // class
} // namespace

WPF Charts (Part 3)

Chart3

In my last post about charts I added a small feature to obtain object information of any chart element under the mouse cursor.

Today we are doing the next logical step. I added proper coordinates in axis units (here EUROs), time and percent units. You hover over the chart and you don’t have to stop at a specific point. The program tells you the position in relation to each axis. We have three of them, so make sure you read the right output. I believe this is more useful than just being able to read curve point hover events. Sometimes you want to know some values in between – in the middle of nowhere. And we are solving this problem now.

I kept the code flexible. There is barely any hard-coding. You might also realize some redundant checks. They may be stupid at this stage. But think practically; many people copy the code and change it according to their needs. You quickly forget implementing the little checks, which were not necessary in the first place.

Like in the last chart posts, we are dealing with two line curves. There is a third one today, which is generated at runtime. Have a look at the corresponding C# code. In hindsight it always gives a better understanding of XAML code. WPF is not as straight forward as WinForms. It requires some training here and there. For instance, you cannot simply change the height property of an object. You need to use the hard way via setters.

The third curve is a manual line. Use the left mouse button (OnMouseLeftButtonDown event) to draw it. When you do this for the first time, the line is added to the chart. It did not exist before and was not just hidden. The events OnMouseMove and OnMouseLeftButtonUp are used to complete the logic. All three are required to draw the line while the chart keeps on adding points in the background. A small bonus are the proper axis coordinates. Despite subsequent new points, which are timer based, the manual line remains scaled. The new curve is linked to the same axes as the first curve (search for: LineSeries lLineSeries = lChart.Series[0] as LineSeries; …. lRangeAxis = xLineSeries.ActualDependentRangeAxis as RangeAxis; … lAxisPoint = AxisPointFactory.getAxisPoint(lChart, lRangeAxis, lPoint); ).

 

<Window x:Class="Demo.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:Demo"
    xmlns:datavisualization="clr-namespace:System.Windows.Controls.DataVisualization;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    xmlns:chart="clr-namespace:System.Windows.Controls.DataVisualization.Charting;assembly=System.Windows.Controls.DataVisualization.Toolkit"
    Title="Demo Window"
    Loaded ="Window_Loaded">

    <Grid>
        <TextBox Name="InfoBox" Text="" Height="Auto"/>
        <chart:Chart Name="myChart" Title="2014" Width="Auto" Height="Auto" MinWidth="400" MinHeight="300"
                     MouseMove="OnMouseMove"
                     MouseLeftButtonDown="OnMouseLeftButtonDown"
                     MouseLeftButtonUp="OnMouseLeftButtonUp">
            <chart:LineSeries Title="Volkswagen"
                              ItemsSource="{Binding Points}"
                              IndependentValueBinding="{Binding Date}"
                              DependentValueBinding="{Binding PriceVW}"
                              MouseMove="OnMouseMove">
                <chart:LineSeries.DependentRangeAxis>
                    <chart:LinearAxis Orientation="Y" Title="Volkswagen" ShowGridLines="True" />
                </chart:LineSeries.DependentRangeAxis>
                <chart:LineSeries.DataPointStyle>
                    <Style TargetType="{x:Type chart:LineDataPoint}">
                        <Setter Property="Background" Value="Red" />
                        <Setter Property="Height" Value="0"/>
                        <Setter Property="Width" Value="0"/>
                    </Style>
                </chart:LineSeries.DataPointStyle>
            </chart:LineSeries>

            <chart:LineSeries Title="Daimler"
                              ItemsSource="{Binding Points}"
                              IndependentValueBinding="{Binding Date}"
                              DependentValueBinding="{Binding PriceDaimler}">
                <chart:LineSeries.DependentRangeAxis>
                    <chart:LinearAxis Orientation="Y" Title="Daimler" />
                </chart:LineSeries.DependentRangeAxis>
                <chart:LineSeries.DataPointStyle>
                    <Style TargetType="{x:Type chart:LineDataPoint}">
                        <Setter Property="Background" Value="Green" />
                        <Setter Property="Height" Value="0"/>
                        <Setter Property="Width" Value="0"/>
                    </Style>
                </chart:LineSeries.DataPointStyle>
            </chart:LineSeries>

            <chart:Chart.Axes>
                <chart:DateTimeAxis Name="SharedXAxis"
                                   Orientation="X"
                                  Title="shared X-Axis"
                                  ShowGridLines="True">

                    <!--rotate the X-Axis labels -->
                    <chart:DateTimeAxis.AxisLabelStyle>
                        <Style TargetType="chart:DateTimeAxisLabel">
                            <Setter Property="Template">
                                <Setter.Value>
                                    <ControlTemplate TargetType="chart:DateTimeAxisLabel">
                                        <TextBlock Text="{TemplateBinding FormattedContent}">
                                            <TextBlock.LayoutTransform>
                                                <RotateTransform Angle="90" CenterX = "40" CenterY = "30"/>
                                            </TextBlock.LayoutTransform>
                                        </TextBlock>
                                    </ControlTemplate>
                                </Setter.Value>
                            </Setter>
                        </Style>
                    </chart:DateTimeAxis.AxisLabelStyle>

                </chart:DateTimeAxis>
            </chart:Chart.Axes>
        </chart:Chart>

    </Grid>
</Window>

 

using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.DataVisualization.Charting;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;

namespace Demo {
    public partial class MainWindow : Window {

        private Model _Model;
        private bool _DrawNewLine = false;

        public MainWindow() {
            InitializeComponent();
        } // constructor

        private void Window_Loaded(object sender, RoutedEventArgs e) {
            ViewModel lViewModel = new ViewModel(myChart);
            DataContext = lViewModel;
            _Model = new Model(lViewModel);
        } // 

        private void OnMouseMove(object sender, MouseEventArgs e) {
            if ((!_DrawNewLine) && (e.LeftButton == MouseButtonState.Pressed)) {
                MouseButtonEventArgs lMouseButtonEventArgs = new MouseButtonEventArgs(e.MouseDevice, e.Timestamp, MouseButton.Left);
                OnMouseLeftButtonDown(sender, lMouseButtonEventArgs);
                return;
            }

            IInputElement lInputElement = sender as IInputElement; // == basically Chart or LineSeries
            Point lPoint = e.GetPosition(lInputElement);

            Chart lChart = sender as Chart;
            if (lChart != null) {
                string s = string.Empty;

                // iterate through all axes
                lock (lChart.ActualAxes) {
                    foreach (IAxis lAxis in lChart.ActualAxes) {
                        RangeAxis lRangeAxis = lAxis as RangeAxis;
                        if (lRangeAxis == null) continue; // won't happen
                        AxisPoint lAxisPoint = AxisPointFactory.getAxisPoint(lChart, lRangeAxis, lPoint);

                        s += lAxisPoint.ToString() + Environment.NewLine;
                    }
                }

                InfoBox.Text = s;
                return;
            }

            LineSeries lLineSeries = sender as LineSeries;
            if (lLineSeries != null) {
                IInputElement lSelection = lLineSeries.InputHitTest(lPoint);
                if (lSelection == null) return;
                InfoBox.Text = "LineSeries object: " + lSelection.GetType().ToString();
                return;
            }
        } //

        private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
            Chart lChart = sender as Chart;
            if (lChart == null) return;

            AxisPoint lAxisPointX;
            AxisPoint lAxisPointY;
            LineSeries lLineSeries = lChart.Series[0] as LineSeries;
            if (lLineSeries == null) return;   // won't happen anyway
            if (!getAxisPointsToDrawALine(sender, e, lLineSeries, out lAxisPointX, out lAxisPointY)) return;

            AxisPointDateTime lAxisPointDateTimeX = lAxisPointX as AxisPointDateTime;
            AxisPointLinear lAxisPointLinearY = lAxisPointY as AxisPointLinear;

            if (lAxisPointDateTimeX == null) return;  // Quick and dirty test. Should not happen anyway.
            if (lAxisPointLinearY == null) return;  // Might be helpful when you change the code and forget about this requirement in another scenario.

            DrawCurve_WPF_Runtime(lChart, lAxisPointDateTimeX, lAxisPointLinearY);
        } //

        // add a new curve and its first point
        private ObservableCollection<PriceClusterSimple> ManualPoints = null;
        private void DrawCurve_WPF_Runtime(Chart lChart, AxisPointDateTime lAxisPointDateTimeX, AxisPointLinear lAxisPointLinearY) {
            PriceClusterSimple lDataPoint = new PriceClusterSimple(lAxisPointDateTimeX.MouseAxisValueAbsolute, lAxisPointLinearY.MouseAxisValueAbsolute);

            if (ManualPoints != null) {
                if (_DrawNewLine) {
                    _DrawNewLine = false;
                    ManualPoints.Clear();
                    ManualPoints.Add(lDataPoint); // from
                    ManualPoints.Add(lDataPoint); // to
                }
                else {
                    ManualPoints.RemoveAt(1);
                    ManualPoints.Add(lDataPoint); // to
                }
                return;
            }

            ManualPoints = new ObservableCollection<PriceClusterSimple>();
            ManualPoints.Add(lDataPoint);
            ManualPoints.Add(lDataPoint);
            LineSeries lNewLineSeries = new LineSeries();
            lNewLineSeries.Title = "manually added curve";
            lNewLineSeries.SetBinding(LineSeries.ItemsSourceProperty, new Binding());
            lNewLineSeries.ItemsSource = ManualPoints;
            lNewLineSeries.IndependentValueBinding = new Binding("Date");
            lNewLineSeries.DependentValueBinding = new Binding("Price");
            lNewLineSeries.DependentRangeAxis = lAxisPointLinearY.Axis;
            Setter lHeightSetter = new Setter(FrameworkElement.HeightProperty, 0.0);
            Setter lWidthSetter = new Setter(FrameworkElement.WidthProperty, 0.0);
            Setter lColor = new Setter(Control.BackgroundProperty, new SolidColorBrush(Colors.Black));
            Style lStyle = new Style(typeof(Control));
            lNewLineSeries.DataPointStyle = lStyle;
            lStyle.Setters.Add(lHeightSetter);
            lStyle.Setters.Add(lWidthSetter);
            lStyle.Setters.Add(lColor);
            lChart.Series.Add(lNewLineSeries);
        } //

        private bool getAxisPointsToDrawALine(object xSender, MouseButtonEventArgs xMouseButtonEventArgs, LineSeries xLineSeries, out AxisPoint xAxisPointX, out AxisPoint xAxisPointY) {
            IInputElement lInputElement = xSender as IInputElement; // == basically Chart or LineSeries
            Point lPoint = xMouseButtonEventArgs.GetPosition(lInputElement);
            xAxisPointX = null;
            xAxisPointY = null;

            Chart lChart = xSender as Chart;
            if (lChart == null) return false;

            RangeAxis lRangeAxis;
            AxisPoint lAxisPoint;

            lRangeAxis = xLineSeries.ActualDependentRangeAxis as RangeAxis;
            if (lRangeAxis == null) return false; // won't happen in our example
            lAxisPoint = AxisPointFactory.getAxisPoint(lChart, lRangeAxis, lPoint);
            if (lRangeAxis.Orientation == AxisOrientation.X) xAxisPointX = lAxisPoint;
            else xAxisPointY = lAxisPoint;

            lRangeAxis = xLineSeries.ActualIndependentAxis as RangeAxis;
            if (lRangeAxis == null) return false; // won't happen in our example
            lAxisPoint = AxisPointFactory.getAxisPoint(lChart, lRangeAxis, lPoint);
            if (lRangeAxis.Orientation == AxisOrientation.X) xAxisPointX = lAxisPoint;
            else xAxisPointY = lAxisPoint;

            if ((xAxisPointX == null) || (xAxisPointY == null)) return false;
            return true;
        } //

        private void OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e) {
            _DrawNewLine = true;
        } //

    } // class
} // namespace

 

using System;
using System.Linq;
using System.Windows.Threading;

namespace Demo {
	public class Model {
		private ViewModel _ViewModel;

		public Model(ViewModel xViewModel) {
			_ViewModel = xViewModel;
			DispatcherTimer lTimer = new DispatcherTimer();
			lTimer.Interval = new TimeSpan(0, 0, 3);
			lTimer.Tick += new EventHandler(Timer_Tick);
			lTimer.Start();
		} // constructor

		void Timer_Tick(object sender, EventArgs e) {
			Random r = new Random();
			PriceCluster lPriceCluster = _ViewModel.Points.Last();
			double lVW = lPriceCluster.PriceVW * (1 + ((2.0 * (r.NextDouble() - 0.5)) / 30.0));
			double lDaimler = lPriceCluster.PriceDaimler * (1 + ((2.0 * (r.NextDouble() - 0.5)) / 30.0));
			_ViewModel.AddPoint(lPriceCluster.Date.AddDays(1), lVW, lDaimler);
		} //

	} // class
} // namespace

 

using System;

namespace Demo {

    public class PriceCluster {
		public DateTime Date { get; set; }
		public double PriceVW { get; set; }
		public double PriceDaimler { get; set; }

		public PriceCluster(DateTime xDate, double xPriceVW, double xPriceDaimler) {
			Date = xDate;
			PriceVW = xPriceVW;
			PriceDaimler = xPriceDaimler;
		} // constructor
	} // class

    public class PriceClusterSimple {
        public DateTime Date { get; set; }
        public double Price { get; set; }

        public PriceClusterSimple(DateTime xDate, double xPrice) {
            Date = xDate;
            Price = xPrice;
        } // constructor
    } // class

} // namespace

 

using System;
using System.Collections.ObjectModel;
using System.Windows.Controls.DataVisualization.Charting;

namespace Demo {
	public class ViewModel {
		private readonly Chart _Chart;
		public ReadOnlyObservableCollection<PriceCluster> Points { get; private set; }
		private ObservableCollection<PriceCluster> _Points = new ObservableCollection<PriceCluster>();

		public ViewModel(Chart xChart) {
			_Chart = xChart;

			AddPoint(new DateTime(2014, 04, 10), 67.29, 13.85);
			AddPoint(new DateTime(2014, 04, 11), 66.15, 13.66);
			AddPoint(new DateTime(2014, 04, 14), 66.22, 13.67);
			AddPoint(new DateTime(2014, 04, 15), 63.99, 13.49);
			AddPoint(new DateTime(2014, 04, 16), 65.32, 13.62);
			AddPoint(new DateTime(2014, 04, 17), 67.29, 13.73);
			AddPoint(new DateTime(2014, 04, 22), 68.72, 13.91);
			AddPoint(new DateTime(2014, 04, 23), 67.85, 13.84);
			AddPoint(new DateTime(2014, 04, 24), 67.75, 13.78);
			AddPoint(new DateTime(2014, 04, 25), 66.29, 13.60);
			AddPoint(new DateTime(2014, 04, 28), 66.99, 13.73);
			AddPoint(new DateTime(2014, 04, 29), 67.79, 13.91);
			AddPoint(new DateTime(2014, 04, 30), 66.73, 13.79);
			AddPoint(new DateTime(2014, 05, 02), 66.24, 13.10);
			AddPoint(new DateTime(2014, 05, 05), 65.90, 13.08);
			AddPoint(new DateTime(2014, 05, 06), 65.16, 13.04);
			AddPoint(new DateTime(2014, 05, 07), 64.80, 13.18);
			AddPoint(new DateTime(2014, 05, 08), 65.00, 13.45);
			AddPoint(new DateTime(2014, 05, 09), 64.52, 13.42);
			AddPoint(new DateTime(2014, 05, 12), 65.28, 13.58);
			AddPoint(new DateTime(2014, 05, 13), 66.48, 13.40);
			AddPoint(new DateTime(2014, 05, 14), 66.74, 13.26);
			AddPoint(new DateTime(2014, 05, 15), 66.00, 12.97);
			AddPoint(new DateTime(2014, 05, 16), 65.21, 13.08);
			AddPoint(new DateTime(2014, 05, 19), 66.02, 13.38);
			AddPoint(new DateTime(2014, 05, 20), 66.46, 13.42);
			AddPoint(new DateTime(2014, 05, 21), 67.15, 13.84);
			AddPoint(new DateTime(2014, 05, 22), 67.52, 13.84);
			AddPoint(new DateTime(2014, 05, 23), 68.14, 14.06);
			AddPoint(new DateTime(2014, 05, 26), 69.61, 14.17);
			AddPoint(new DateTime(2014, 05, 27), 69.56, 14.15);
			AddPoint(new DateTime(2014, 05, 28), 69.29, 14.17);
			AddPoint(new DateTime(2014, 05, 29), 69.65, 14.18);
			AddPoint(new DateTime(2014, 05, 30), 69.70, 14.29);
			AddPoint(new DateTime(2014, 06, 02), 69.32, 14.31);
			AddPoint(new DateTime(2014, 06, 03), 69.68, 14.32);
			AddPoint(new DateTime(2014, 06, 04), 69.31, 14.31);
			AddPoint(new DateTime(2014, 06, 05), 70.31, 14.34);
			AddPoint(new DateTime(2014, 06, 06), 70.24, 14.42);
			AddPoint(new DateTime(2014, 06, 09), 70.09, 14.42);
			AddPoint(new DateTime(2014, 06, 10), 70.08, 14.47);
			AddPoint(new DateTime(2014, 06, 11), 69.66, 14.30);
			AddPoint(new DateTime(2014, 06, 12), 69.49, 14.26);
			AddPoint(new DateTime(2014, 06, 13), 69.12, 14.42);
			AddPoint(new DateTime(2014, 06, 16), 69.05, 14.44);
			AddPoint(new DateTime(2014, 06, 17), 69.65, 14.43);
			AddPoint(new DateTime(2014, 06, 18), 69.62, 14.62);
			AddPoint(new DateTime(2014, 06, 19), 70.10, 14.93);
			AddPoint(new DateTime(2014, 06, 20), 70.08, 14.93);
			AddPoint(new DateTime(2014, 06, 23), 69.46, 14.97);
			AddPoint(new DateTime(2014, 06, 24), 69.04, 15.06);
			AddPoint(new DateTime(2014, 06, 25), 68.71, 14.89);
			AddPoint(new DateTime(2014, 06, 26), 68.14, 15.12);
			AddPoint(new DateTime(2014, 06, 27), 68.33, 15.17);
			AddPoint(new DateTime(2014, 06, 30), 68.40, 15.08);
			AddPoint(new DateTime(2014, 07, 01), 69.19, 15.21);
			AddPoint(new DateTime(2014, 07, 02), 69.72, 15.20);
			AddPoint(new DateTime(2014, 07, 03), 70.44, 15.31);
			AddPoint(new DateTime(2014, 07, 04), 70.44, 15.16);
			AddPoint(new DateTime(2014, 07, 07), 69.28, 14.95);
			AddPoint(new DateTime(2014, 07, 08), 68.15, 14.84);
			AddPoint(new DateTime(2014, 07, 09), 68.16, 14.73);
			AddPoint(new DateTime(2014, 07, 10), 67.05, 14.43);
			AddPoint(new DateTime(2014, 07, 11), 66.68, 14.50);
			AddPoint(new DateTime(2014, 07, 14), 67.61, 14.60);
			AddPoint(new DateTime(2014, 07, 15), 67.28, 14.70);
			AddPoint(new DateTime(2014, 07, 16), 67.77, 14.89);
			AddPoint(new DateTime(2014, 07, 17), 66.56, 14.53);
			AddPoint(new DateTime(2014, 07, 18), 65.40, 14.52);
			AddPoint(new DateTime(2014, 07, 21), 64.84, 14.49);
			AddPoint(new DateTime(2014, 07, 22), 66.09, 14.83);
			AddPoint(new DateTime(2014, 07, 23), 65.58, 14.74);
			AddPoint(new DateTime(2014, 07, 24), 66.30, 14.92);
			AddPoint(new DateTime(2014, 07, 25), 65.15, 14.65);
			AddPoint(new DateTime(2014, 07, 28), 63.08, 14.61);
			AddPoint(new DateTime(2014, 07, 29), 63.89, 14.71);
			AddPoint(new DateTime(2014, 07, 30), 63.07, 14.43);
			AddPoint(new DateTime(2014, 07, 31), 61.88, 14.13);
			AddPoint(new DateTime(2014, 08, 01), 60.85, 13.60);
			AddPoint(new DateTime(2014, 08, 04), 61.17, 13.58);
			AddPoint(new DateTime(2014, 08, 05), 60.43, 13.61);
			AddPoint(new DateTime(2014, 08, 06), 59.82, 13.40);
			AddPoint(new DateTime(2014, 08, 07), 58.95, 13.16);
			AddPoint(new DateTime(2014, 08, 08), 59.27, 13.16);
			AddPoint(new DateTime(2014, 08, 11), 60.71, 13.36);
			AddPoint(new DateTime(2014, 08, 12), 59.85, 13.17);
			AddPoint(new DateTime(2014, 08, 13), 60.66, 13.80);
			AddPoint(new DateTime(2014, 08, 14), 61.07, 13.77);
			AddPoint(new DateTime(2014, 08, 15), 59.71, 13.65);
			AddPoint(new DateTime(2014, 08, 18), 60.99, 13.72);
			AddPoint(new DateTime(2014, 08, 19), 61.60, 13.72);
			AddPoint(new DateTime(2014, 08, 20), 61.33, 13.82);
			AddPoint(new DateTime(2014, 08, 21), 62.20, 13.86);
			AddPoint(new DateTime(2014, 08, 22), 61.65, 13.70);
			AddPoint(new DateTime(2014, 08, 25), 62.88, 13.88);
			AddPoint(new DateTime(2014, 08, 26), 63.49, 13.87);
			AddPoint(new DateTime(2014, 08, 27), 63.15, 13.89);
			AddPoint(new DateTime(2014, 08, 28), 62.16, 13.77);
			AddPoint(new DateTime(2014, 08, 29), 62.24, 13.83);
			AddPoint(new DateTime(2014, 09, 01), 61.88, 13.92);
			AddPoint(new DateTime(2014, 09, 02), 61.82, 13.92);
			AddPoint(new DateTime(2014, 09, 03), 62.90, 14.17);
			AddPoint(new DateTime(2014, 09, 04), 64.14, 14.34);
			AddPoint(new DateTime(2014, 09, 05), 65.17, 14.40);

			Points = new ReadOnlyObservableCollection<PriceCluster>(_Points);
		} // constructor

		// only to be called from the dispatcher thread!
		public void AddPoint(DateTime xDate, double xPriceVW, double xPriceDaimler) {
			_Points.Add(new PriceCluster(xDate, xPriceVW, xPriceDaimler));
		} //

	} // class
} // namespace