Skip to content

Commit

Permalink
feat: add interval sampler unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mauriciopoppe committed Dec 17, 2023
1 parent 5013eec commit e63dfe1
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 67 deletions.
2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ <h1>
</code></pre>

<p>
Check out the docs generated with TypeDocs <a href="./docs/interfaces/_src_types_.functionplotoptions.html">API Docs</a>
Check out the docs generated with TypeDocs <a href="./docs/functions/default-1.html">API Docs</a>
</p>


Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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'
Expand Down
74 changes: 37 additions & 37 deletions src/samplers/builtIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ type Asymptote = {
d1: [number, number]
}

type EvalResultSingle = [number, number]
type EvalResultGroup = Array<EvalResultSingle>
type EvalResult = Array<EvalResultGroup>
type SamplerResultSingle = [number, number]
type SamplerResultGroup = Array<SamplerResultSingle>
type SamplerResult = Array<SamplerResultGroup>

function checkAsymptote(
d0: [number, number],
Expand Down Expand Up @@ -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()
Expand All @@ -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 &&
Expand All @@ -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]
Expand All @@ -132,12 +130,12 @@ function linear(samplerParams: SamplerParams): EvalResult {
return splitData
}

function parametric(samplerParams: SamplerParams): Array<any> {
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 })
Expand All @@ -147,12 +145,12 @@ function parametric(samplerParams: SamplerParams): Array<any> {
return [samples]
}

function polar(samplerParams: SamplerParams): Array<any> {
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 })
Expand All @@ -163,29 +161,31 @@ function polar(samplerParams: SamplerParams): Array<any> {
return [samples]
}

function points(samplerParams: SamplerParams): Array<any> {
function points(samplerParams: SamplerParams): SamplerResult {
return [samplerParams.d.points]
}

function vector(sampleParams: SamplerParams): Array<any> {
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<any> {
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
105 changes: 105 additions & 0 deletions src/samplers/interval.test.js
Original file line number Diff line number Diff line change
@@ -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 */
})
})
})
43 changes: 22 additions & 21 deletions src/samplers/interval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
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<SamplerResultSingle> | null
type SamplerResult = Array<SamplerResultGroup>

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])
}
Expand Down Expand Up @@ -66,7 +68,7 @@ function interval1d(samplerParams: SamplerParams): Array<any> {
}

let rectEps: number
function smallRect(x: Interval, y: Interval) {
function smallRect(x: Interval, _: Interval) {
return Interval.width(x) < rectEps
}

Expand All @@ -93,30 +95,29 @@ function quadTree(x: Interval, y: Interval, d: FunctionPlotDatum) {
quadTree.call(this, west, south, d)
}

function interval2d(samplerParams: SamplerParams): Array<any> {
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<any> {
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
Loading

0 comments on commit e63dfe1

Please sign in to comment.