From e63dfe1af56fb7346f6932e1302910875e8bd850 Mon Sep 17 00:00:00 2001 From: Mauricio Poppe Date: Sat, 16 Dec 2023 19:58:25 -0500 Subject: [PATCH] feat: add interval sampler unit tests --- site/index.html | 2 +- src/index.ts | 7 ++- src/samplers/builtIn.ts | 74 ++++++++++++------------ src/samplers/interval.test.js | 105 ++++++++++++++++++++++++++++++++++ src/samplers/interval.ts | 43 +++++++------- src/types.ts | 13 +++-- 6 files changed, 177 insertions(+), 67 deletions(-) create mode 100644 src/samplers/interval.test.js diff --git a/site/index.html b/site/index.html index 27de980..85007a3 100644 --- a/site/index.html +++ b/site/index.html @@ -120,7 +120,7 @@

- Check out the docs generated with TypeDocs API Docs + Check out the docs generated with TypeDocs API Docs

diff --git a/src/index.ts b/src/index.ts index 3bbefd7..32ecbc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { Chart, ChartMeta, ChartMetaMargin } from './chart' import globals, { registerGraphType } from './globals' import { polyline, interval, scatter, text } from './graph-types' -import * as $eval from './helpers/eval' +import { interval as intervalEval, builtIn as builtInEval } from './helpers/eval' // register common graphTypes on library load. registerGraphType('polyline', polyline) @@ -35,7 +35,10 @@ export default function functionPlot(options: FunctionPlotOptions) { } functionPlot.globals = globals -functionPlot.$eval = $eval +functionPlot.$eval = { + builtIn: builtInEval, + interval: intervalEval +} functionPlot.graphTypes = { interval, polyline, scatter } export * from './types' diff --git a/src/samplers/builtIn.ts b/src/samplers/builtIn.ts index 1b1a741..80c859c 100644 --- a/src/samplers/builtIn.ts +++ b/src/samplers/builtIn.ts @@ -10,9 +10,9 @@ type Asymptote = { d1: [number, number] } -type EvalResultSingle = [number, number] -type EvalResultGroup = Array -type EvalResult = Array +type SamplerResultSingle = [number, number] +type SamplerResultGroup = Array +type SamplerResult = Array function checkAsymptote( d0: [number, number], @@ -49,16 +49,14 @@ function checkAsymptote( /** * Splits the evaluated data into arrays, each array is separated by any asymptote found * through the process of detecting slope/sign brusque changes - * - * @returns {Array[]} */ -function split(d: FunctionPlotDatum, data: EvalResultGroup, yScale: FunctionPlotScale): EvalResult { +function split(d: FunctionPlotDatum, data: SamplerResultGroup, yScale: FunctionPlotScale): SamplerResult { let oldSign - const evalResult: EvalResult = [] + const samplerResult: SamplerResult = [] const yMin = yScale.domain()[0] - utils.infinity() const yMax = yScale.domain()[1] + utils.infinity() - let evalGroup: EvalResultGroup = [data[0]] + let samplerGroup: SamplerResultGroup = [data[0]] let i = 1 let deltaX = utils.infinity() @@ -70,7 +68,7 @@ function split(d: FunctionPlotDatum, data: EvalResultGroup, yScale: FunctionPlot // make a new set if: if ( // we have at least 2 entries (so that we can compute deltaY) - evalGroup.length >= 2 && + samplerGroup.length >= 2 && // utils.sgn(y1) * utils.sgn(y0) < 0 && // there's a change in the evaluated values sign // there's a change in the slope sign oldSign !== newSign && @@ -84,36 +82,36 @@ function split(d: FunctionPlotDatum, data: EvalResultGroup, yScale: FunctionPlot // we just need to update the yCoordinate data[i - 1][0] = check.d0[0] data[i - 1][1] = utils.clamp(check.d0[1], yMin, yMax) - evalResult.push(evalGroup) + samplerResult.push(samplerGroup) // data[i] has an updated [x,y], create a new group with it. data[i][0] = check.d1[0] data[i][1] = utils.clamp(check.d1[1], yMin, yMax) - evalGroup = [data[i]] + samplerGroup = [data[i]] } else { // false alarm, it's not an asymptote - evalGroup.push(data[i]) + samplerGroup.push(data[i]) } } else { - evalGroup.push(data[i]) + samplerGroup.push(data[i]) } // wait for at least 2 entries in the group before computing deltaX. - if (evalGroup.length > 1) { - deltaX = evalGroup[evalGroup.length - 1][0] - evalGroup[evalGroup.length - 2][0] + if (samplerGroup.length > 1) { + deltaX = samplerGroup[samplerGroup.length - 1][0] - samplerGroup[samplerGroup.length - 2][0] oldSign = newSign } ++i } - if (evalGroup.length) { - evalResult.push(evalGroup) + if (samplerGroup.length) { + samplerResult.push(samplerGroup) } - return evalResult + return samplerResult } -function linear(samplerParams: SamplerParams): EvalResult { +function linear(samplerParams: SamplerParams): SamplerResult { const allX = utils.space(samplerParams.xAxis, samplerParams.range, samplerParams.nSamples) const yDomain = samplerParams.yScale.domain() // const yDomainMargin = yDomain[1] - yDomain[0] @@ -132,12 +130,12 @@ function linear(samplerParams: SamplerParams): EvalResult { return splitData } -function parametric(samplerParams: SamplerParams): Array { +function parametric(samplerParams: SamplerParams): SamplerResult { // range is mapped to canvas coordinates from the input // for parametric plots the range will tell the start/end points of the `t` param const parametricRange = samplerParams.d.range || [0, 2 * Math.PI] const tCoords = utils.space(samplerParams.xAxis, parametricRange, samplerParams.nSamples) - const samples = [] + const samples: SamplerResultGroup = [] for (let i = 0; i < tCoords.length; i += 1) { const t = tCoords[i] const x = evaluate(samplerParams.d, 'x', { t }) @@ -147,12 +145,12 @@ function parametric(samplerParams: SamplerParams): Array { return [samples] } -function polar(samplerParams: SamplerParams): Array { +function polar(samplerParams: SamplerParams): SamplerResult { // range is mapped to canvas coordinates from the input // for polar plots the range will tell the start/end points of the `theta` param const polarRange = samplerParams.d.range || [-Math.PI, Math.PI] const thetaSamples = utils.space(samplerParams.xAxis, polarRange, samplerParams.nSamples) - const samples = [] + const samples: SamplerResultGroup = [] for (let i = 0; i < thetaSamples.length; i += 1) { const theta = thetaSamples[i] const r = evaluate(samplerParams.d, 'r', { theta }) @@ -163,29 +161,31 @@ function polar(samplerParams: SamplerParams): Array { return [samples] } -function points(samplerParams: SamplerParams): Array { +function points(samplerParams: SamplerParams): SamplerResult { return [samplerParams.d.points] } -function vector(sampleParams: SamplerParams): Array { +function vector(sampleParams: SamplerParams): SamplerResult { const d = sampleParams.d d.offset = d.offset || [0, 0] return [[d.offset, [d.vector[0] + d.offset[0], d.vector[1] + d.offset[1]]]] } -const sampler: SamplerFn = function sampler(samplerParams: SamplerParams): Array { - const fnTypes = { - parametric, - polar, - points, - vector, - linear +const sampler: SamplerFn = function sampler(samplerParams: SamplerParams): SamplerResult { + switch (samplerParams.d.fnType) { + case 'linear': + return linear(samplerParams) + case 'parametric': + return parametric(samplerParams) + case 'polar': + return polar(samplerParams) + case 'vector': + return vector(samplerParams) + case 'points': + return points(samplerParams) + default: + throw Error(samplerParams.d.fnType + ' is not supported in the `builtIn` sampler') } - if (!(samplerParams.d.fnType in fnTypes)) { - throw Error(samplerParams.d.fnType + ' is not supported in the `builtIn` sampler') - } - // @ts-ignore - return fnTypes[samplerParams.d.fnType].apply(null, arguments) } export default sampler diff --git a/src/samplers/interval.test.js b/src/samplers/interval.test.js new file mode 100644 index 0000000..56836eb --- /dev/null +++ b/src/samplers/interval.test.js @@ -0,0 +1,105 @@ +import { scaleLinear as d3ScaleLinear } from 'd3-scale' +import { expect } from '@jest/globals' +import { Interval } from 'interval-arithmetic-eval' + +import interval from './interval' + +const width = 200 +const height = 100 +const xDomain = [-5, 5] +const yDomain = [-5, 5] +const xScale = d3ScaleLinear().domain(xDomain).range([0, width]) +const yScale = d3ScaleLinear().domain(yDomain).range([height, 0]) + +function toBeCloseToInterval(got, want, eps = 1e-3) { + if (!Interval.isInterval(got) || !Interval.isInterval(want)) { + throw new Error('got and want must be Intervals') + } + if (Math.abs(got.lo - want.lo) > eps || Math.abs(got.hi - want.hi) > eps) { + return { + message: () => + `expected ${this.utils.printReceived(got)} to be within range of ${this.utils.printReceived(want)}`, + pass: false + } + } + return { pass: true } +} + +expect.extend({ toBeCloseToInterval }) + +describe('interval sampler', () => { + describe('with linear sampler', () => { + it('should render 2 points for x', () => { + const nSamples = 2 + // map the screen coordinates [0, width] to the domain [-5, 5] + const samplerParams = { + d: { fn: '1000000*x', fnType: 'linear' }, + range: xDomain, + xScale, + yScale, + xAxis: { type: 'linear' }, + nSamples + } + const data = interval(samplerParams) + expect(data instanceof Array).toEqual(true) + expect(data.length).toEqual(1) /* we expect 1 group */ + expect(data[0].length).toEqual(1) /* the group should have 1 single result */ + expect(data[0][0].length).toEqual(2) /* the result should be an array of 2 intervals */ + // the the group should be evaluated at 2 points, however with intervals + // we only need 2 pairs expressed in a single datum + // + // [x, y] where x = [x_lo, x_hi] and y = [y_lo, y_hi] + // + // The graphical way is to see the bounded rectangle + // .......... y_hi + // . . + // . . + // . . + // .......... y_lo + // x_lo x_hi + expect(data[0][0][0]).toBeCloseToInterval({ lo: -5, hi: 5 }) /* the x_lo, x_hi tuple */ + expect(data[0][0][1]).toBeCloseToInterval({ lo: -5000000, hi: 5000000 }) /* the y_lo, y_hi tuple */ + }) + + it('should render 100000 points for x^2', () => { + const nSamples = 100001 + // map the screen coordinates [0, width] to the domain [-5, 5] + const samplerParams = { + d: { fn: 'x^2', fnType: 'linear' }, + range: xDomain, + xScale, + yScale, + xAxis: { type: 'linear' }, + nSamples + } + const data = interval(samplerParams) + expect(data instanceof Array).toEqual(true) + expect(data.length).toEqual(1) /* we expect 1 group */ + expect(data[0].length).toEqual(100000) /* the group should have nSamples - 1 single result */ + expect(data[0][0][0]).toBeCloseToInterval({ lo: -5, hi: -5 }) + expect(data[0][0][1]).toBeCloseToInterval({ lo: 25, hi: 25 }) + expect(data[0][50000][0]).toBeCloseToInterval({ lo: 0, hi: 0 }) + expect(data[0][50000][1]).toBeCloseToInterval({ lo: 0, hi: 0 }) + expect(data[0][99999][0]).toBeCloseToInterval({ lo: 5, hi: 5 }) + expect(data[0][99999][1]).toBeCloseToInterval({ lo: 25, hi: 25 }) + }) + + it('should render 1 group for 1/x', () => { + const nSamples = 4 + // map the screen coordinates [0, width] to the domain [-5, 5] + const samplerParams = { + d: { fn: '1/x', fnType: 'linear' }, + range: xDomain, + xScale, + yScale, + xAxis: { type: 'linear' }, + nSamples + } + const data = interval(samplerParams) + expect(data instanceof Array).toEqual(true) + expect(data.length).toEqual(1) /* we expect 1 group */ + expect(data[0].length).toEqual(3) /* the group should have nSamples - 1 single result */ + expect(data[0][1]).toEqual(null) /* the interval containing 1/0 should not be rendered */ + }) + }) +}) diff --git a/src/samplers/interval.ts b/src/samplers/interval.ts index 58a40ae..a1c3826 100644 --- a/src/samplers/interval.ts +++ b/src/samplers/interval.ts @@ -9,17 +9,19 @@ import { SamplerParams, SamplerFn } from './types' // disable the use of typed arrays in interval-arithmetic to improve the performance ;(intervalArithmeticEval as any).policies.disableRounding() -function interval1d(samplerParams: SamplerParams): Array { - const xCoords = utils.space(samplerParams.xAxis, samplerParams.range, samplerParams.nSamples) - const xScale = samplerParams.xScale - const yScale = samplerParams.yScale - const yMin = yScale.domain()[0] - const yMax = yScale.domain()[1] - const samples = [] +type SamplerResultSingle = [Interval, Interval] +type SamplerResultGroup = Array | null +type SamplerResult = Array + +function interval1d({ d, xAxis, range, nSamples, xScale, yScale }: SamplerParams): SamplerResult { + const xCoords = utils.space(xAxis, range, nSamples) + const yMin = yScale.domain()[0] - utils.infinity() + const yMax = yScale.domain()[1] + utils.infinity() + const samples: SamplerResultGroup = [] let i for (i = 0; i < xCoords.length - 1; i += 1) { const x = { lo: xCoords[i], hi: xCoords[i + 1] } - const y = evaluate(samplerParams.d, 'fn', { x }) + const y = evaluate(d, 'fn', { x }) if (!Interval.isEmpty(y) && !Interval.isWhole(y)) { samples.push([x, y]) } @@ -66,7 +68,7 @@ function interval1d(samplerParams: SamplerParams): Array { } let rectEps: number -function smallRect(x: Interval, y: Interval) { +function smallRect(x: Interval, _: Interval) { return Interval.width(x) < rectEps } @@ -93,30 +95,29 @@ function quadTree(x: Interval, y: Interval, d: FunctionPlotDatum) { quadTree.call(this, west, south, d) } -function interval2d(samplerParams: SamplerParams): Array { +function interval2d(samplerParams: SamplerParams): SamplerResult { const xScale = samplerParams.xScale const xDomain = samplerParams.xScale.domain() const yDomain = samplerParams.yScale.domain() const x = { lo: xDomain[0], hi: xDomain[1] } const y = { lo: yDomain[0], hi: yDomain[1] } - const samples: any = [] + const samples: SamplerResultGroup = [] // 1 px rectEps = xScale.invert(1) - xScale.invert(0) quadTree.call(samples, x, y, samplerParams.d) - samples.scaledDx = 1 + ;(samples as any).scaledDx = 1 return [samples] } -const sampler: SamplerFn = function sampler(samplerParams: SamplerParams): Array { - const fnTypes = { - implicit: interval2d, - linear: interval1d - } - if (!Object.hasOwn(fnTypes, samplerParams.d.fnType)) { - throw Error(samplerParams.d.fnType + ' is not supported in the `interval` sampler') +const sampler: SamplerFn = function sampler(samplerParams: SamplerParams): SamplerResult { + switch (samplerParams.d.fnType) { + case 'linear': + return interval1d(samplerParams) + case 'implicit': + return interval2d(samplerParams) + default: + throw new Error(samplerParams.d.fnType + ' is not supported in the `interval` sampler') } - // @ts-ignore - return fnTypes[samplerParams.d.fnType].apply(null, arguments) } export default sampler diff --git a/src/types.ts b/src/types.ts index 1a0921a..81ac5c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,16 +145,17 @@ export interface FunctionPlotDatum { /** * The type of graph to render * - * - polyline: uses the builtIn sampler to render a disjoint set of lines + * - polyline: uses the builtIn sampler to render a disjoint set of line segments * - interval: uses the interval arithmetic sampler to render a disjoint set of rectangles - * - scatter: dotted line + * - scatter: uses the builtIn sampler to render a disjoint set of points + * - text: text */ - graphType?: 'polyline' | 'interval' | 'scatter' + graphType?: 'polyline' | 'interval' | 'scatter' | 'text' /** * The type of function to render */ - fnType?: 'linear' | 'parametric' | 'implicit' | 'polar' | 'points' | 'vector' | string + fnType?: 'linear' | 'parametric' | 'implicit' | 'polar' | 'points' | 'vector' /** * The sampler to take samples from `range`, available values are `interval|builtIn` @@ -219,7 +220,7 @@ export interface FunctionPlotDatum { /** * An array of 2-number array which hold the coordinates of the points to render when `fnType: 'points'` */ - points?: number[][] + points?: Array<[number, number]> /** * An array of 2-number array which hold the coordinates of the points to render when `fnType: 'vector'` @@ -229,7 +230,7 @@ export interface FunctionPlotDatum { /** * Vector offset when `fnType: 'vector'` */ - offset?: number[] + offset?: [number, number] /** * An array of 2-number array for the position of the text when `fnType: 'text'`