diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index ed89788..472cdbc 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -13,7 +13,7 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-node@v3.1.1 + - uses: actions/setup-node@v4 - name: Install dependencies run: npm install @@ -29,7 +29,7 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-node@v3.1.1 + - uses: actions/setup-node@v4 - name: Install dependencies run: npm install @@ -44,21 +44,19 @@ jobs: path: | test/e2e/**/*.png - - license: + perf: runs-on: ubuntu-latest - env: - FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} - steps: - uses: actions/checkout@v2 with: fetch-depth: 0 - - name: Run Fossa and upload data - if: env.FOSSA_API_KEY != '' - uses: fossa-contrib/fossa-action@v1 - with: - fossa-api-key: ${{ env.FOSSA_API_KEY }} + - uses: actions/setup-node@v4 + + - name: Install dependencies + run: npm install + + - name: Perf + run: npm run perf:pipeline diff --git a/design/pipeline.md b/design/pipeline.md new file mode 100644 index 0000000..b7673f9 --- /dev/null +++ b/design/pipeline.md @@ -0,0 +1,34 @@ +# Compile-Eval-Render Pipeline for Intervals + +> Summary of the render process that happens on every tick (pan/zoom) in the graph. + +As of v1.24.0 the render pipeline consists of: + +- Compile + Eval + - compile `fn` to a *-eval function (see [interval-arithmetic-eval](https://github.com/mauriciopoppe/interval-arithmetic-eval) + or [built-in-math-eval](https://github.com/mauriciopoppe/built-in-math-eval)) + - The [above is cached]() so that when `functionPlot` is invoked it isn't + the compilation isn't run many times. + - [use `nSamples`](https://github.com/mauriciopoppe/function-plot/blob/b46e07c3281bce5b6bff00050ba3d6a16795a483/src/evaluate.ts#L40) + to create [`nSamples` equally distinct points](https://github.com/mauriciopoppe/function-plot/blob/b46e07c3281bce5b6bff00050ba3d6a16795a483/src/samplers/interval.ts#L17), + e.g. `[x_0, x_1, ..., x_{n_samples - 1}]` + - In `O(n)`, iterate over all the points and evaluate each them against the compiled function evaluator (created in the preparation stage), + return a data structure that encodes the result (`Array | null>`) +- Render + - In `O(n)` iterate over all the results and create a [``](https://github.com/mauriciopoppe/function-plot/blob/b46e07c3281bce5b6bff00050ba3d6a16795a483/src/graph-types/interval.ts#L96) + where `rectanglePaint` is a [series of commands](https://github.com/mauriciopoppe/function-plot/blob/b46e07c3281bce5b6bff00050ba3d6a16795a483/src/graph-types/interval.ts#L68) + of the form `M v ` which move to `(x, y)` and paint a rectangle of width `width`. + +## Perf stats + +Using `npm run perf:pipeline`: + +``` +compile and eval 1000 x 1,163 ops/sec ±0.26% (98 runs sampled) +compile and eval 1000 x 1,172 ops/sec ±0.46% (97 runs sampled) +compile and eval 1000 x 1,164 ops/sec ±0.23% (96 runs sampled) + +drawPath 1000 x 8,398 ops/sec ±0.58% (99 runs sampled) +drawPath 1000 x 8,392 ops/sec ±0.53% (97 runs sampled) +drawPath 1000 x 8,266 ops/sec ±0.55% (96 runs sampled) +``` diff --git a/src/graph-types/interval.ts b/src/graph-types/interval.ts index 4e40880..4873a07 100644 --- a/src/graph-types/interval.ts +++ b/src/graph-types/interval.ts @@ -4,73 +4,79 @@ import evaluate from '../evaluate' import utils from '../utils' import { Chart } from '../index' -import { Interval, FunctionPlotDatum } from '../types' +import { Interval, FunctionPlotDatum, FunctionPlotScale } from '../types' -export default function interval(chart: Chart) { - let minWidthHeight: number - const xScale = chart.meta.xScale - const yScale = chart.meta.yScale - - function clampRange(vLo: number, vHi: number, gLo: number, gHi: number) { - // issue 69 - // by adding the option `invert` to both the xAxis and the `yAxis` - // it might be possible that after the transformation to canvas space - // the y limits of the rectangle get inverted i.e. gLo > gHi - // - // e.g. - // - // functionPlot({ - // target: '#playground', - // yAxis: { invert: true }, - // // ... - // }) - // - if (gLo > gHi) { - const t = gLo - gLo = gHi - gHi = t - } - const hi = Math.min(vHi, gHi) - const lo = Math.max(vLo, gLo) - if (lo > hi) { - // no overlap - return [-minWidthHeight, 0] - } - return [lo, hi] +function clampRange(minWidthHeight: number, vLo: number, vHi: number, gLo: number, gHi: number) { + // issue 69 + // by adding the option `invert` to both the xAxis and the `yAxis` + // it might be possible that after the transformation to canvas space + // the y limits of the rectangle get inverted i.e. gLo > gHi + // + // e.g. + // + // functionPlot({ + // target: '#playground', + // yAxis: { invert: true }, + // // ... + // }) + // + if (gLo > gHi) { + const t = gLo + gLo = gHi + gHi = t + } + const hi = Math.min(vHi, gHi) + const lo = Math.max(vLo, gLo) + if (lo > hi) { + // no overlap + return [-minWidthHeight, 0] } + return [lo, hi] +} - const line = function (points: Interval[][], closed: boolean) { - let path = '' - const range = yScale.range() - const minY = Math.min.apply(Math, range) - const maxY = Math.max.apply(Math, range) - for (let i = 0, length = points.length; i < length; i += 1) { - if (points[i]) { - const x = points[i][0] - const y = points[i][1] - let yLo = y.lo - let yHi = y.hi - // if options.closed is set to true then one of the bounds must be zero - if (closed) { - yLo = Math.min(yLo, 0) - yHi = Math.max(yHi, 0) - } - // points.scaledDX is added because of the stroke-width - const moveX = xScale(x.lo) + (points as any).scaledDx / 2 - const viewportY = clampRange( - minY, - maxY, - isFinite(yHi) ? yScale(yHi) : -Infinity, - isFinite(yLo) ? yScale(yLo) : Infinity - ) - const vLo = viewportY[0] - const vHi = viewportY[1] - path += ' M ' + moveX + ' ' + vLo - path += ' v ' + Math.max(vHi - vLo, minWidthHeight) +export function createPathD( + xScale: FunctionPlotScale, + yScale: FunctionPlotScale, + minWidthHeight: number, + points: Array<[Interval, Interval]>, + closed: boolean +) { + let path = '' + const range = yScale.range() + const minY = Math.min.apply(Math, range) + const maxY = Math.max.apply(Math, range) + for (let i = 0, length = points.length; i < length; i += 1) { + if (points[i]) { + const x = points[i][0] + const y = points[i][1] + let yLo = y.lo + let yHi = y.hi + // if options.closed is set to true then one of the bounds must be zero + if (closed) { + yLo = Math.min(yLo, 0) + yHi = Math.max(yHi, 0) } + // points.scaledDX is added because of the stroke-width + const moveX = xScale(x.lo) + (points as any).scaledDx / 2 + const viewportY = clampRange( + minWidthHeight, + minY, + maxY, + isFinite(yHi) ? yScale(yHi) : -utils.infinity(), + isFinite(yLo) ? yScale(yLo) : utils.infinity() + ) + const vLo = viewportY[0] + const vHi = viewportY[1] + path += ' M ' + moveX + ' ' + vLo + path += ' v ' + Math.max(vHi - vLo, minWidthHeight) } - return path } + return path +} + +export default function interval(chart: Chart) { + const xScale = chart.meta.xScale + const yScale = chart.meta.yScale function plotLine(selection: Selection) { selection.each(function (d) { @@ -81,7 +87,7 @@ export default function interval(chart: Chart) { const innerSelection = el.selectAll(':scope > path.line').data(evaluatedData) // the min height/width of the rects drawn by the path generator - minWidthHeight = Math.max(evaluatedData[0].scaledDx, 1) + const minWidthHeight = Math.max(evaluatedData[0].scaledDx, 1) const cls = `line line-${index}` const innerSelectionEnter = innerSelection.enter().append('path').attr('class', cls).attr('fill', 'none') @@ -92,8 +98,8 @@ export default function interval(chart: Chart) { .attr('stroke-width', minWidthHeight) .attr('stroke', utils.color(d, index) as any) .attr('opacity', closed ? 0.5 : 1) - .attr('d', function (d: Interval[][]) { - return line(d, closed) + .attr('d', function (d: Array<[Interval, Interval]>) { + return createPathD(xScale, yScale, minWidthHeight, d, closed) }) if (d.attr) { diff --git a/src/perf/interval-pipeline.ts b/src/perf/interval-pipeline.ts new file mode 100644 index 0000000..38196b8 --- /dev/null +++ b/src/perf/interval-pipeline.ts @@ -0,0 +1,80 @@ +/** + * interval-pipeline evaluates the performance of the compile, eval, render pipeline, + * the design is at /design/pipeline.md + */ + +// @ts-ignore +import Benchmark from 'benchmark' +import { scaleLinear } from 'd3-scale' + +import { FunctionPlotDatum, FunctionPlotOptionsAxis } from '../types' +import { createPathD } from '../graph-types/interval' +import interval from '../samplers/interval' + +function createData(nSamples: number) { + const width = 500 + const height = 300 + const xDomain: [number, number] = [-5, 5] + const yDomain: [number, number] = [-5, 5] + const xScale = scaleLinear().domain(xDomain).range([0, width]) + const yScale = scaleLinear().domain(yDomain).range([height, 0]) + const d: FunctionPlotDatum = { + fn: '1/x', + fnType: 'linear' + } + const xAxis: FunctionPlotOptionsAxis = { type: 'linear' } + const yAxis: FunctionPlotOptionsAxis = { type: 'linear' } + const samplerParams = { + d, + range: xDomain, + xScale, + yScale, + xAxis, + yAxis, + nSamples + } + const data = interval(samplerParams) + return { data, xScale, yScale } +} + +function compileAndEval() { + const compileAndEval = new Benchmark.Suite() + const nSamples = 1000 + compileAndEval + .add(`compile and eval ${nSamples}`, function () { + createData(nSamples) + }) + // add listeners + .on('cycle', function (event) { + console.log(String(event.target)) + }) + .on('complete', function () { + console.log('Fastest is ' + this.filter('fastest').map('name')) + }) + .run({ async: false }) +} + +function drawPath() { + const compileAndEval = new Benchmark.Suite() + const nSamples = 1000 + const { xScale, yScale, data } = createData(nSamples) + compileAndEval + .add(`drawPath ${nSamples}`, function () { + createPathD(xScale, yScale, 1 /* minWidthHeight, dummy = 1 */, data[0], false /* closed */) + }) + // add listeners + .on('cycle', function (event) { + console.log(String(event.target)) + }) + .on('complete', function () { + console.log('Fastest is ' + this.filter('fastest').map('name')) + }) + .run({ async: false }) +} + +function main() { + compileAndEval() + drawPath() +} + +main()