diff --git a/.github/workflows/graphviewer-review.yml b/.github/workflows/graphviewer-review.yml new file mode 100644 index 0000000..ad0795c --- /dev/null +++ b/.github/workflows/graphviewer-review.yml @@ -0,0 +1,102 @@ +name: GraphViewer package review + +on: + push: + branches: [master, experimental/math-expression-migration] + paths: [GraphViewer/**, GraphViewer.Review/**, Directory.Build.props, .github/workflows/graphviewer-review.yml] + pull_request: + paths: [GraphViewer/**, GraphViewer.Review/**, Directory.Build.props, .github/workflows/graphviewer-review.yml] + workflow_dispatch: + +permissions: + contents: read + +jobs: + review: + runs-on: windows-2025 + timeout-minutes: 20 + env: + LocalNuGetPath: ${{ github.workspace }}/.package-review/graphviewer-feed + NUGET_PACKAGES: ${{ github.workspace }}/.package-review/graphviewer-packages + GRAPHVIEWER_REVIEW_OUTPUT: ${{ github.workspace }}/.package-review/graphviewer-images + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v4 + with: + path: consumer + - uses: actions/checkout@v4 + with: + repository: LTRData/Library + ref: 0e853ea1abdc3d4484695db1e026ebf88535ae42 + path: library + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + 9.0.x + - uses: microsoft/setup-msbuild@v3 + - name: Configure the shared package feed + run: | + New-Item -ItemType Directory -Force $env:LocalNuGetPath | Out-Null + @' + + + + + + + + + + + + + '@ | Set-Content NuGet.Config + - name: Build Library packages + run: | + foreach ($name in @('LTRData.Extensions', 'LTRData.MathExpression', 'LTRData.FunctionPlotting')) { + $project = "library/$name/$name.csproj" + dotnet restore $project --configfile NuGet.Config + if ($LASTEXITCODE) { throw "Restore failed: $project" } + dotnet build $project -c Release --no-restore + if ($LASTEXITCODE) { throw "Package build failed: $project" } + } + - name: Restore GraphViewer packages + run: | + dotnet restore consumer/GraphViewer/GraphViewer.vbproj --configfile NuGet.Config + if ($LASTEXITCODE) { throw 'Consumer restore failed' } + - name: Build modern Windows targets + run: | + foreach ($framework in @('net8.0-windows', 'net9.0-windows', 'net10.0-windows')) { + dotnet build consumer/GraphViewer/GraphViewer.vbproj -c Release -f $framework --no-restore + if ($LASTEXITCODE) { throw "Build failed: $framework" } + } + - name: Build legacy targets using Windows resource tools + run: | + # Windows 2025 has VS 2022 MSBuild. Select its supported SDK for these + # two legacy targets; modern targets and the review still use .NET 10. + '{"sdk":{"version":"9.0.100","rollForward":"latestFeature"}}' | Set-Content consumer/global.json + Push-Location consumer + try { + foreach ($framework in @('net35', 'net40')) { + msbuild GraphViewer/GraphViewer.vbproj /p:Configuration=Release /p:TargetFramework=$framework /m + if ($LASTEXITCODE) { throw "Build failed: $framework" } + } + } finally { + Remove-Item global.json + Pop-Location + } + - name: Run native drawing and form scenarios + run: | + dotnet restore consumer/GraphViewer.Review/GraphViewer.Review.csproj --configfile NuGet.Config + if ($LASTEXITCODE) { throw 'Review restore failed' } + dotnet run --project consumer/GraphViewer.Review/GraphViewer.Review.csproj -c Release --no-restore + if ($LASTEXITCODE) { throw 'Review failed' } + - uses: actions/upload-artifact@v4 + with: + name: graphviewer-review-images + path: ${{ env.GRAPHVIEWER_REVIEW_OUTPUT }} + if-no-files-found: error diff --git a/GraphViewer.Review/GraphViewer.Review.csproj b/GraphViewer.Review/GraphViewer.Review.csproj new file mode 100644 index 0000000..4ae4a00 --- /dev/null +++ b/GraphViewer.Review/GraphViewer.Review.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0-windows + true + true + enable + enable + false + + + + + diff --git a/GraphViewer.Review/Program.cs b/GraphViewer.Review/Program.cs new file mode 100644 index 0000000..1b5034b --- /dev/null +++ b/GraphViewer.Review/Program.cs @@ -0,0 +1,111 @@ +using System.Drawing.Imaging; +using GraphViewer; +using LTRData.FunctionPlotting; + +internal static class Program +{ + [STAThread] + private static int Main() + { + CheckGeometryAndDrawing(); + CheckFormComposition(); + Console.WriteLine("PASS: Windows drawing, five image formats, print margins, diagnostics, overlays, visibility and resize."); + return 0; + } + + private static void CheckGeometryAndDrawing() + { + var definitions = new[] { PlotDefinition.Parse("sin(x)"), PlotDefinition.Parse("x^2") }; + var frame = PlotSnapshot.Build(definitions, new NumericRange(-3, 3), new NumericRange(-2, 4), new CanvasSize(399, 239)); + Require(frame.Layers.Count == 2, "both overlays are retained"); + Require(frame.Layers.All(p => p.Curve.Polylines.Count > 0 && p.Derivative.Polylines.Count > 0 && p.Integral.Polylines.Count > 0), "all three curves are available"); + var gap = PlotSnapshot.Build([PlotDefinition.Parse("1/x")], new NumericRange(-1, 1), new NumericRange(-10, 10), new CanvasSize(400, 240)); + Require(gap.Layers[0].Curve.Polylines.Count == 2, "a non-finite sample splits the curve"); + foreach (var source in new[] { "y+x", "sin(1,2)", "(x+" }) + { + try { PlotDefinition.Parse(source); throw new Exception("Invalid expression accepted: " + source); } + catch (FormatException) { } + } + + using var bitmap = new Bitmap(460, 300); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.Clear(Color.Magenta); + var originalTransform = graphics.Transform.Elements; + var originalClip = graphics.ClipBounds; + PlotDrawing.Render(graphics, frame, new Rectangle(30, 20, 400, 240), true, true, true); + Require(graphics.Transform.Elements.SequenceEqual(originalTransform), "drawing restores the caller's transform"); + Require(graphics.ClipBounds == originalClip, "drawing restores the caller's clipping region"); + } + Require(bitmap.GetPixel(0, 0).ToArgb() == Color.Magenta.ToArgb(), "print margins are untouched"); + Require(bitmap.GetPixel(31, 21).ToArgb() == Color.White.ToArgb(), "print background is positioned at the margin"); + var colored = 0; + for (var y = 20; y < 260; y++) + for (var x = 30; x < 430; x++) + if (bitmap.GetPixel(x, y).ToArgb() != Color.White.ToArgb()) colored++; + Require(colored > 200, "native rendering draws visible curves"); + foreach (var format in new[] { ImageFormat.Bmp, ImageFormat.Gif, ImageFormat.Jpeg, ImageFormat.Png, ImageFormat.Tiff }) + { + using var stream = new MemoryStream(); + bitmap.Save(stream, format); + stream.Position = 0; + using var decoded = Image.FromStream(stream); + Require(decoded.Width == 460 && decoded.Height == 300, "encoded image dimensions: " + format); + } + var output = Environment.GetEnvironmentVariable("GRAPHVIEWER_REVIEW_OUTPUT"); + if (!string.IsNullOrEmpty(output)) + { + Directory.CreateDirectory(output); + bitmap.Save(Path.Combine(output, "graphviewer-print.png"), ImageFormat.Png); + } + } + + private static void CheckFormComposition() + { + Application.EnableVisualStyles(); + using var form = new GraphView { StartPosition = FormStartPosition.Manual, Location = new Point(-20000, -20000) }; + form.Show(); + Application.DoEvents(); + var formula = (ComboBox)form.Controls.Find("cmbExpression", true).Single(); + var picture = (PictureBox)form.Controls.Find("pbSurface", true).Single(); + var menu = (MenuStrip)form.Controls.Find("MenuStrip", true).Single(); + var items = AllItems(menu.Items).ToDictionary(p => p.Name!); + var clear = (ToolStripMenuItem)items["ClearSurfaceBeforeDrawingToolStripMenuItem"]; + clear.Checked = true; + formula.Text = "sin(x)"; + form.RedrawGraphs(); + Require(form.CurrentPlot.Definitions.Count == 1, "replace mode creates one plot"); + clear.Checked = false; + formula.Text = "x^2"; + form.RedrawGraphs(); + Require(form.CurrentPlot.Definitions.Count == 2, "overlay mode retains both formulas"); + var derivative = (ToolStripMenuItem)items["DrawCalculatedderivativeGraphToolStripMenuItem"]; + derivative.Checked = !derivative.Checked; + Require(form.CurrentPlot.Definitions.Count == 2, "visibility toggles do not append plots"); + form.Size = new Size(900, 640); + Application.DoEvents(); + Require(form.CurrentPlot.Definitions.Count == 2, "resize preserves overlays"); + Require(form.CurrentPlot.Viewport.Canvas.Width == picture.Width - 1, "resize rebuilds geometry for the canvas"); + using (var image = new Bitmap(picture.Width, picture.Height)) + picture.DrawToBitmap(image, picture.ClientRectangle); + Require(form.CurrentPlot.Definitions.Count == 2, "painting does not append plots"); + items["ClearDrawingSurfaceToolStripMenuItem"].PerformClick(); + Require(form.CurrentPlot.Definitions.Count == 1 && form.CurrentPlot.Definitions[0].Source == "x^2", "clear retains the current formula"); + form.Close(); + } + + private static IEnumerable AllItems(ToolStripItemCollection items) + { + foreach (ToolStripItem item in items) + { + yield return item; + if (item is ToolStripDropDownItem dropDown) + foreach (var child in AllItems(dropDown.DropDownItems)) yield return child; + } + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new Exception(message); + } +} diff --git a/GraphViewer/GraphView.Designer.vb b/GraphViewer/GraphView.Designer.vb index dc1d675..2467bf5 100644 --- a/GraphViewer/GraphView.Designer.vb +++ b/GraphViewer/GraphView.Designer.vb @@ -313,7 +313,7 @@ Partial Class GraphView Me.ClearSurfaceBeforeDrawingToolStripMenuItem.CheckState = System.Windows.Forms.CheckState.Checked Me.ClearSurfaceBeforeDrawingToolStripMenuItem.Name = "ClearSurfaceBeforeDrawingToolStripMenuItem" Me.ClearSurfaceBeforeDrawingToolStripMenuItem.Size = New System.Drawing.Size(279, 22) - Me.ClearSurfaceBeforeDrawingToolStripMenuItem.Text = "&Clear surface before drawing new graph" + Me.ClearSurfaceBeforeDrawingToolStripMenuItem.Text = "&Replace previous graphs on redraw" ' 'DrawCalculatedderivativeGraphToolStripMenuItem ' @@ -322,14 +322,14 @@ Partial Class GraphView Me.DrawCalculatedderivativeGraphToolStripMenuItem.CheckState = System.Windows.Forms.CheckState.Checked Me.DrawCalculatedderivativeGraphToolStripMenuItem.Name = "DrawCalculatedderivativeGraphToolStripMenuItem" Me.DrawCalculatedderivativeGraphToolStripMenuItem.Size = New System.Drawing.Size(279, 22) - Me.DrawCalculatedderivativeGraphToolStripMenuItem.Text = "Draw calculated &derivative graph" + Me.DrawCalculatedderivativeGraphToolStripMenuItem.Text = "Draw numerical &derivative graph" ' 'DrawCalculatedantiderivativeGraphToolStripMenuItem ' Me.DrawCalculatedantiderivativeGraphToolStripMenuItem.CheckOnClick = True Me.DrawCalculatedantiderivativeGraphToolStripMenuItem.Name = "DrawCalculatedantiderivativeGraphToolStripMenuItem" Me.DrawCalculatedantiderivativeGraphToolStripMenuItem.Size = New System.Drawing.Size(279, 22) - Me.DrawCalculatedantiderivativeGraphToolStripMenuItem.Text = "Draw calculated &antiderivative graph" + Me.DrawCalculatedantiderivativeGraphToolStripMenuItem.Text = "Draw numerical &integral graph" ' 'AboutToolStripMenuItem ' diff --git a/GraphViewer/GraphView.vb b/GraphViewer/GraphView.vb index 0d82c4c..4ac8fab 100644 --- a/GraphViewer/GraphView.vb +++ b/GraphViewer/GraphView.vb @@ -2,321 +2,194 @@ Imports System.Drawing Imports System.Drawing.Imaging Imports System.Globalization Imports System.Windows.Forms -Imports LTRLib.MathGraph -Imports LTRData.MathExpression +Imports LTRData.FunctionPlotting #Disable Warning IDE1006 ' Naming Styles Public Class GraphView + Private snapshot As PlotSnapshot - Public Shared ReadOnly fntTimes As New Font("Times", 12) - - Public Shared ReadOnly sftRightAligned As New StringFormat With { - .LineAlignment = StringAlignment.Center, - .Alignment = StringAlignment.Far} - - Public Shared ReadOnly sftLeftAligned As New StringFormat With { - .LineAlignment = StringAlignment.Center, - .Alignment = StringAlignment.Near} - - Public Shared ReadOnly sftTopAligned As New StringFormat With { - .LineAlignment = StringAlignment.Near, - .Alignment = StringAlignment.Center} - - Public Shared ReadOnly sftBottomAligned As New StringFormat With { - .LineAlignment = StringAlignment.Far, - .Alignment = StringAlignment.Center} - - ' This is the object that provides formula evaluation functions - Private ReadOnly ScriptControl As New ScriptControl(New MathExpressionParser(CultureInfo.InvariantCulture)) - - ' This is the drawing surface instance for the form - Private ReadOnly ScreenSurface As New Surface - - Private Property Ymax As Double + Public ReadOnly Property CurrentPlot As PlotSnapshot Get - Return Double.Parse(tbYmax.Text) + Return snapshot End Get - Set(value As Double) - tbYmax.Text = value.ToString() - End Set End Property - Private Property Ymin As Double - Get - Return Double.Parse(tbYmin.Text) - End Get - Set(value As Double) - tbYmin.Text = value.ToString() - End Set - End Property - - Private Property Xmax As Double - Get - Return Double.Parse(tbXmax.Text) - End Get - Set(value As Double) - tbXmax.Text = value.ToString() - End Set - End Property - - Private Property Xmin As Double - Get - Return Double.Parse(tbXmin.Text) - End Get - Set(value As Double) - tbXmin.Text = value.ToString() - End Set - End Property - - Private Sub NumericTextBox_KeyPress(sender As Object, e As KeyPressEventArgs) Handles tbXmin.KeyPress, tbXmax.KeyPress, tbYmin.KeyPress, tbYmax.KeyPress - If _ - (Not Char.IsDigit(e.KeyChar)) AndAlso - e.KeyChar <> Microsoft.VisualBasic.ControlChars.Back AndAlso - e.KeyChar <> "." AndAlso - Not (e.KeyChar = "-" AndAlso - DirectCast(sender, TextBox).SelectionStart = 0 AndAlso - Not DirectCast(sender, TextBox).Text.StartsWith("-"c)) Then - - e.Handled = True - - End If - End Sub - Private Sub cmbExpression_KeyPress(sender As Object, e As KeyPressEventArgs) Handles cmbExpression.KeyPress If e.KeyChar = Microsoft.VisualBasic.ControlChars.Cr Then e.Handled = True - cmbExpression.DroppedDown = False - - If cmbExpression.Text <> "" AndAlso Not cmbExpression.Items.Contains(cmbExpression.Text) Then - cmbExpression.Items.Insert(0, cmbExpression.Text) - End If - RedrawGraphs() End If End Sub Public Sub RedrawGraphs() - If Not Visible Then - Exit Sub - End If - - If ClearSurfaceBeforeDrawingToolStripMenuItem.Checked Then - ScreenSurface.Clear() - End If + CommitGraph(ClearSurfaceBeforeDrawingToolStripMenuItem.Checked) + End Sub + Private Sub CommitGraph(clearPrevious As Boolean) + If Not Visible OrElse pbSurface.Width < 2 OrElse pbSurface.Height < 2 Then Return Try - ScriptControl.Expression = cmbExpression.Text - ScreenSurface.Refresh(ScriptControl) - Catch Ex As Exception - MessageBox.Show(Ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Exclamation) + Dim plot = PlotDefinition.Parse(cmbExpression.Text) + Dim xRange = ReadRange(tbXmin, tbXmax, "X") + Dim yRange = ReadRange(tbYmin, tbYmax, "Y") + Dim plots As New List(Of PlotDefinition) + If Not clearPrevious AndAlso snapshot IsNot Nothing Then plots.AddRange(snapshot.Definitions) + plots.Add(plot) + ' Commit only after the whole new frame succeeds. Invalid input keeps the previous graph. + Dim candidate = PlotSnapshot.Build(plots, xRange, yRange, CanvasFor(pbSurface.ClientSize)) + snapshot = candidate + If Not cmbExpression.Items.Contains(plot.Source) Then cmbExpression.Items.Insert(0, plot.Source) + pbSurface.Invalidate() + Catch ex As Exception + MessageBox.Show(ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Exclamation) End Try - - pbSurface.Refresh() - End Sub - - Private Sub TextBox_Leave(sender As Object, e As EventArgs) Handles tbXmin.Leave, tbXmax.Leave, tbYmin.Leave, tbYmax.Leave - With CType(sender, TextBox) - .SelectionStart = 0 - .SelectionLength = .Text.Length - End With End Sub - Private Sub ComboBox_Leave(sender As Object, e As EventArgs) Handles cmbExpression.Leave - With CType(sender, ComboBox) - .SelectionStart = 0 - .SelectionLength = .Text.Length - End With - End Sub - - Private Sub pbSurface_Layout(sender As Object, e As LayoutEventArgs) Handles pbSurface.Layout - With ScreenSurface - .Area.Size = CType(sender, PictureBox).ClientRectangle.Size - - .Clear() - End With + Private Shared Function ReadRange(minimum As TextBox, maximum As TextBox, name As String) As NumericRange + Dim minValue, maxValue As Double + If Not TryReadNumber(minimum.Text, minValue) OrElse Not TryReadNumber(maximum.Text, maxValue) Then + Throw New FormatException($"The {name} range requires finite numbers.") + End If + If maxValue <= minValue OrElse Double.IsInfinity(maxValue - minValue) Then + Throw New FormatException($"The {name} maximum must exceed its minimum, with a finite difference.") + End If + Return New NumericRange(minValue, maxValue) + End Function - RedrawGraphs() - End Sub + Private Shared Function TryReadNumber(text As String, ByRef value As Double) As Boolean + ' Range controls accept local decimals and invariant scientific notation. + Return (Double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, value) OrElse + Double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, value)) AndAlso + Not Double.IsNaN(value) AndAlso Not Double.IsInfinity(value) + End Function - Private Sub tbXmin_TextChanged(sender As Object, e As EventArgs) Handles tbXmin.TextChanged - Try - ScreenSurface.Xmin = Xmin - Catch - ScreenSurface.Xmin = 0 - End Try - End Sub + Private Shared Function CanvasFor(size As Size) As CanvasSize + Return New CanvasSize(Math.Max(1, size.Width - 1), Math.Max(1, size.Height - 1)) + End Function - Private Sub tbXmax_TextChanged(sender As Object, e As EventArgs) Handles tbXmax.TextChanged - Try - ScreenSurface.Xmax = Xmax - Catch - ScreenSurface.Xmax = 0 - End Try + Private Sub TextBox_Leave(sender As Object, e As EventArgs) Handles tbXmin.Leave, tbXmax.Leave, tbYmin.Leave, tbYmax.Leave + DirectCast(sender, TextBox).SelectAll() End Sub - Private Sub tbYmin_TextChanged(sender As Object, e As EventArgs) Handles tbYmin.TextChanged - Try - ScreenSurface.Ymin = Ymin - Catch - ScreenSurface.Ymin = 0 - End Try + Private Sub ComboBox_Leave(sender As Object, e As EventArgs) Handles cmbExpression.Leave + DirectCast(sender, ComboBox).SelectAll() End Sub - Private Sub tbYmax_TextChanged(sender As Object, e As EventArgs) Handles tbYmax.TextChanged + Private Sub pbSurface_Layout(sender As Object, e As LayoutEventArgs) Handles pbSurface.Layout + If snapshot Is Nothing OrElse pbSurface.Width < 2 OrElse pbSurface.Height < 2 Then Return Try - ScreenSurface.Ymax = Ymax - Catch - ScreenSurface.Ymax = 0 + snapshot = PlotSnapshot.Build(snapshot.Definitions, snapshot.Viewport.XRange, + snapshot.Viewport.YRange, CanvasFor(pbSurface.ClientSize)) + pbSurface.Invalidate() + Catch ex As Exception + Debug.WriteLine(ex) End Try End Sub - Private Sub ExitToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles ExitToolStripMenuItem.Click - Close() - End Sub - - Protected Overrides Sub OnFormClosed(e As FormClosedEventArgs) - My.Settings.DrawDerivative = DrawCalculatedderivativeGraphToolStripMenuItem.Checked - My.Settings.DrawAntiderivative = DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked - My.Settings.ClearBeforeRedraw = ClearSurfaceBeforeDrawingToolStripMenuItem.Checked - End Sub - Protected Overrides Sub OnLoad(e As EventArgs) - PrintDocument.DefaultPageSettings.Landscape = True - + MyBase.OnLoad(e) + If PrintDocument.PrinterSettings.IsValid Then PrintDocument.DefaultPageSettings.Landscape = True DrawCalculatedderivativeGraphToolStripMenuItem.Checked = My.Settings.DrawDerivative DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked = My.Settings.DrawAntiderivative ClearSurfaceBeforeDrawingToolStripMenuItem.Checked = My.Settings.ClearBeforeRedraw + MenuStrip.ShowItemToolTips = True + DrawCalculatedderivativeGraphToolStripMenuItem.ToolTipText = "Numerical derivative; differences never cross gaps." + DrawCalculatedantiderivativeGraphToolStripMenuItem.ToolTipText = "Numerical integral, starting at zero at the left edge of each finite segment." End Sub Protected Overrides Sub OnShown(e As EventArgs) - If cmbExpression.Text <> "" Then - cmbExpression.Items.Add(cmbExpression.Text) - End If - + MyBase.OnShown(e) RedrawGraphs() End Sub + Protected Overrides Sub OnFormClosed(e As FormClosedEventArgs) + My.Settings.DrawDerivative = DrawCalculatedderivativeGraphToolStripMenuItem.Checked + My.Settings.DrawAntiderivative = DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked + My.Settings.ClearBeforeRedraw = ClearSurfaceBeforeDrawingToolStripMenuItem.Checked + My.Settings.Save() + MyBase.OnFormClosed(e) + End Sub + Private Sub ClearDrawingSurfaceToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles ClearDrawingSurfaceToolStripMenuItem.Click - ScreenSurface.Clear() - RedrawGraphs() + CommitGraph(True) End Sub - Private Sub RedrawGraphToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles RedrawGraphToolStripMenuItem.Click, DrawCalculatedderivativeGraphToolStripMenuItem.CheckedChanged, DrawCalculatedantiderivativeGraphToolStripMenuItem.CheckedChanged + Private Sub RedrawGraphToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles RedrawGraphToolStripMenuItem.Click RedrawGraphs() End Sub - Private Sub SaveAsPictureToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles SaveAsPictureToolStripMenuItem.Click - Dim SelectedImageFormat As ImageFormat - - Try - With SaveFileDialog - If .ShowDialog(Me) = DialogResult.Cancel Then - Exit Sub - End If - - Select Case SaveFileDialog.FilterIndex - Case 2 - SelectedImageFormat = ImageFormat.Gif - Case 3 - SelectedImageFormat = ImageFormat.Jpeg - Case 4 - SelectedImageFormat = ImageFormat.Png - Case 5 - SelectedImageFormat = ImageFormat.Tiff - Case Else - SelectedImageFormat = ImageFormat.Bmp - End Select - End With - - Dim Bitmap As New Bitmap(pbSurface.Width, pbSurface.Height) - pbSurface.DrawToBitmap(Bitmap, pbSurface.ClientRectangle) - Bitmap.Save(SaveFileDialog.FileName, SelectedImageFormat) - Catch Ex As Exception - MessageBox.Show(Ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Exclamation) - End Try + Private Sub CurveVisibilityChanged(sender As Object, e As EventArgs) Handles DrawCalculatedderivativeGraphToolStripMenuItem.CheckedChanged, DrawCalculatedantiderivativeGraphToolStripMenuItem.CheckedChanged + pbSurface.Invalidate() End Sub Private Sub pbSurface_Paint(sender As Object, e As PaintEventArgs) Handles pbSurface.Paint - Try + If snapshot Is Nothing Then e.Graphics.Clear(Color.DarkBlue) + Return + End If + PlotDrawing.Render(e.Graphics, snapshot, pbSurface.ClientRectangle, + DrawCalculatedderivativeGraphToolStripMenuItem.Checked, + DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked, False) + End Sub - With ScreenSurface - .DrawAxis(e.Graphics, Pens.DarkRed) - - If DrawCalculatedderivativeGraphToolStripMenuItem.Checked Then - .DrawDerivative(e.Graphics, Pens.DarkGreen) - End If - If DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked Then - .DrawIntegral(e.Graphics, Pens.Blue) - End If - .DrawGraph(e.Graphics, Pens.Yellow) - End With - Catch + Private Sub SaveAsPictureToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles SaveAsPictureToolStripMenuItem.Click + If snapshot Is Nothing Then Return + Try + If SaveFileDialog.ShowDialog(Me) <> DialogResult.OK Then Return + Dim formats = {ImageFormat.Bmp, ImageFormat.Gif, ImageFormat.Jpeg, ImageFormat.Png, ImageFormat.Tiff} + Using bitmap As New Bitmap(pbSurface.Width, pbSurface.Height) + Using drawing = Graphics.FromImage(bitmap) + PlotDrawing.Render(drawing, snapshot, pbSurface.ClientRectangle, + DrawCalculatedderivativeGraphToolStripMenuItem.Checked, + DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked, False) + End Using + bitmap.Save(SaveFileDialog.FileName, formats(Math.Max(0, Math.Min(4, SaveFileDialog.FilterIndex - 1)))) + End Using + Catch ex As Exception + MessageBox.Show(ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Exclamation) End Try End Sub Private Sub PrintDocument_PrintPage(sender As Object, e As Drawing.Printing.PrintPageEventArgs) Handles PrintDocument.PrintPage - With New Surface - .Xmin = ScreenSurface.Xmin - .Xmax = ScreenSurface.Xmax - .Ymin = ScreenSurface.Ymin - .Ymax = ScreenSurface.Ymax - - .Area = e.MarginBounds - - ScriptControl.Expression = cmbExpression.Text - .Refresh(ScriptControl) - - .DrawAxis(e.Graphics, Pens.Orange) - - If DrawCalculatedderivativeGraphToolStripMenuItem.Checked Then - .DrawDerivative(e.Graphics, Pens.LightGray) - End If - If DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked Then - .DrawIntegral(e.Graphics, Pens.DarkGray) - End If - .DrawGraph(e.Graphics, Pens.DarkRed) - End With - - With e.Graphics - .DrawString("y = " & cmbExpression.Text, fntTimes, Brushes.DarkRed, New Rectangle(e.MarginBounds.Left, e.PageBounds.Top, e.MarginBounds.Width, e.MarginBounds.Top), sftLeftAligned) - - .DrawString(tbXmin.Text, fntTimes, Brushes.Black, New RectangleF(e.PageBounds.Left, e.MarginBounds.Top, e.MarginBounds.Left, CSng(Ymax * 2 * e.MarginBounds.Height / (Ymax - Ymin))), sftRightAligned) - .DrawString(tbXmax.Text, fntTimes, Brushes.Black, New RectangleF(e.MarginBounds.Right, e.MarginBounds.Top, e.PageBounds.Right - e.MarginBounds.Right, CSng(Ymax * 2 * e.MarginBounds.Height / (Ymax - Ymin))), sftLeftAligned) - - .DrawString(tbYmin.Text, fntTimes, Brushes.Black, New RectangleF(e.MarginBounds.Left, e.MarginBounds.Bottom, CSng(Xmin * -2 * e.MarginBounds.Width / (Xmax - Xmin)), e.PageBounds.Bottom - e.MarginBounds.Bottom), sftTopAligned) - .DrawString(tbYmax.Text, fntTimes, Brushes.Black, New RectangleF(e.MarginBounds.Left, e.PageBounds.Top, CSng(Xmin * -2 * e.MarginBounds.Width / (Xmax - Xmin)), e.MarginBounds.Top), sftBottomAligned) - End With + If snapshot Is Nothing Then Return + Dim printed = PlotSnapshot.Build(snapshot.Definitions, snapshot.Viewport.XRange, + snapshot.Viewport.YRange, CanvasFor(e.MarginBounds.Size)) + PlotDrawing.Render(e.Graphics, printed, e.MarginBounds, + DrawCalculatedderivativeGraphToolStripMenuItem.Checked, + DrawCalculatedantiderivativeGraphToolStripMenuItem.Checked, True) + Using font As New Font("Times New Roman", 12), alignment As New StringFormat With {.Alignment = StringAlignment.Near} + Dim formulas = String.Join("; ", printed.Definitions.Select(Function(p) "y = " & p.Source).ToArray()) + Dim header As New RectangleF(e.MarginBounds.Left, e.PageBounds.Top, e.MarginBounds.Width, e.MarginBounds.Top) + e.Graphics.DrawString(formulas, font, Brushes.DarkRed, header, alignment) + Dim ranges = $"X: {printed.Viewport.XRange.Minimum} to {printed.Viewport.XRange.Maximum} Y: {printed.Viewport.YRange.Minimum} to {printed.Viewport.YRange.Maximum}" + e.Graphics.DrawString(ranges, font, Brushes.Black, e.MarginBounds.Left, e.MarginBounds.Bottom + 4) + End Using End Sub Private Sub PrintToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles PrintToolStripMenuItem.Click - If PrintDialog.ShowDialog() = DialogResult.OK Then - PrintDocument.Print() - End If + If snapshot IsNot Nothing AndAlso PrintDialog.ShowDialog(Me) = DialogResult.OK Then PrintDocument.Print() End Sub Private Sub PrintPreviewToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles PrintPreviewToolStripMenuItem.Click - PrintPreviewDialog.ShowDialog(Me) + If snapshot IsNot Nothing Then PrintPreviewDialog.ShowDialog(Me) End Sub Private Sub PrintPreviewDialog_Load(sender As Object, e As EventArgs) Handles PrintPreviewDialog.Load - With PrintPreviewDialog - .Left = Left + 10 - .Top = Top + 40 - .Width = ScreenSurface.Area.Width - 20 - .Height = ScreenSurface.Area.Height - End With + PrintPreviewDialog.SetBounds(Left + 10, Top + 40, Math.Max(300, Width - 20), Math.Max(300, Height)) End Sub Private Sub PrintSetupToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles PrintSetupToolStripMenuItem.Click PageSetupDialog.ShowDialog(Me) End Sub + Private Sub ExitToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles ExitToolStripMenuItem.Click + Close() + End Sub + Private Sub AboutToolStripMenuItem_Click(sender As Object, e As EventArgs) Handles AboutToolStripMenuItem.Click - Using AboutBox As New AboutBox - AboutBox.ShowDialog(Me) + Using about As New AboutBox + about.ShowDialog(Me) End Using End Sub -End Class \ No newline at end of file +End Class diff --git a/GraphViewer/GraphViewer.vbproj b/GraphViewer/GraphViewer.vbproj index bf8bf98..829c875 100644 --- a/GraphViewer/GraphViewer.vbproj +++ b/GraphViewer/GraphViewer.vbproj @@ -24,7 +24,8 @@ - + + diff --git a/GraphViewer/PlotDrawing.vb b/GraphViewer/PlotDrawing.vb new file mode 100644 index 0000000..5f8b5bf --- /dev/null +++ b/GraphViewer/PlotDrawing.vb @@ -0,0 +1,54 @@ +Imports System.Drawing +Imports System.Drawing.Drawing2D +Imports LTRData.FunctionPlotting + +' System.Drawing stays at the Windows application boundary. +Public Module PlotDrawing + Public Sub Render(graphics As Graphics, snapshot As PlotSnapshot, bounds As Rectangle, + drawDerivative As Boolean, drawIntegral As Boolean, forPrint As Boolean) +#If NET6_0_OR_GREATER Then + ArgumentNullException.ThrowIfNull(graphics) + ArgumentNullException.ThrowIfNull(snapshot) +#Else + If graphics Is Nothing Then Throw New ArgumentNullException(NameOf(graphics)) + If snapshot Is Nothing Then Throw New ArgumentNullException(NameOf(snapshot)) +#End If + If bounds.Width <= 0 OrElse bounds.Height <= 0 Then Return + Dim state = graphics.Save() + Try + graphics.SetClip(bounds, CombineMode.Intersect) + Using background As New SolidBrush(If(forPrint, Color.White, Color.DarkBlue)) + graphics.FillRectangle(background, bounds) + End Using + graphics.TranslateTransform(bounds.Left, bounds.Top) + graphics.SmoothingMode = SmoothingMode.AntiAlias + Dim viewport = snapshot.Viewport + Dim axisPen = If(forPrint, Pens.Orange, Pens.DarkRed) + If viewport.YRange.Contains(0) Then + Dim origin = CartesianTransform.ToCanvas(viewport.XRange.Minimum, 0, viewport) + graphics.DrawLine(axisPen, 0.0F, CSng(origin.Y), CSng(viewport.Canvas.Width), CSng(origin.Y)) + End If + If viewport.XRange.Contains(0) Then + Dim origin = CartesianTransform.ToCanvas(0, viewport.YRange.Minimum, viewport) + graphics.DrawLine(axisPen, CSng(origin.X), 0.0F, CSng(origin.X), CSng(viewport.Canvas.Height)) + End If + For Each layer In snapshot.Layers + If drawDerivative Then DrawCurve(graphics, layer.Derivative, If(forPrint, Pens.LightGray, Pens.DarkGreen)) + If drawIntegral Then DrawCurve(graphics, layer.Integral, If(forPrint, Pens.DarkGray, Pens.Blue)) + DrawCurve(graphics, layer.Curve, If(forPrint, Pens.DarkRed, Pens.Yellow)) + Next + Finally + graphics.Restore(state) + End Try + End Sub + + Private Sub DrawCurve(graphics As Graphics, geometry As CurveGeometry, pen As Pen) + For Each line In geometry.Polylines + Dim points(line.Points.Count - 1) As PointF + For index = 0 To points.Length - 1 + points(index) = New PointF(CSng(line.Points(index).X), CSng(line.Points(index).Y)) + Next + If points.Length >= 2 Then graphics.DrawLines(pen, points) + Next + End Sub +End Module diff --git a/GraphViewer/PlotSnapshot.vb b/GraphViewer/PlotSnapshot.vb new file mode 100644 index 0000000..8006e05 --- /dev/null +++ b/GraphViewer/PlotSnapshot.vb @@ -0,0 +1,84 @@ +Imports System.Collections.ObjectModel +Imports LTRData.FunctionPlotting +Imports LTRData.MathExpression + +' Application-owned plot definitions contain no native drawing resources. +Public NotInheritable Class PlotDefinition + Private Sub New(source As String, formula As UnaryMathFunction) + Me.Source = source + Me.Formula = formula + End Sub + + Public ReadOnly Property Source As String + Public ReadOnly Property Formula As UnaryMathFunction + + Public Shared Function Parse(source As String) As PlotDefinition + Dim parsed = MathParser.Default.Parse(source) + If Not parsed.Success Then + Throw New FormatException(FormatDiagnostics(parsed.Diagnostics)) + End If + Dim binding = MathBinder.Bind(parsed.Root, MathSymbolCatalog.Standard) + If Not binding.Success Then + Throw New FormatException(FormatDiagnostics(binding.Diagnostics)) + End If + If binding.Expression.Variables.Any(Function(v) Not String.Equals(v.Name, "x", StringComparison.OrdinalIgnoreCase)) Then + Throw New FormatException("A graph may use x as its only variable. Previous-y recurrence is not supported.") + End If + Return New PlotDefinition(source, binding.Expression.BindUnary("x")) + End Function + + Private Shared Function FormatDiagnostics(diagnostics As IEnumerable(Of MathDiagnostic)) As String + Return String.Join(Environment.NewLine, diagnostics.Select(Function(d) d.ToString()).ToArray()) + End Function +End Class + +Public NotInheritable Class PlotLayer + Friend Sub New(curve As CurveGeometry, derivative As CurveGeometry, integral As CurveGeometry) + Me.Curve = curve + Me.Derivative = derivative + Me.Integral = integral + End Sub + + Public ReadOnly Property Curve As CurveGeometry + Public ReadOnly Property Derivative As CurveGeometry + Public ReadOnly Property Integral As CurveGeometry +End Class + +' A complete, immutable frame. All overlays share one coordinate system. +Public NotInheritable Class PlotSnapshot + Private Sub New(definitions As PlotDefinition(), viewport As PlotViewport, layers As PlotLayer()) + Me.Definitions = Array.AsReadOnly(definitions) + Me.Viewport = viewport + Me.Layers = Array.AsReadOnly(layers) + End Sub + + Public ReadOnly Property Definitions As ReadOnlyCollection(Of PlotDefinition) + Public ReadOnly Property Viewport As PlotViewport + Public ReadOnly Property Layers As ReadOnlyCollection(Of PlotLayer) + + Public Shared Function Build(definitions As IEnumerable(Of PlotDefinition), xRange As NumericRange, + yRange As NumericRange, canvas As CanvasSize) As PlotSnapshot +#If NET6_0_OR_GREATER Then + ArgumentNullException.ThrowIfNull(definitions) +#Else + If definitions Is Nothing Then Throw New ArgumentNullException(NameOf(definitions)) +#End If + If Double.IsInfinity(xRange.Length) OrElse Double.IsInfinity(yRange.Length) Then + Throw New ArgumentException("The difference between each range's endpoints must be finite.") + End If + Dim viewport = New PlotViewport(xRange, yRange, canvas) + Dim plots = definitions.ToArray() + Dim layers(plots.Length - 1) As PlotLayer + Dim sampleCount = CInt(Math.Min(32769, Math.Max(3, Math.Ceiling(canvas.Width) * 2 + 1))) + For index = 0 To plots.Length - 1 + Dim plot = plots(index) + If plot Is Nothing Then Throw New ArgumentException("Plot definitions cannot contain Nothing.", NameOf(definitions)) + Dim samples = FunctionSampler.Sample(AddressOf plot.Formula.Evaluate, xRange, sampleCount) + layers(index) = New PlotLayer( + CurveGeometryBuilder.Build(samples, viewport), + CurveGeometryBuilder.Build(SampleCalculus.Differentiate(samples), viewport), + CurveGeometryBuilder.Build(SampleCalculus.IntegrateFiniteRuns(samples), viewport)) + Next + Return New PlotSnapshot(plots, viewport, layers) + End Function +End Class diff --git a/WindowsTools.slnx b/WindowsTools.slnx index 1e103ff..07385c1 100644 --- a/WindowsTools.slnx +++ b/WindowsTools.slnx @@ -5,6 +5,7 @@ - + + diff --git a/docs/graphviewer-migration.md b/docs/graphviewer-migration.md new file mode 100644 index 0000000..b64a813 --- /dev/null +++ b/docs/graphviewer-migration.md @@ -0,0 +1,79 @@ +# GraphViewer modern expression and plotting API review + +GraphViewer now consumes `LTRData.MathExpression` 1.1.0 and +`LTRData.FunctionPlotting` 1.2.0 through NuGet. The existing net35/net40 +and net8.0-windows/net9.0-windows/net10.0-windows targets remain. + +The old `ScriptControl`/`Surface` implementation and the `LTRLib.Windows` package +reference are replaced by bound unary functions and portable plot geometry. A +small application-owned System.Drawing renderer serves painting, printing and +BMP/GIF/JPEG/PNG/TIFF export. No SkiaSharp/native deployment package is needed. + +## Behavior to review + +- With replacement enabled, redraw captures one formula. With it disabled, redraw + appends an overlay. Repaint, resize and derivative/integral toggles preserve the + formula collection without adding duplicates. Changing ranges reprojects every + retained formula into the new common viewport. +- Invalid formula/range input preserves the last successful graph. Range controls + accept local decimals and invariant scientific notation, with finite increasing + limits. Formula syntax itself remains invariant and uses the modern language. +- Numerical derivatives use three-point differences with one-sided endpoints. + Integrals use signed trapezoidal areas, starting at zero at the leftmost sample + of each finite run. Neither calculation crosses a non-finite sample. Integrals + do not wrap at viewport edges. Tooltips describe these rules. +- Printing and export use the committed plots, including overlays. Editing the + formula box without redrawing does not change the printed graph. Print geometry + is rebuilt for the page and offset into the margins. +- Previous-`y` recurrence and shift/bitwise syntax are rejected. Conventional + power precedence applies, including `-2^2` = -4 and `2^3^2` = 512. + +Sampling is twice the canvas width plus one, bounded to 3–32769 points. These are +numerical approximations, so changing the sample density can change derivative +and integral values. A discontinuity between finite samples can still be missed; +there is no improper integration or complete asymptote detection. + +## Build through the local feed + +Set `LocalNuGetPath` to the shared package output directory and include it in this +repository's local NuGet.Config. Build these Library projects in Release first: + +```powershell +dotnet build LTRData.Extensions/LTRData.Extensions.csproj -c Release +dotnet build LTRData.MathExpression/LTRData.MathExpression.csproj -c Release +dotnet build LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj -c Release +``` + +Then, from WindowsTools: + +```powershell +dotnet restore GraphViewer/GraphViewer.vbproj --force-evaluate +dotnet build GraphViewer/GraphViewer.vbproj -c Release -f net10.0-windows --no-restore +dotnet run --project GraphViewer.Review/GraphViewer.Review.csproj -c Release +``` + +The .NET Framework targets contain bitmap/icon resources that need the full +Windows MSBuild resource toolchain. From a Visual Studio developer shell, after +the restore above: + +```powershell +msbuild GraphViewer/GraphViewer.vbproj /p:Configuration=Release /p:TargetFramework=net35 +msbuild GraphViewer/GraphViewer.vbproj /p:Configuration=Release /p:TargetFramework=net40 +``` + +The focused `GraphViewer package review` workflow builds the producer packages, +compiles all application targets and runs the review executable on Windows. The +review checks actual GDI+ rendering, all five encoders, margin placement and +graphics-state restoration, plus form overlays, toggles and resize. Its optional +`GRAPHVIEWER_REVIEW_OUTPUT` directory receives a print-style PNG, also uploaded as +a CI artifact. Library's +[local package workflow](https://github.com/LTRData/Library/blob/experimental/math-expression-redesign/docs/local-package-workflow.md) +explains source mapping and fresh caches to ensure the locally built packages are +used. There are no project references across repositories. + +The application owner has built and tested this migration successfully. Actual +printers, DPI scaling, saved preferences and curve appearance at usual ranges +remain useful manual review cases. Compilation of net35/net40 does not establish +execution on an old Windows installation. FreeBSD SkiaSharp work remains deferred; +XML serialization generation is addressed in ltrwebdb. Neither is a dependency +of this application.