Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 82 additions & 3 deletions Computator.NET.WebApi.IntegrationTests/ChartApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Net;
using System.Net.Http.Json;
using System.Threading;
using Computator.NET.DataTypes.Functions;
using Computator.NET.WebApi.Services;
using Newtonsoft.Json.Linq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using NUnit.Framework;
Expand Down Expand Up @@ -40,11 +46,84 @@ public async Task ReturnsHttpOkForComplexChart()
using (var response = await _client.GetAsync("/api/chart/2z")) response.EnsureSuccessStatusCode();
}

private async Task<JObject> Surface(object request)
{
using var response = await _client.PostAsJsonAsync("/api/v2/chart", request);
var text = await response.Content.ReadAsStringAsync();
NUnit.Framework.Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), text);
NUnit.Framework.Assert.That(response.Content.Headers.ContentType?.MediaType, Is.EqualTo("application/json"));
return JObject.Parse(text);
}

[TestCase("x*x + y*y", 8.0, 0.0)]
[TestCase("x + y", -4.0, 0.0)]
[TestCase("sin(x)*cos(y)", 0.37840124765396416, 0.0)]
public async Task RealFxyApiSamplesKnownSurfaces(string expression, double corner, double center)
{
var grid = await Surface(new { expression, gridSize = 9, xMin = -2, xMax = 2,
yMin = -2, yMax = 2, width = 300, height = 300 });
NUnit.Framework.Assert.That((string)grid["kind"], Is.EqualTo("surface"));
NUnit.Framework.Assert.That((int)grid["finiteSamples"], Is.EqualTo(81));
NUnit.Framework.Assert.That((int)grid["missingSamples"], Is.Zero);
NUnit.Framework.Assert.That(grid["x"].Count(), Is.EqualTo(9));
NUnit.Framework.Assert.That(grid["y"].Count(), Is.EqualTo(9));
NUnit.Framework.Assert.That((double)grid["x"][0], Is.EqualTo(-2));
NUnit.Framework.Assert.That((double)grid["x"][8], Is.EqualTo(2));
NUnit.Framework.Assert.That((double)grid["y"][0], Is.EqualTo(-2));
NUnit.Framework.Assert.That((double)grid["y"][8], Is.EqualTo(2));
NUnit.Framework.Assert.That((double)grid["z"][0][0], Is.EqualTo(corner).Within(1e-9));
NUnit.Framework.Assert.That((double)grid["z"][4][4], Is.EqualTo(center).Within(1e-9));
}

[Test]
public async Task UnsavedTwoArgumentCustomFunctionsStayRequestLocal()
{
for (var offset = 1; offset <= 2; offset++)
{
var grid = await Surface(new { expression = "SurfaceProbe(x,y)", gridSize = 9,
customFunctions = "static real SurfaceProbe(real x, real y) { return x*x+y*y+" + offset + "; }" });
NUnit.Framework.Assert.That((double)grid["z"][4][4], Is.EqualTo(offset));
}
using var missing = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "SurfaceProbe(x,y)", gridSize = 9 });
NUnit.Framework.Assert.That(missing.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}

[Test]
public async Task SingularitiesBecomeNullHolesRatherThanInvalidJsonOrFlattenedZeros()
{
var grid = await Surface(new { expression = "1/(x-y)", gridSize = 9, xMin = -2, xMax = 2, yMin = -2, yMax = 2 });
NUnit.Framework.Assert.That((int)grid["missingSamples"], Is.GreaterThan(0));
NUnit.Framework.Assert.That(grid["z"][0][0].Type, Is.EqualTo(JTokenType.Null));
NUnit.Framework.Assert.That((double)grid["z"][0][8], Is.EqualTo(0.25).Within(1e-9));
}

[Test]
public async Task AllSingularSurfaceIsRejectedWithoutCorruptingSubsequentRequests()
{
using var response = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "1/(y-y)", gridSize = 9 });
NUnit.Framework.Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity));
await Surface(new { expression = "x+y", gridSize = 9 });
}

[TestCase(0)]
[TestCase(8)]
[TestCase(130)]
[TestCase(100000)]
public async Task SampleCountIsBoundedBeforeExpressionCompilation(int count)
{
using var response = await _client.PostAsJsonAsync("/api/v2/chart", new { expression = "not-valid(", gridSize = count });
NUnit.Framework.Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
NUnit.Framework.Assert.That(JObject.Parse(await response.Content.ReadAsStringAsync())["errors"]?["gridSize"], Is.Not.Null);
}

[Test]
[Ignore("Chart3D does not support server-side rendering yet")]
public async Task ReturnsHttpOkForChart3D()
public void SurfaceSamplerHonorsCancellationBeforeEvaluating()
{
using (var response = await _client.GetAsync("/api/chart/2y")) response.EnsureSuccessStatusCode();
var function = new Function(new Func<double, double, double>((x, y) => x+y), FunctionType.Real3D);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
NUnit.Framework.Assert.Throws<OperationCanceledException>(() =>
SurfaceSampler.Sample(function, -2, 2, -2, 2, 129, cancelled.Token));
}

[TestCase("png/300/300/-2.5/2.5/-0.5/0.5/x")]
Expand Down
16 changes: 15 additions & 1 deletion Computator.NET.WebApi/Controllers/ExecutionV2Controller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Computator.NET.Core.NumericalCalculations;
using Computator.NET.DataTypes;
using Computator.NET.DataTypes.Charts;
using Computator.NET.DataTypes.Functions;
using Computator.NET.WebApi.Services;
using Microsoft.AspNetCore.Mvc;

Expand Down Expand Up @@ -74,7 +75,19 @@ public IActionResult Chart([FromBody] ExecutionRequest request)
try
{
var mode = _mode.DetermineMode(request.Expression, request.CustomFunctions);
if (mode == CalculationsMode.Fxy) return UnprocessableEntity(new ProblemDetails { Title = "Server-side 3D rendering is not implemented." });
if (mode == CalculationsMode.Fxy)
{
// Preserve the PNG contract for existing real/complex charts.
// Surface samples are numerical data, not server-side bitmap rendering.
var surfaceFunction = new ExpressionsEvaluator().Evaluate(request.Expression, request.CustomFunctions, mode);
if (surfaceFunction.FunctionType != FunctionType.Real3D)
return UnprocessableEntity(new ProblemDetails { Title = "Implicit 3D equations are not supported as height surfaces." });
var surface = SurfaceSampler.Sample(surfaceFunction, request.XMin, request.XMax,
request.YMin, request.YMax, request.GridSize, HttpContext?.RequestAborted ?? CancellationToken.None);
if (surface.FiniteSamples < 3)
return UnprocessableEntity(new ProblemDetails { Title = "The selected domain contains too few finite surface samples." });
return Ok(surface);
}
var function = new ExpressionsEvaluator().Evaluate(request.Expression, request.CustomFunctions, mode);
chart = _charts.Create(mode); chart.AddFunction(function);
chart.XMin = request.XMin; chart.XMax = request.XMax;
Expand Down Expand Up @@ -107,6 +120,7 @@ public sealed class ExecutionRequest
public double Epsilon { get; set; } = 1e-6;
public int Width { get; set; } = 600;
public int Height { get; set; } = 400;
public int GridSize { get; set; } = 65;
public double XMin { get; set; } = -5;
public double XMax { get; set; } = 5;
public double YMin { get; set; } = -5;
Expand Down
8 changes: 7 additions & 1 deletion Computator.NET.WebApi/Services/ExecutionPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static class ExecutionPolicy
public const int MaximumOutputCharacters = 65536;
public const int MaximumDimension = 4096;
public const long MaximumPixels = 4194304;
public const int MaximumSurfaceGridSize = 129;
public const uint MaximumIterations = 1000000;
public const uint MaximumDerivativeOrder = 10;

Expand All @@ -31,7 +32,12 @@ public static Dictionary<string, string[]> Validate(ExecutionRequest request, bo
Source(errors, "expression", request.Expression, false);
Source(errors, "customFunctions", request.CustomFunctions, true);
if (!ValidNumbers(request)) errors["parameters"] = new[] { "Numeric parameters must be finite and within the documented iteration/order/error limits." };
if (chart) Chart(errors, request.Width, request.Height, request.XMin, request.XMax, request.YMin, request.YMax);
if (chart)
{
Chart(errors, request.Width, request.Height, request.XMin, request.XMax, request.YMin, request.YMax);
if (request.GridSize < 9 || request.GridSize > MaximumSurfaceGridSize)
errors["gridSize"] = new[] { $"Surface sampling requires 9–{MaximumSurfaceGridSize} points per axis." };
}
else
{
if (!new[] { "calculate", "script", "integral", "derivative", "function-root" }.Contains(request.Operation))
Expand Down
78 changes: 78 additions & 0 deletions Computator.NET.WebApi/Services/SurfaceSampler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System;
using System.Threading;
using Computator.NET.DataTypes;
using Computator.NET.DataTypes.Functions;

namespace Computator.NET.WebApi.Services
{
/// <summary>
/// Bounded numeric data for an interactive browser surface. This class never
/// initializes a Windows chart or ships compiled expressions to the client.
/// Z is row-major: Z[yIndex][xIndex]. Missing/non-real points are null.
/// </summary>
public static class SurfaceSampler
{
public static SurfaceGrid Sample(Function function, double xMin, double xMax,
double yMin, double yMax, int gridSize, CancellationToken cancellation)
{
if (function == null || function.FunctionType != FunctionType.Real3D)
throw new ArgumentException("Only explicit real f(x,y) functions can be sampled.", nameof(function));
if (gridSize < 9 || gridSize > ExecutionPolicy.MaximumSurfaceGridSize)
throw new ArgumentOutOfRangeException(nameof(gridSize));
if (!double.IsFinite(xMin) || !double.IsFinite(xMax) || !double.IsFinite(yMin) ||
!double.IsFinite(yMax) || xMin >= xMax || yMin >= yMax)
throw new ArgumentException("Surface bounds must be finite increasing intervals.");

var x = Axis(xMin, xMax, gridSize);
var y = Axis(yMin, yMax, gridSize);
var z = new double?[gridSize][];
var finite = 0;
for (var row = 0; row < gridSize; row++)
{
cancellation.ThrowIfCancellationRequested();
z[row] = new double?[gridSize];
for (var column = 0; column < gridSize; column++)
{
cancellation.ThrowIfCancellationRequested();
double value;
try { value = function.Evaluate(x[column], y[row]); }
// Arithmetic singularities leave holes in the mesh; other
// runtime errors (including invalid user code) remain errors.
catch (CalculationException error) when (error.InnerException is ArithmeticException)
{ continue; }
catch (ArithmeticException) { continue; }
if (!double.IsFinite(value)) continue;
z[row][column] = value;
finite++;
}
}
return new SurfaceGrid(x, y, z, finite);
}

private static double[] Axis(double minimum, double maximum, int count)
{
var values = new double[count];
for (var index = 0; index < count; index++)
{
// Weighted interpolation avoids overflowing (maximum - minimum).
var ratio = index / (double)(count - 1);
values[index] = index == 0 ? minimum : index == count - 1 ? maximum
: (1 - ratio) * minimum + ratio * maximum;
}
return values;
}
}

public sealed class SurfaceGrid
{
public string Kind => "surface";
public double[] X { get; }
public double[] Y { get; }
public double?[][] Z { get; }
public int FiniteSamples { get; }
public int MissingSamples => X.Length * Y.Length - FiniteSamples;

public SurfaceGrid(double[] x, double[] y, double?[][] z, int finiteSamples)
{ X = x; Y = y; Z = z; FiniteSamples = finiteSamples; }
}
}
41 changes: 33 additions & 8 deletions Computator.NET.WebClient/ClientApp/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import { useCustomFunctions } from '../helpers/CustomFunctionsContext';
import { useChartDraft } from '../helpers/ToolDraftContext';
import { Expression } from './Expression';
import { NumericInput } from './NumericInput';
import { SurfacePlot, isSurfaceGrid, type SurfaceGrid } from './SurfacePlot';

type Plot = { expression: string; customFunctions: string; xMin: number; xMax: number; yMin: number; yMax: number };
type RenderedPlot = { url: string; expression: string };
type Plot = { expression: string; customFunctions: string; xMin: number; xMax: number; yMin: number; yMax: number; gridSize: number };
type RenderedPlot = { kind: 'image'; url: string; expression: string } | { kind: 'surface'; grid: SurfaceGrid; expression: string };

export function Chart() {
const { expression, setExpression, xMin, setXMin, xMax, setXMax, yMin, setYMin, yMax, setYMax } = useChartDraft();
const [rendered, setRendered] = useState<RenderedPlot>();
const [gridSize, setGridSize] = useState(65);
const [error, setError] = useState(''), [pending, setPending] = useState(false), [status, setStatus] = useState('');
const container = useRef<HTMLDivElement>(null);
const active = useRef<AbortController | null>(null), imageUrl = useRef<string | null>(null);
const requested = useRef<Plot | null>(null);
// A surface resizes client-side without recompiling the user's function.
const shownSurface = useRef<Plot | null>(null);
const functions = useCustomFunctions();

const renderPlot = useCallback(async (plot: Plot) => {
Expand All @@ -40,13 +44,26 @@ export function Chart() {
}
throw new Error(detail);
}
if (!response.headers.get('content-type')?.toLowerCase().startsWith('image/png')) throw new Error('Invalid chart response.');
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
if (contentType.includes('application/json')) {
const json: unknown = await response.json();
if (!isSurfaceGrid(json)) throw new Error('Invalid 3D surface response.');
if (active.current !== controller || controller.signal.aborted) return;
if (imageUrl.current) URL.revokeObjectURL(imageUrl.current);
imageUrl.current = null;
shownSurface.current = plot;
setRendered({ kind: 'surface', grid: json, expression: plot.expression });
setStatus('3D surface samples ready.');
return;
}
if (!contentType.startsWith('image/png')) throw new Error('Invalid chart response.');
const image = await response.blob();
if (active.current !== controller || controller.signal.aborted) return;
const url = URL.createObjectURL(image);
if (imageUrl.current) URL.revokeObjectURL(imageUrl.current);
imageUrl.current = url;
setRendered({ url, expression: plot.expression });
shownSurface.current = null;
setRendered({ kind: 'image', url, expression: plot.expression });
setStatus('Chart ready.');
} catch (reason) {
if (active.current === controller && !controller.signal.aborted) {
Expand All @@ -65,7 +82,7 @@ export function Chart() {
if (![xMin, xMax, yMin, yMax].every(Number.isFinite) || xMin >= xMax || yMin >= yMax) {
setError('Each minimum must be smaller than its maximum, and all bounds must be finite.'); return;
}
const plot = { expression, customFunctions: functions.code, xMin, xMax, yMin, yMax };
const plot = { expression, customFunctions: functions.code, xMin, xMax, yMin, yMax, gridSize };
requested.current = plot;
void renderPlot(plot);
}
Expand All @@ -80,7 +97,7 @@ export function Chart() {
clearTimeout(timeout);
timeout = setTimeout(() => {
// Resize only the explicitly requested plot, never unsubmitted editor text.
if (requested.current) void renderPlot(requested.current);
if (requested.current && requested.current !== shownSurface.current) void renderPlot(requested.current);
}, 200);
};
window.addEventListener('resize', resize);
Expand All @@ -95,15 +112,23 @@ export function Chart() {
{error && <p role="alert" className="text-danger">{error}</p>}
<p role="status" aria-live="polite">{status}</p>
<div className="row g-3"><div className="col-md-9" ref={container} aria-busy={pending}>
{rendered ? <figure><img className="chart-image" src={rendered.url} alt={rendered.expression} />
{rendered?.kind === 'image' ? <figure><img className="chart-image" src={rendered.url} alt={rendered.expression} />
<figcaption>Rendered expression: <code>{rendered.expression}</code></figcaption></figure>
: rendered?.kind === 'surface' ? <SurfacePlot grid={rendered.grid} expression={rendered.expression} />
: <p>Enter an expression and choose Draw chart.</p>}
</div><div className="col-md-3">
<div className="d-flex flex-wrap gap-2 mb-3">
<button type="button" className="btn btn-primary btn-lg" disabled={!expression.trim() || pending} onClick={draw}>Draw chart!</button>
{pending && <button type="button" className="btn btn-outline-danger" onClick={cancel}>Cancel chart</button>}
{rendered && <a className="btn btn-outline-secondary" href={rendered.url} download="computator-chart.png">Download PNG</a>}
{rendered?.kind === 'image' && <a className="btn btn-outline-secondary" href={rendered.url} download="computator-chart.png">Download PNG</a>}
</div>
<label className="form-label" htmlFor="surface-grid-size">3D surface quality</label>
<select className="form-select mb-3" id="surface-grid-size" value={gridSize}
onChange={event => setGridSize(Number(event.target.value))}>
<option value={33}>Fast (33 × 33)</option>
<option value={65}>Balanced (65 × 65)</option>
<option value={97}>Detailed (97 × 97)</option>
</select>
<h2 className="h6">Chart area values:</h2><ul className="list-group">
<li className="list-group-item"><NumericInput label="X" subscriptLabel="MIN" value={xMin} onValueChanged={setXMin} /></li>
<li className="list-group-item"><NumericInput label="X" subscriptLabel="MAX" value={xMax} onValueChanged={setXMax} /></li>
Expand Down
Loading
Loading