Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plot annotations? #71

Open
emumanu opened this issue Aug 16, 2024 · 2 comments
Open

Plot annotations? #71

emumanu opened this issue Aug 16, 2024 · 2 comments

Comments

@emumanu
Copy link

emumanu commented Aug 16, 2024

Hello there!

Nice project you have. Also, it is refreshing to see that you don't have thousands of unresolved open issues. That talks a lot about the quality of the project.

I have been successfully using the VectSharp.Plot nuget package. I can add links to my SVG files, but the only thing I'm really missing to get rid of other chart libraries are the annotations when you hover the mouse over the graph. I'm talking about something like this:

image

It is nice to be able to display custom data (not only the x and y values) when hovering over the graph or data points. I know I will need some javascript/webassembly for that, but is there any plan to support something like that?

@arklumpus
Copy link
Owner

Hi, I'm glad you like VectSharp!

I think it should already be possible to do what you want; the trick is to use "tags". When you draw something specifying a tag that is not null, the tag becomes the id of the graphics element in the SVG document. You can then use these ids to access the elements e.g. from JavaScript code.

Here is a long and detailed example...
using MathNet.Numerics.Distributions;
using System.Text;
using System.Text.Json;
using System.Xml;
using VectSharp;
using VectSharp.Plots;
using VectSharp.SVG;

// Generate some random data.
double[][] data = Enumerable.Range(0, 101).Select(x => new double[] { x, 2 * x + 3 + Normal.Sample(0, 10) }).ToArray();

// Create a scatter plot.
Plot plot = Plot.Create.ScatterPlot(data);

// Get the ScatterPoint objects that draws the point and give it a Tag.
plot.GetFirst<ScatterPoints<IReadOnlyList<double>>>().Tag = "dataPoints";

// Add a LinearTrendLine with a Tag.
LinearTrendLine trendLine = new LinearTrendLine(data, plot.GetFirst<IContinuousCoordinateSystem>()) { Tag = "trendLine" };
plot.AddPlotElement(trendLine);

// Render the plot to a page.
Page renderedPlot = plot.Render();

Page container = new Page(renderedPlot.Width, renderedPlot.Height);
container.Graphics.DrawGraphics(0, 0, renderedPlot.Graphics);

// There are multiple ways in which you could display things on top of the plot.
// For example, you could generate new DOM elements within the JavaScript code,
// or if you are using Blazor/WebAssembly you could generate a new SVG image
// on the fly. For this example, I'm drawing a placeholder element whose contents
// will be updated by the JS code.

Brush dataPointBrush = plot.GetFirst<ScatterPoints<IReadOnlyList<double>>>().PresentationAttributes.Fill;
Brush trendLineBrush = plot.GetFirst<LinearTrendLine>().PresentationAttributes.Stroke;
Font fntBold = new Font(FontFamily.ResolveFontFamily(FontFamily.StandardFontFamilies.HelveticaBold), 10);
Font fnt = new Font(FontFamily.ResolveFontFamily(FontFamily.StandardFontFamilies.Helvetica), 10);

// Fancy shadow.
Graphics pointBoxShadow = new Graphics();
pointBoxShadow.FillRectangle(3, 3, 80, 45, Colours.Black.WithAlpha(0.5));
container.Graphics.DrawGraphics(0, 0, pointBoxShadow, new VectSharp.Filters.GaussianBlurFilter(3), tag: "pointBox_Shadow");

// An info box showing X and Y coordinates.
container.Graphics.FillRectangle(0, 0, 80, 45, Colours.White, tag: "pointBox_BG");
container.Graphics.StrokeRectangle(0, 0, 80, 45, dataPointBrush, tag: "pointBox_Border");
container.Graphics.FillRectangle(0, 0, 80, 14, dataPointBrush, tag: "pointBox_TitleBG");
container.Graphics.FillText(5, 10, "Data point:", fntBold, Colours.White, textBaseline: TextBaselines.Baseline, tag: "pointBox_Title");
container.Graphics.FillText(5 + fntBold.MeasureText("Data point:").Width + 5, 10, "0", fntBold, Colours.White, textBaseline: TextBaselines.Baseline, tag: "pointBox_TitleNumber");
container.Graphics.FillText(5, 25, "X:", fntBold, Colours.Black, textBaseline: TextBaselines.Baseline, tag: "pointBox_xCoordLabel");
container.Graphics.FillText(20, 25, "0", fnt, Colours.Black, textBaseline: TextBaselines.Baseline, tag: "pointBox_xCoordValue");
container.Graphics.FillText(5, 39, "Y:", fntBold, Colours.Black, textBaseline: TextBaselines.Baseline, tag: "pointBox_yCoordLabel");
container.Graphics.FillText(20, 39, "0", fnt, Colours.Black, textBaseline: TextBaselines.Baseline, tag: "pointBox_yCoordValue");

// Let's create an info box for the trendline (this will have fixed text).
string trendLineEquation = "y = " + trendLine.Slope.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) + " x " + (trendLine.Intercept > 0 ? "+ " : "- ") + Math.Abs(trendLine.Intercept).ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
double averageY = data.Select(v => v[1]).Average();
double rSquared = 1 - data.Select(v => v[1] - (trendLine.Slope * v[0] + trendLine.Intercept)).Select(x => x * x).Sum() / data.Select(v => v[1] - averageY).Select(x => x * x).Sum();

// Fancy shadow #2.
Graphics tlBoxShadow = new Graphics();
tlBoxShadow.FillRectangle(3, 3, fnt.MeasureText(trendLineEquation).Width + 10, 45, Colours.Black.WithAlpha(0.5));
container.Graphics.DrawGraphics(0, 0, tlBoxShadow, new VectSharp.Filters.GaussianBlurFilter(3), tag: "tlBox_Shadow");

// An info box for the trendline.
container.Graphics.FillRectangle(0, 0, fnt.MeasureText(trendLineEquation).Width + 10, 45, Colours.White, tag: "tlBox_BG");
container.Graphics.StrokeRectangle(0, 0, fnt.MeasureText(trendLineEquation).Width + 10, 45, trendLineBrush, tag: "tlBox_Border");
container.Graphics.FillRectangle(0, 0, fnt.MeasureText(trendLineEquation).Width + 10, 13, trendLineBrush, tag: "tlBox_TitleBG");
container.Graphics.FillText(5, 10, "Trendline", fntBold, Colours.White, textBaseline: TextBaselines.Baseline, tag: "tlBox_Title");
container.Graphics.FillText(5, 25, trendLineEquation, fnt, Colours.Black, textBaseline: TextBaselines.Baseline, tag: "tlBox_equation");
container.Graphics.FillText(5, 39, FormattedText.Format("<b>R<sup>2</sup>:</b> " + rSquared.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture), FontFamily.StandardFontFamilies.Helvetica, fnt.FontSize), Colours.Black, textBaseline: TextBaselines.Baseline, tag: "tlBox_R2");

// Finally, let's add some other text elements to make sure that the embedded fonts
// contain all the glyphs we may need. We will remove these elements from the
// rendered SVG later. Alternatively, you could use TextOptions.EmbedFonts to
// embed the full font rather than a subset of glyphs.

container.Graphics.FillText(0, 0, "-0123456789.", fntBold, Colours.Fuchsia, tag: "removeMe_1");
container.Graphics.FillText(0, 0, "-0123456789.", fnt, Colours.Fuchsia, tag: "removeMe_2");

// You can render the Page to an SVG XmlDocument, so that you can add nodes
// to it (e.g., <script> elements) and manipulate it.
XmlDocument doc = container.SaveAsSVG();

// Now, the plot elements have been rendered with the specified tags.
// However, to ensure that each tag is unique, a suffix may have been
// added to the tag of individual graphics actions. For example,
// each point drawn by the ScatterPoints object is drawn with a tag
// of the form "dataPoints@<N>", where <N> is a number from 0 to
// 100 (corresponding to the index in the data array).

// We will use this to store the tags for the data points.
List<string> dataPointTags = new List<string>(data.Length);

// This will be used to store the tags for the trendline (there should
// normally only be one, but this is not guaranteed).
List<string> trendLineTags = new List<string>(1);

// This will be used for the point info box tags.
List<string> pointBoxTags = new List<string>();

// This will be used for the trendline info box tags.
List<string> tlBoxTags = new List<string>();

// You can get a list of all the tags that have been created in the
// plot using the GetTags method on the Graphics object.
foreach (string tag in container.Graphics.GetTags())
{
    if (tag.StartsWith("dataPoints"))
    {
        dataPointTags.Add(tag);
    }
    else if (tag.StartsWith("trendLine"))
    {
        trendLineTags.Add(tag);
    }
    else if (tag.StartsWith("pointBox"))
    {
        pointBoxTags.Add(tag);
    }
    else if (tag.StartsWith("tlBox"))
    {
        tlBoxTags.Add(tag);
    }
}

// We can now remove the placeholder text.
foreach (XmlElement element in doc.GetElementsByTagName("text").Cast<XmlElement>().Where(x => x.GetAttribute("id").StartsWith("removeMe")).ToList())
{
    element.ParentNode.RemoveChild(element);
}

// Right now, the info boxes are shown on top of everything even
// when we are not hovering on a point. We should set the default
// visibility to hidden.
foreach (XmlElement element in doc.DocumentElement.SelectNodes("*").Cast<XmlElement>().Where(x => x.GetAttribute("id").StartsWith("pointBox") || x.GetAttribute("id").StartsWith("tlBox")))
{
    element.SetAttribute("visibility", "hidden");
}

// Now we need some JavaScript for the interactivity. Start by creating a
// <script> element.
XmlElement scriptElement = doc.CreateElement("script", "http://www.w3.org/2000/svg");
scriptElement.SetAttribute("type", "text/javascript");

// JavaScript coding time! Of course, you can make this as complex as you want
// (e.g., by using a template script file included as an embedded resource).
StringBuilder javascriptCode = new StringBuilder();

// This code will be run when the document has finished loading.
javascriptCode.AppendLine("window.onload = function(event) {");

// Copy the data to the JS code. Here I'm using JSON arrays, but you could simply
// write out the arrays yourself.
javascriptCode.Append("  let data = JSON.parse(");
javascriptCode.Append(JsonSerializer.Serialize(JsonSerializer.Serialize(data)));
javascriptCode.AppendLine(");");

// Copy the data point tags to the JS code.
javascriptCode.Append("  let dataPointTags = JSON.parse(");
javascriptCode.Append(JsonSerializer.Serialize(JsonSerializer.Serialize(dataPointTags)));
javascriptCode.AppendLine(");");

// Copy the trendline tag(s) to the JS code.
javascriptCode.Append("  let trendLineTags = JSON.parse(");
javascriptCode.Append(JsonSerializer.Serialize(JsonSerializer.Serialize(trendLineTags)));
javascriptCode.AppendLine(");");

// Copy the point info box tag(s) to the JS code.
javascriptCode.Append("  let pointBoxTags = JSON.parse(");
javascriptCode.Append(JsonSerializer.Serialize(JsonSerializer.Serialize(pointBoxTags)));
javascriptCode.AppendLine(");");

// Copy the trendline info box tag(s) to the JS code.
javascriptCode.Append("  let tlBoxTags = JSON.parse(");
javascriptCode.Append(JsonSerializer.Serialize(JsonSerializer.Serialize(tlBoxTags)));
javascriptCode.AppendLine(");");

javascriptCode.AppendLine();

// Add event handlers to the data points.
javascriptCode.AppendLine("  for (let i = 0; i < dataPointTags.length; i++) {");
javascriptCode.AppendLine("    let tag = dataPointTags[i];");
javascriptCode.AppendLine("    let dataPoint = document.getElementById(tag);");

// Mouse enter event.
javascriptCode.AppendLine("    dataPoint.onmouseenter = function (mouseEvent) {");

// Change the colour of the point.
javascriptCode.AppendLine("      dataPoint.style.fill = \"#D55E00\";");
javascriptCode.AppendLine("      let pointIndex = parseInt(tag.substr(tag.indexOf(\"@\") + 1));");

// Find the coordinates of the point in screen space and add a small offset.
javascriptCode.AppendLine("      let coords = dataPoint.ownerSVGElement.createSVGPoint().matrixTransform(dataPoint.getScreenCTM());");
javascriptCode.AppendLine("      coords.x += 10;");
javascriptCode.AppendLine("      coords.y += 10;");

// Move around all the elements in the point info box.
javascriptCode.AppendLine("      for (let j = 0; j < pointBoxTags.length; j++) {");
javascriptCode.AppendLine("          let boxElement = document.getElementById(pointBoxTags[j]);");

// We need a null check because occasionally some VectSharp tags will not correspond
// to any SVG element (e.g., transforms).
javascriptCode.AppendLine("          if (boxElement != null) {");
// Show the element.
javascriptCode.AppendLine("            boxElement.style.visibility = \"visible\";");
// Find the coordinates of the point in the box elements's coordinate space.
javascriptCode.AppendLine("            let elementCoords = coords.matrixTransform(boxElement.ownerSVGElement.getScreenCTM().inverse());");
// Modify the box element's transform so that it appears in the correct position. 
// boxElement.getAttribute("transform") is the "default" transform (which will ensure
// that all box elements are in the correct position with respect to each other).
javascriptCode.AppendLine("            boxElement.style.transform = boxElement.getAttribute(\"transform\") + \" translate(\" + elementCoords.x + \"px, \" + elementCoords.y + \"px)\";");
javascriptCode.AppendLine("        }");
javascriptCode.AppendLine("      }");

// Change the text of the labels. Note that the text will not be repositioned,
// so you need to be careful with how much space you leave around the placeholder.
// Also, if you use a glyph that is not included in the embedded fonts, a default
// font may be used instead.
javascriptCode.AppendLine("      document.getElementById(\"pointBox_TitleNumber\").innerHTML = pointIndex;");
javascriptCode.AppendLine("      document.getElementById(\"pointBox_xCoordValue\").innerHTML = data[pointIndex][0].toFixed(5);");
javascriptCode.AppendLine("      document.getElementById(\"pointBox_yCoordValue\").innerHTML = data[pointIndex][1].toFixed(5);");
javascriptCode.AppendLine("    };");
javascriptCode.AppendLine();

// Mouse leave event.
javascriptCode.AppendLine("    dataPoint.onmouseleave = function (mouseEvent) {");

// Reset the point fill colour.
javascriptCode.AppendLine("      dataPoint.style.fill = \"\";");

// Hide the point info box and reset its position.
javascriptCode.AppendLine("      for (let j = 0; j < pointBoxTags.length; j++) {");
javascriptCode.AppendLine("        let boxElement = document.getElementById(pointBoxTags[j]);");
javascriptCode.AppendLine("        if (boxElement != null) {");
javascriptCode.AppendLine("          boxElement.style.visibility = \"hidden\";");
javascriptCode.AppendLine("          boxElement.style.transform = boxElement.getAttribute(\"transform\");");
javascriptCode.AppendLine("        }");
javascriptCode.AppendLine("      }");
javascriptCode.AppendLine("    };");
javascriptCode.AppendLine("  }");

// Add event handlers to the trendline.
javascriptCode.AppendLine("  for (let i = 0; i < trendLineTags.length; i++) {");
javascriptCode.AppendLine("    let tag = trendLineTags[i];");
javascriptCode.AppendLine("    let trendLine = document.getElementById(tag);");

// Mouse enter event.
javascriptCode.AppendLine("    trendLine.onmouseenter = function (mouseEvent) {");

// Change the stroke colour of the trendline.
javascriptCode.AppendLine("      trendLine.style.stroke = \"#CC79A7\";");

// Find the coordinates of the current mouse position (plus a small offset) in the
// SVG coordinate space.
javascriptCode.AppendLine("      let coords = trendLine.ownerSVGElement.createSVGPoint();");
javascriptCode.AppendLine("      coords.x = mouseEvent.clientX + 10;");
javascriptCode.AppendLine("      coords.y = mouseEvent.clientY + 10;");

// Move around the elements in the trendline info box.
javascriptCode.AppendLine("      for (let j = 0; j < tlBoxTags.length; j++) {");
javascriptCode.AppendLine("          let boxElement = document.getElementById(tlBoxTags[j]);");
javascriptCode.AppendLine("          if (boxElement != null) {");
javascriptCode.AppendLine("            boxElement.style.visibility = \"visible\";");
javascriptCode.AppendLine("            let elementCoords = coords.matrixTransform(boxElement.ownerSVGElement.getScreenCTM().inverse());");
javascriptCode.AppendLine("            boxElement.style.transform = boxElement.getAttribute(\"transform\") + \" translate(\" + elementCoords.x + \"px, \" + elementCoords.y + \"px)\";");
javascriptCode.AppendLine("        }");
javascriptCode.AppendLine("      }");
javascriptCode.AppendLine("    };");
javascriptCode.AppendLine();

// Mouse leave event. Resets the stroke colour and hides the info box.
javascriptCode.AppendLine("    trendLine.onmouseleave = function (mouseEvent) {");
javascriptCode.AppendLine("      trendLine.style.stroke = \"\";");
javascriptCode.AppendLine("      for (let j = 0; j < tlBoxTags.length; j++) {");
javascriptCode.AppendLine("        let boxElement = document.getElementById(tlBoxTags[j]);");
javascriptCode.AppendLine("        if (boxElement != null) {");
javascriptCode.AppendLine("          boxElement.style.visibility = \"hidden\";");
javascriptCode.AppendLine("          boxElement.style.transform = boxElement.getAttribute(\"transform\");");
javascriptCode.AppendLine("        }");
javascriptCode.AppendLine("      }");
javascriptCode.AppendLine("    };");
javascriptCode.AppendLine("  }");
javascriptCode.AppendLine("};");

// Add the code to the script element.
scriptElement.InnerText = javascriptCode.ToString();

// Append the script to the SVG document.
doc.DocumentElement.AppendChild(scriptElement);

// Save the SVG to a file - you can use other overloads of this method to
// write it to a Stream or to a string.
doc.WriteSVGXML("plot.svg");

The only thing is that the doc.WriteSVGXML method used in the last line was not public, so you will need VectSharp.SVG 1.10.2-a1 to use it.

This is the result [GitHub will block the scripts, but if you right-click, download the SVG file, and open it locally it should work]:

plot

This allows you to create a stand-alone interactive SVG; if you want to create an actual plot GUI, it might be a good idea to look at Avalonia. You can use VectSharp.Canvas to create an Avalonia Canvas object from the plot, which will make it easier to work with all the events etc. directly from C# code.

I hope this helps, let me know if you have any further questions!

@emumanu
Copy link
Author

emumanu commented Aug 19, 2024

It is quite convolved, but it works. Thank you very much for the example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants