diff --git a/AGENTS.md b/AGENTS.md index 60c3fa4..baa5468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other | `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math | | `samples/SharpEquations.Avalonia.Demo/` | A window with the editor (`DemoView`, shared with the web demo) | | `samples/SharpEquations.Avalonia.Browser.Demo/` | The same demo on WebAssembly (needs the `wasm-tools` workload, so not in the solution; `.github/workflows/demo.yml` publishes it to GitHub Pages) | -| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad, the library of formulas) | +| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad, the library of formulas, the unit converter) | | `samples/SharpEquations.Avalonia.Browser.MathTools.Demo/` | The math tools on WebAssembly (as the web demo, not in the solution build; `demo.yml` publishes them under `math/`) | | `benchmarks/SharpEquations.Benchmarks/` | BenchmarkDotNet benchmarks of reading, writing and converting the corpora | | `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) | diff --git a/README.md b/README.md index 780ce40..08a6a42 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ UnicodeMath and LaTeX, and `samples/SharpEquations.Avalonia.Browser.Demo` the sa (published to GitHub Pages from `main`; to run it locally: `dotnet workload install wasm-tools`, then `dotnet run --project samples/SharpEquations.Avalonia.Browser.Demo`). `samples/SharpEquations.Avalonia.MathTools.Demo` is a second app around the editor, a set of math tools (a scientific keypad that types into the editor, a library of -formulas), with +formulas, a unit converter), with `samples/SharpEquations.Avalonia.Browser.MathTools.Demo` in the browser (published under `math/` of the same site). ## Architecture diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs index ae66ffb..72aff30 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs @@ -9,8 +9,8 @@ namespace SharpEquations.Avalonia.MathTools using SharpEquations; /// - /// The math tools, a page each: the scientific keypad around the equation editor, and the library of formulas. The - /// desktop window and the WebAssembly page show the same view. + /// The math tools, a page each: the scientific keypad around the equation editor, the library of formulas and the + /// unit converter. The desktop window and the WebAssembly page show the same view. /// public sealed class MathToolsView : UserControl { @@ -20,6 +20,7 @@ public MathToolsView() { Ui.AddBrushes(Resources); Calculator = new CalculatorPage(); + Converter = new UnitsPage(); Tabs = new TabControl { Margin = new Thickness(20, 12, 20, 0) }; FormulaLibrary = new FormulasPage(equation => { @@ -31,6 +32,7 @@ public MathToolsView() { Tab("Calculator", Calculator), Tab("Formulas", FormulaLibrary), + Tab("Units", Converter), }; var root = new DockPanel { Children = { Ui.Docked(Header(), Dock.Top), Tabs } }; @@ -44,6 +46,9 @@ public MathToolsView() /// The page of the library of formulas. public FormulasPage FormulaLibrary { get; } + /// The page of the unit converter. + public UnitsPage Converter { get; } + /// The tabs of the pages. public TabControl Tabs { get; } diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Units.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/Units.cs new file mode 100644 index 0000000..4c609a3 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/Units.cs @@ -0,0 +1,144 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + + /// A unit of a quantity: a value in it is value × Factor + Offset in the base unit of the quantity. + public sealed class Unit + { + public Unit(string name, string symbol, double factor, double offset = 0) + { + Name = name; + Symbol = symbol; + Factor = factor; + Offset = offset; + } + + public string Name { get; } + + public string Symbol { get; } + + public double Factor { get; } + + public double Offset { get; } + + public override string ToString() => $"{Name} ({Symbol})"; + } + + /// A quantity and its units, the first of which is the base unit. + public sealed class Quantity + { + public Quantity(string name, params Unit[] units) + { + Name = name; + Units = units; + } + + public string Name { get; } + + public IReadOnlyList Units { get; } + + public Unit this[string symbol] => Units.Single(u => u.Symbol == symbol); + + public override string ToString() => Name; + } + + /// The quantities of the converter, and the conversion and the formatting of the values. + public static class Units + { + private const double Inch = 0.0254; + private const double Pound = 0.45359237; + private const double Gallon = 3.785411784e-3; + + public static IReadOnlyList All { get; } = new[] + { + new Quantity("Length", + new Unit("metre", "m", 1), new Unit("kilometre", "km", 1e3), new Unit("centimetre", "cm", 1e-2), new Unit("millimetre", "mm", 1e-3), + new Unit("micrometre", "μm", 1e-6), new Unit("nanometre", "nm", 1e-9), new Unit("inch", "in", Inch), new Unit("foot", "ft", 12 * Inch), + new Unit("yard", "yd", 36 * Inch), new Unit("mile", "mi", 63360 * Inch), new Unit("nautical mile", "nmi", 1852), + new Unit("astronomical unit", "au", 149597870700), new Unit("light-year", "ly", 9460730472580800)), + new Quantity("Area", + new Unit("square metre", "m²", 1), new Unit("square kilometre", "km²", 1e6), new Unit("square centimetre", "cm²", 1e-4), + new Unit("hectare", "ha", 1e4), new Unit("are", "a", 100), new Unit("square inch", "in²", Inch * Inch), + new Unit("square foot", "ft²", 144 * Inch * Inch), new Unit("acre", "ac", 4046.8564224), new Unit("square mile", "mi²", 63360 * Inch * 63360 * Inch)), + new Quantity("Volume", + new Unit("cubic metre", "m³", 1), new Unit("litre", "L", 1e-3), new Unit("millilitre", "mL", 1e-6), new Unit("cubic centimetre", "cm³", 1e-6), + new Unit("US gallon", "gal", Gallon), new Unit("US quart", "qt", Gallon / 4), new Unit("US pint", "pt", Gallon / 8), + new Unit("US cup", "cup", Gallon / 16), new Unit("US fluid ounce", "fl oz", Gallon / 128), new Unit("imperial gallon", "imp gal", 4.54609e-3), + new Unit("cubic foot", "ft³", 1728 * Inch * Inch * Inch), new Unit("cubic inch", "in³", Inch * Inch * Inch)), + new Quantity("Mass", + new Unit("kilogram", "kg", 1), new Unit("gram", "g", 1e-3), new Unit("milligram", "mg", 1e-6), new Unit("tonne", "t", 1e3), + new Unit("pound", "lb", Pound), new Unit("ounce", "oz", Pound / 16), new Unit("stone", "st", 14 * Pound), + new Unit("short ton", "ton", 2000 * Pound), new Unit("carat", "ct", 2e-4)), + new Quantity("Time", + new Unit("second", "s", 1), new Unit("millisecond", "ms", 1e-3), new Unit("microsecond", "μs", 1e-6), new Unit("minute", "min", 60), + new Unit("hour", "h", 3600), new Unit("day", "d", 86400), new Unit("week", "wk", 604800), new Unit("year (365.25 days)", "yr", 31557600)), + new Quantity("Speed", + new Unit("metre per second", "m/s", 1), new Unit("kilometre per hour", "km/h", 1 / 3.6), new Unit("mile per hour", "mph", 63360 * Inch / 3600), + new Unit("foot per second", "ft/s", 12 * Inch), new Unit("knot", "kn", 1852 / 3600.0), new Unit("speed of light", "c", 299792458)), + new Quantity("Temperature", + new Unit("kelvin", "K", 1), new Unit("degree Celsius", "°C", 1, 273.15), new Unit("degree Fahrenheit", "°F", 5 / 9.0, 459.67 * 5 / 9.0), + new Unit("degree Rankine", "°R", 5 / 9.0)), + new Quantity("Energy", + new Unit("joule", "J", 1), new Unit("kilojoule", "kJ", 1e3), new Unit("calorie", "cal", 4.184), new Unit("kilocalorie", "kcal", 4184), + new Unit("watt-hour", "Wh", 3600), new Unit("kilowatt-hour", "kWh", 3.6e6), new Unit("electronvolt", "eV", 1.602176634e-19), + new Unit("British thermal unit", "BTU", 1055.05585262)), + new Quantity("Pressure", + new Unit("pascal", "Pa", 1), new Unit("kilopascal", "kPa", 1e3), new Unit("bar", "bar", 1e5), new Unit("atmosphere", "atm", 101325), + new Unit("millimetre of mercury", "mmHg", 133.322387415), new Unit("pound per square inch", "psi", Pound * 9.80665 / (Inch * Inch))), + new Quantity("Angle", + new Unit("radian", "rad", 1), new Unit("degree", "°", Math.PI / 180), new Unit("gradian", "grad", Math.PI / 200), + new Unit("arcminute", "′", Math.PI / 10800), new Unit("arcsecond", "″", Math.PI / 648000), new Unit("turn", "tr", 2 * Math.PI)), + new Quantity("Data", + new Unit("byte", "B", 1), new Unit("bit", "bit", 0.125), new Unit("kilobyte", "kB", 1e3), new Unit("megabyte", "MB", 1e6), + new Unit("gigabyte", "GB", 1e9), new Unit("terabyte", "TB", 1e12), new Unit("kibibyte", "KiB", 1024), new Unit("mebibyte", "MiB", 1 << 20), + new Unit("gibibyte", "GiB", 1 << 30), new Unit("tebibyte", "TiB", 1099511627776)), + }; + + public static Quantity Get(string name) => All.Single(q => q.Name == name); + + /// A value in a unit, in another unit of the same quantity. + public static double Convert(double value, Unit from, Unit to) => (value * from.Factor + from.Offset - to.Offset) / to.Factor; + + /// A number typed by the user: a point or a comma before the decimals, and an exponent (1.5e3). + public static bool TryParse(string? text, out double value) + { + value = 0; + if (string.IsNullOrWhiteSpace(text)) + return false; + var normalized = text!.Trim().Replace(" ", string.Empty).Replace('−', '-').Replace(',', '.'); + return double.TryParse(normalized, NumberStyles.Float, CultureInfo.InvariantCulture, out value) && !double.IsInfinity(value); + } + + /// + /// A value in UnicodeMath, with six significant digits: in scientific notation (1.5×10^6) when it is very + /// large or very small. What follows it is separated by a space, which ends the exponent. + /// + public static string Format(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + return "\"—\""; + var magnitude = Math.Abs(value); + if (magnitude != 0 && (magnitude >= 1e6 || magnitude < 1e-4)) + { + var exponent = (int)Math.Floor(Math.Log10(magnitude)); + var mantissa = Math.Round(value / Math.Pow(10, exponent), 5); + if (Math.Abs(mantissa) >= 10) + { + mantissa /= 10; + exponent++; + } + return Plain(mantissa) + "×10^" + (exponent < 0 ? "(-" + -exponent + ")" : exponent.ToString(CultureInfo.InvariantCulture)); + } + return Plain(value); + } + + private static string Plain(double value) + { + var text = value.ToString("G6", CultureInfo.InvariantCulture); + return text == "-0" ? "0" : text; + } + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/UnitsPage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/UnitsPage.cs new file mode 100644 index 0000000..9b683c9 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/UnitsPage.cs @@ -0,0 +1,141 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System.Collections.Generic; + using System.Linq; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using SharpEquations; + + /// + /// The unit converter: a value in a unit of a quantity, in another unit and in all of them, the results written in + /// UnicodeMath and drawn by a . + /// + public sealed class UnitsPage : UserControl + { + private readonly MathView result = new MathView { FontSize = 30, HorizontalAlignment = HorizontalAlignment.Left }; + private readonly StackPanel table = new StackPanel { Spacing = 2 }; + private bool updating; + + public UnitsPage() + { + Quantity = new ComboBox { ItemsSource = Units.All, SelectedIndex = 0, MinWidth = 160 }; + Value = new TextBox { Text = "1", Width = 150, PlaceholderText = "A number" }; + From = new ComboBox { MinWidth = 220 }; + To = new ComboBox { MinWidth = 220 }; + var swap = new Button { Content = "⇄", FontSize = 17, Padding = new Thickness(12, 2), CornerRadius = new CornerRadius(8) }; + ToolTip.SetTip(swap, "Swap the units"); + + Quantity.SelectionChanged += (_, _) => ShowQuantity(); + Value.PropertyChanged += (_, e) => + { + if (e.Property == TextBox.TextProperty) + Refresh(); + }; + From.SelectionChanged += (_, _) => Refresh(); + To.SelectionChanged += (_, _) => Refresh(); + swap.Click += (_, _) => Swap(); + + var resultFrame = new Border { CornerRadius = new CornerRadius(12), Padding = new Thickness(18, 14), MinHeight = 76, Child = result }; + resultFrame.Bind(Border.BackgroundProperty, resultFrame.GetResourceObservable("CodeBrush")); + + Content = new StackPanel + { + Spacing = 20, + Children = + { + Ui.Card("Units", "A value in a unit, in another: length, area, volume, mass, time, speed, temperature, energy, pressure, angle and data", new StackPanel + { + Spacing = 16, + Children = + { + Ui.Row(Field("Quantity", Quantity), Field("Value", Value), Field("From", From), swap, Field("To", To)), + resultFrame, + }, + }), + Ui.Card("In every unit", "The value in all the units of the quantity", table), + }, + }; + ShowQuantity(); + } + + public ComboBox Quantity { get; } + + public TextBox Value { get; } + + public ComboBox From { get; } + + public ComboBox To { get; } + + /// The result, as UnicodeMath (1 " km"=0.621371 " mi", with no-break spaces in the text), or null when the value is not a number. + public string? Result { get; private set; } + + /// Swaps the units, as the ⇄ button does. + public void Swap() + { + var from = From.SelectedIndex; + updating = true; + From.SelectedIndex = To.SelectedIndex; + updating = false; + To.SelectedIndex = from; + } + + private Quantity Selected => (Quantity)Quantity.SelectedItem!; + + private void ShowQuantity() + { + updating = true; + From.ItemsSource = Selected.Units; + To.ItemsSource = Selected.Units; + From.SelectedIndex = 0; + To.SelectedIndex = 1; + updating = false; + Refresh(); + } + + private void Refresh() + { + if (updating || From.SelectedItem is not Unit from || To.SelectedItem is not Unit to) + return; + + table.Children.Clear(); + if (!Units.TryParse(Value.Text, out var value)) + { + Result = null; + result.Equation = null; + table.Children.Add(Ui.Muted("Type a number, such as 12.5 or 1.5e3.")); + return; + } + + Result = Units.Format(value) + " " + Text(from.Symbol) + "=" + Units.Format(Units.Convert(value, from, to)) + " " + Text(to.Symbol); + result.Equation = Equation.Parse(Result, EquationFormat.UnicodeMath, new EquationOptions { Display = true }); + foreach (var unit in Selected.Units) + table.Children.Add(Line(unit, Units.Convert(value, from, unit), unit == from || unit == to)); + } + + private static Grid Line(Unit unit, double value, bool highlighted) + { + var name = new TextBlock { Text = unit.Name, FontSize = 13.5, VerticalAlignment = VerticalAlignment.Center }; + if (highlighted) + name.FontWeight = FontWeight.SemiBold; + var math = Ui.Math(Units.Format(value) + " " + Text(unit.Symbol), 17); + math.HorizontalAlignment = HorizontalAlignment.Left; + math.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(math, 1); + return new Grid { ColumnDefinitions = new ColumnDefinitions("220,*"), MinHeight = 32, Children = { name, math } }; + } + + // a symbol as text of UnicodeMath, upright and kept as it is (a quote in it is not in any symbol), after a + // no-break space that separates it from the number (the space of UnicodeMath before it ends an exponent, and is + // not drawn) + private static string Text(string symbol) => "\"\u00A0" + symbol + "\""; + + private static StackPanel Field(string label, Control control) + { + var text = Ui.Muted(label, 12.5); + text.VerticalAlignment = VerticalAlignment.Center; + return new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { text, control } }; + } + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs index c4b2dbc..095fe49 100644 --- a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs @@ -68,6 +68,40 @@ public async Task FormulaOpensInTheCalculator() await Assert.That(equation).IsEqualTo(Equation.Parse("c^2=a^2+b^2-2ab cos⁡γ", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); await Assert.That(tab).IsEqualTo(0); } + + [Test] + public async Task UnitsConvert() + { + var (miles, swapped, celsius, invalid) = await Headless.Run(() => + { + var window = new MainWindow(); + window.Show(); + var view = window.GetVisualDescendants().OfType().Single(); + view.Tabs.SelectedIndex = 2; + var page = view.Converter; + page.Value.Text = "42.195"; + page.From.SelectedItem = Units.Get("Length")["km"]; + page.To.SelectedItem = Units.Get("Length")["mi"]; + window.UpdateLayout(); + var first = page.Result; + Save(window, "math-tools-units"); + page.Swap(); + var second = page.Result; + page.Quantity.SelectedItem = Units.Get("Temperature"); + page.Value.Text = "98.6"; + page.From.SelectedItem = Units.Get("Temperature")["°F"]; + page.To.SelectedItem = Units.Get("Temperature")["°C"]; + var third = page.Result; + page.Value.Text = "abc"; + var fourth = page.Result; + window.Close(); + return (first, second, third, fourth); + }); + await Assert.That(miles).IsEqualTo("42.195 \"\u00A0km\"=26.2188 \"\u00A0mi\""); + await Assert.That(swapped).IsEqualTo("42.195 \"\u00A0mi\"=67.9063 \"\u00A0km\""); + await Assert.That(celsius).IsEqualTo("98.6 \"\u00A0°F\"=37 \"\u00A0°C\""); + await Assert.That(invalid).IsNull(); + } } } #endif diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/UnitsTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/UnitsTests.cs new file mode 100644 index 0000000..222fb02 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/MathTools/UnitsTests.cs @@ -0,0 +1,95 @@ +#if NET10_0_OR_GREATER +namespace SharpEquations.Avalonia.Tests.MathTools +{ + using System; + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Avalonia.MathTools; + + /// The conversions of the unit converter, and the values written as UnicodeMath. + public class UnitsTests + { + [Test] + [Arguments("Length", 1, "mi", "km", 1.609344)] + [Arguments("Length", 1, "ft", "in", 12)] + [Arguments("Area", 1, "ha", "m²", 10000)] + [Arguments("Area", 1, "mi²", "ac", 640)] + [Arguments("Volume", 1, "gal", "L", 3.785411784)] + [Arguments("Mass", 1, "lb", "oz", 16)] + [Arguments("Mass", 1, "kg", "lb", 2.20462262185)] + [Arguments("Time", 2, "h", "min", 120)] + [Arguments("Speed", 100, "km/h", "m/s", 27.7777777778)] + [Arguments("Temperature", 100, "°C", "°F", 212)] + [Arguments("Temperature", -40, "°F", "°C", -40)] + [Arguments("Temperature", 0, "K", "°C", -273.15)] + [Arguments("Temperature", 491.67, "°R", "°F", 32)] + [Arguments("Energy", 1, "kWh", "kcal", 860.420650096)] + [Arguments("Pressure", 1, "atm", "psi", 14.6959487755)] + [Arguments("Angle", 180, "°", "rad", Math.PI)] + [Arguments("Data", 1, "GiB", "MB", 1073.741824)] + public async Task Converts(string quantity, double value, string from, string to, double expected) + { + var q = Units.Get(quantity); + await Assert.That(Units.Convert(value, q[from], q[to])).IsEqualTo(expected).Within(Math.Abs(expected) * 1e-9 + 1e-9); + await Assert.That(Units.Convert(Units.Convert(value, q[from], q[to]), q[to], q[from])).IsEqualTo(value).Within(Math.Abs(value) * 1e-9 + 1e-9); + } + + [Test] + [Arguments(1.0, "1")] + [Arguments(0.621371192237, "0.621371")] + [Arguments(-273.15, "-273.15")] + [Arguments(123456.7, "123457")] + [Arguments(9460730472580800.0, "9.46073×10^15")] + [Arguments(1.602176634e-19, "1.60218×10^(-19)")] + [Arguments(9.999996e9, "1×10^10")] + [Arguments(0.0, "0")] + public async Task FormatsAsUnicodeMath(double value, string expected) + { + await Assert.That(Units.Format(value)).IsEqualTo(expected); + await Assert.That(Equation.TryParse(expected, EquationFormat.UnicodeMath, out _)).IsTrue(); + } + + /// The unit after a value in scientific notation is not in its exponent. + [Test] + public async Task UnitIsNotInTheExponent() + { + var math = Equation.Parse(Units.Format(4.2195e6) + " \" cm\"", EquationFormat.UnicodeMath).ToMathML(); + var power = math.Descendants().Single(e => e.Name.LocalName == "msup"); + await Assert.That(power.Descendants().Any(e => e.Name.LocalName == "mtext")).IsFalse(); + } + + [Test] + [Arguments("12.5", 12.5)] + [Arguments(" 12,5 ", 12.5)] + [Arguments("−3", -3.0)] + [Arguments("1.5e3", 1500.0)] + public async Task ParsesWhatIsTyped(string text, double expected) + { + await Assert.That(Units.TryParse(text, out var value)).IsTrue(); + await Assert.That(value).IsEqualTo(expected); + } + + [Test] + public async Task RejectsWhatIsNotANumber() + { + foreach (var text in new[] { "", " ", "abc", "1e999" }) + await Assert.That(Units.TryParse(text, out _)).IsFalse().Because(text); + } + + [Test] + public async Task EverySymbolIsUniqueAndParses() + { + foreach (var quantity in Units.All) + { + await Assert.That(quantity.Units.Select(u => u.Symbol).Distinct().Count()).IsEqualTo(quantity.Units.Count).Because(quantity.Name); + foreach (var unit in quantity.Units) + { + var equation = Equation.Parse("1 \"" + unit.Symbol + "\"", EquationFormat.UnicodeMath); + await Assert.That(equation.ToMathML().Descendants().Any(e => e.Name.LocalName == "merror")).IsFalse().Because(unit.Symbol); + } + } + } + } +} +#endif