diff --git a/site/js/site.js b/site/js/site.js index e942a7b5..c65ea3f4 100644 --- a/site/js/site.js +++ b/site/js/site.js @@ -810,6 +810,30 @@ $(document).on('markupLoaded', function () { ] }) + /** + * ### Polar coordinate grid + * + * Polar equations use Cartesian axes by default. Set `coordinateSystem: 'polar'` + * to draw a polar grid with equal x/y units instead. `polar.grid` defaults to + * `true`, while angular labels are opt-in with `polar.angularLabels: true`. + * Labels use radians by default; `angleUnit: 'degrees'` changes their format. + */ + functionPlot({ + target: '#polar-grid', + coordinateSystem: 'polar', + polar: { + radiusDomain: [0, 3], + angularLabels: true + }, + data: [ + { + r: '2 * sin(4 * theta)', + fnType: 'polar', + graphType: 'polyline' + } + ] + }) + /** * ### Implicit functions * diff --git a/src/chart.test.ts b/src/chart.test.ts new file mode 100644 index 00000000..47939a9e --- /dev/null +++ b/src/chart.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from '@jest/globals' + +import { Chart } from './chart' + +describe('chart dimensions', () => { + function createChart(coordinateSystem: 'cartesian' | 'polar', title?: string) { + const chart = Object.create(Chart.prototype) as Chart + chart.options = { + target: '#playground', + width: 550, + height: 350, + coordinateSystem, + title, + data: [] + } + chart.meta = {} + return chart + } + + it('uses the full SVG viewport for the polar canvas and clip', () => { + const chart = createChart('polar', 'Polar plot') + chart.internalVars() + expect(chart.meta.margin).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }) + expect(chart.meta.width).toBe(550) + expect(chart.meta.height).toBe(350) + }) + + it('retains axis and title margins for the Cartesian canvas and clip', () => { + const chart = createChart('cartesian') + chart.internalVars() + expect(chart.meta.margin).toEqual({ left: 40, right: 20, top: 20, bottom: 20 }) + expect(chart.meta.width).toBe(490) + expect(chart.meta.height).toBe(310) + + chart.options.title = 'Cartesian plot' + chart.internalVars() + expect(chart.meta.margin.top).toBe(40) + expect(chart.meta.height).toBe(290) + }) +}) diff --git a/src/chart.ts b/src/chart.ts index fa38f58c..b48ccea0 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -2,7 +2,7 @@ import { line as d3Line, Line } from 'd3-shape' import { format as d3Format } from 'd3-format' import { scaleLinear as d3ScaleLinear, scaleLog as d3ScaleLog } from 'd3-scale' import { axisLeft as d3AxisLeft, axisBottom as d3AxisBottom, Axis } from 'd3-axis' -import { zoom as d3Zoom } from 'd3-zoom' +import { zoom as d3Zoom, zoomIdentity as d3ZoomIdentity } from 'd3-zoom' // @ts-ignore import { select as d3Select, pointer as d3Pointer } from 'd3-selection' import { interpolateRound as d3InterpolateRound } from 'd3-interpolate' @@ -15,6 +15,7 @@ import mousetip from './tip' import helpers from './helpers' import datumDefaults from './datum-defaults' import globals from './globals' +import { getPolarDomains, renderPolarGrid, validatePolarOptions } from './polar-grid' export interface ChartMetaMargin { left?: number @@ -52,6 +53,64 @@ function getD3Scale(type: 'linear' | 'log') { return d3ScaleLog } +/** Compute the initial domains and preserve equal units per pixel for polar plots. */ +function getDomains( + options: FunctionPlotOptions, + width: number, + height: number +): { + xDomain: number[] + yDomain: number[] +} { + const computeYDomainSize = (xDomain: number[]) => { + const xSize = xDomain[1] - xDomain[0] + return (height * xSize) / width + } + + const radius = options.coordinateSystem === 'polar' ? options.polar?.radiusDomain?.[1] : undefined + const radiusLimit = radius === undefined ? undefined : radius * 1.2 + + const xDomain = (() => { + if (options.xAxis.domain) { + return options.xAxis.domain + } + + if (options.xAxis.type === 'log') { + return [1, 10] + } + + if (options.xAxis.type === 'linear') { + return radiusLimit === undefined ? [-6, 6] : [-radiusLimit, radiusLimit] + } + + throw new Error(`axis type ${options.xAxis.type} unsupported`) + })() + + const yDomain = (() => { + if (options.yAxis.domain) { + return options.yAxis.domain + } + + if (options.yAxis.type === 'log') { + return [1, 10] + } + + if (options.yAxis.type === 'linear') { + if (radiusLimit !== undefined) return [-radiusLimit, radiusLimit] + const ySize = computeYDomainSize(xDomain) + return [-ySize / 2, ySize / 2] + } + + throw new Error(`axis type ${options.yAxis.type} unsupported`) + })() + + if (options.coordinateSystem === 'polar') { + return getPolarDomains(xDomain, yDomain, width, height) + } + + return { xDomain, yDomain } +} + /** * An instance can subscribe to any of the following events by doing `instance.on([eventName], callback)`, * events can be triggered by doing `instance.emit([eventName][, params])` @@ -172,9 +231,11 @@ export class Chart extends EventEmitter.EventEmitter { } internalVars() { - const margin = (this.meta.margin = { left: 40, right: 20, top: 20, bottom: 20 }) + const margin = (this.meta.margin = this.isPolarCoordinateSystem() + ? { left: 0, right: 0, top: 0, bottom: 0 } + : { left: 40, right: 20, top: 20, bottom: 20 }) // if there's a title make the top margin bigger - if (this.options.title) { + if (this.options.title && !this.isPolarCoordinateSystem()) { this.meta.margin.top = 40 } // inner width/height @@ -198,43 +259,24 @@ export class Chart extends EventEmitter.EventEmitter { } } - function computeYScale(xScale: number[]) { - // assumes that xScale is a linear scale - const xDiff = xScale[1] - xScale[0] - return (self.meta.height * xDiff) / self.meta.width - } - + this.options.coordinateSystem = this.options.coordinateSystem || 'cartesian' this.options.xAxis = this.options.xAxis || {} this.options.xAxis.type = this.options.xAxis.type || 'linear' this.options.yAxis = this.options.yAxis || {} this.options.yAxis.type = this.options.yAxis.type || 'linear' - const xDomain = (this.meta.xDomain = (function (axis) { - if (axis.domain) { - return axis.domain - } - if (axis.type === 'linear') { - const xLimit = 12 - return [-xLimit / 2, xLimit / 2] - } else if (axis.type === 'log') { - return [1, 10] - } - throw Error('axis type ' + axis.type + ' unsupported') - })(this.options.xAxis)) + if (this.options.coordinateSystem !== 'cartesian' && this.options.coordinateSystem !== 'polar') { + throw new Error(`unsupported coordinate system ${this.options.coordinateSystem}`) + } - const yDomain = (this.meta.yDomain = (function (axis) { - if (axis.domain) { - return axis.domain - } - const yLimit = computeYScale(xDomain) - if (axis.type === 'linear') { - return [-yLimit / 2, yLimit / 2] - } else if (axis.type === 'log') { - return [1, 10] - } - throw Error('axis type ' + axis.type + ' unsupported') - })(this.options.yAxis)) + if (this.options.coordinateSystem === 'polar') { + validatePolarOptions(this.options) + } + + const { xDomain, yDomain } = getDomains(this.options, this.meta.width, this.meta.height) + this.meta.xDomain = xDomain + this.meta.yDomain = yDomain if (!this.meta.xScale) { this.meta.xScale = getD3Scale(this.options.xAxis.type)() @@ -255,11 +297,17 @@ export class Chart extends EventEmitter.EventEmitter { if (!this.meta.xAxis) { this.meta.xAxis = d3AxisBottom(this.meta.xScale) } - this.meta.xAxis.tickSize(this.options.grid ? -this.meta.height : 0).tickFormat(formatter) + this.meta.xAxis + .scale(this.meta.xScale) + .tickSize(this.options.grid ? -this.meta.height : 0) + .tickFormat(formatter) if (!this.meta.yAxis) { this.meta.yAxis = d3AxisLeft(this.meta.yScale) } - this.meta.yAxis.tickSize(this.options.grid ? -this.meta.width : 0).tickFormat(formatter) + this.meta.yAxis + .scale(this.meta.yScale) + .tickSize(this.options.grid ? -this.meta.width : 0) + .tickFormat(formatter) this.line = d3Line() .x(function (d) { @@ -319,10 +367,11 @@ export class Chart extends EventEmitter.EventEmitter { // enter const selectionEnter = selection.enter().append('text') + // Polar plots have no top margin, so the title sits inside the SVG viewport. selectionEnter .merge(selection) .attr('class', 'title') - .attr('y', this.meta.margin.top / 2) + .attr('y', this.isPolarCoordinateSystem() ? 20 : this.meta.margin.top / 2) .attr('x', this.meta.margin.left + this.meta.width / 2) .attr('font-size', 25) .attr('text-anchor', 'middle') @@ -337,12 +386,13 @@ export class Chart extends EventEmitter.EventEmitter { // enter this.root.enter.append('text').attr('class', 'top-right-legend').attr('text-anchor', 'end') - // update + enter + // Polar legends use an inset and hanging baseline to stay inside the zero-margin viewport. this.root .merge(this.root.enter) .select('.top-right-legend') - .attr('y', this.meta.margin.top / 2) - .attr('x', this.meta.width + this.meta.margin.left) + .attr('y', this.isPolarCoordinateSystem() ? 8 : this.meta.margin.top / 2) + .attr('x', this.meta.width + this.meta.margin.left - (this.isPolarCoordinateSystem() ? 8 : 0)) + .attr('dominant-baseline', this.isPolarCoordinateSystem() ? 'hanging' : null) } buildCanvas() { @@ -404,20 +454,26 @@ export class Chart extends EventEmitter.EventEmitter { .merge(this.canvas.enter) .select('.x.axis') .attr('transform', 'translate(0,' + this.meta.height + ')') + .style('display', this.isPolarCoordinateSystem() ? 'none' : null) .call(this.meta.xAxis) - this.canvas.merge(this.canvas.enter).select('.y.axis').call(this.meta.yAxis) + this.canvas + .merge(this.canvas.enter) + .select('.y.axis') + .style('display', this.isPolarCoordinateSystem() ? 'none' : null) + .call(this.meta.yAxis) } buildAxisLabel() { // axis labeling const canvas = this.canvas + const self = this const xLabel = canvas .merge(canvas.enter) .selectAll('text.x.axis-label') .data(function (d: FunctionPlotOptions) { - return [d.xAxis.label].filter(Boolean) + return self.isPolarCoordinateSystem() ? [] : [d.xAxis.label].filter(Boolean) }) // prettier-ignore const xLabelEnter = xLabel.enter().append('text') @@ -438,7 +494,7 @@ export class Chart extends EventEmitter.EventEmitter { .merge(canvas.enter) .selectAll('text.y.axis-label') .data(function (d: FunctionPlotOptions) { - return [d.yAxis.label].filter(Boolean) + return self.isPolarCoordinateSystem() ? [] : [d.yAxis.label].filter(Boolean) }) const yLabelEnter = yLabel @@ -490,12 +546,16 @@ export class Chart extends EventEmitter.EventEmitter { const yOrigin = content .merge(contentEnter) .selectAll(':scope > path.y.origin') - .data([ - [ - [0, this.meta.yScale.domain()[0]], - [0, this.meta.yScale.domain()[1]] - ] - ]) + .data( + this.isPolarCoordinateSystem() + ? [] + : [ + [ + [0, this.meta.yScale.domain()[0]], + [0, this.meta.yScale.domain()[1]] + ] + ] + ) const yOriginEnter = yOrigin .enter() .append('path') @@ -503,6 +563,7 @@ export class Chart extends EventEmitter.EventEmitter { .attr('stroke', 'black') .attr('opacity', 0.2) yOrigin.merge(yOriginEnter).attr('d', this.line) + yOrigin.exit().remove() } // helper line y = 0 @@ -510,12 +571,16 @@ export class Chart extends EventEmitter.EventEmitter { const xOrigin = content .merge(contentEnter) .selectAll(':scope > path.x.origin') - .data([ - [ - [this.meta.xScale.domain()[0], 0], - [this.meta.xScale.domain()[1], 0] - ] - ]) + .data( + this.isPolarCoordinateSystem() + ? [] + : [ + [ + [this.meta.xScale.domain()[0], 0], + [this.meta.xScale.domain()[1], 0] + ] + ] + ) const xOriginEnter = xOrigin .enter() .append('path') @@ -523,6 +588,7 @@ export class Chart extends EventEmitter.EventEmitter { .attr('stroke', 'black') .attr('opacity', 0.2) xOrigin.merge(xOriginEnter).attr('d', this.line) + xOrigin.exit().remove() } // annotations @@ -569,20 +635,23 @@ export class Chart extends EventEmitter.EventEmitter { this.meta.zoomBehavior = d3Zoom().on('zoom', function onZoom(ev) { self.getEmitInstance().emit('all:zoom', ev) }) - // the zoom behavior must work with a copy of the scale, the zoom behavior has its own state and assumes - // that its updating the original scale! - // things that failed when I tried rescaleX(self.meta.xScale), the state of self.meta.xScale was a multiplied - // for zoom/mousemove operations - // - // this copy should only be created once when the application starts - self.meta.zoomBehavior.xScale = self.meta.xScale.copy() - self.meta.zoomBehavior.yScale = self.meta.yScale.copy() } - // in the case where the original scale domains were updated (because of a change in the size of the canvas) - // update the range only but not the domain, the domain is going to be updated - self.meta.zoomBehavior.xScale.range(self.meta.xScale.range()) - self.meta.zoomBehavior.yScale.range(self.meta.yScale.range()) + if (this.isPolarCoordinateSystem() || this.getEmitInstance().options.lastCoordinateSystem === 'polar') { + // Rebase polar rebuilds and transitions out of the zero-margin polar canvas. + // Undo the current transform so its next application reproduces the rebuilt domains, + // preserving zoom and pan even when another Chart instance reuses the SVG. + const transform = (this.getDraggableNode() as any)?.__zoom || d3ZoomIdentity + const inverseTransform = d3ZoomIdentity.scale(1 / transform.k).translate(-transform.x, -transform.y) + self.meta.zoomBehavior.xScale = inverseTransform.rescaleX(self.meta.xScale) + self.meta.zoomBehavior.yScale = inverseTransform.rescaleY(self.meta.yScale) + } else { + // Cartesian plots keep their initial zoom baseline; only pixel ranges change on rebuild. + self.meta.zoomBehavior.xScale = self.meta.zoomBehavior.xScale || self.meta.xScale.copy() + self.meta.zoomBehavior.yScale = self.meta.zoomBehavior.yScale || self.meta.yScale.copy() + self.meta.zoomBehavior.xScale.range(self.meta.xScale.range()) + self.meta.zoomBehavior.yScale.range(self.meta.yScale.range()) + } // enter this.canvas.enter @@ -613,6 +682,7 @@ export class Chart extends EventEmitter.EventEmitter { }) .attr('width', this.meta.width) .attr('height', this.meta.height) + this.options.lastCoordinateSystem = this.options.coordinateSystem } setUpPlugins() { @@ -633,6 +703,16 @@ export class Chart extends EventEmitter.EventEmitter { const instance = this const canvas = instance.canvas.merge(instance.canvas.enter) + renderPolarGrid(canvas, this.meta, this.options) + + if (this.isPolarCoordinateSystem()) { + canvas.select('.x.axis').style('display', 'none') + canvas.select('.y.axis').style('display', 'none') + return + } + + canvas.select('.x.axis').style('display', null) + // center the axes canvas.select('.x.axis').call(instance.meta.xAxis) @@ -656,6 +736,7 @@ export class Chart extends EventEmitter.EventEmitter { } canvas.select('.y.axis').call(instance.meta.yAxis) + canvas.select('.y.axis').style('display', null) if (this.options.yAxis.position === 'sticky') { const xMin = this.meta.xScale.domain()[0] @@ -701,6 +782,10 @@ export class Chart extends EventEmitter.EventEmitter { instance.emit('after:draw') } + private isPolarCoordinateSystem() { + return this.options.coordinateSystem === 'polar' + } + setUpEventListeners() { const self = this diff --git a/src/polar-grid.test.ts b/src/polar-grid.test.ts new file mode 100644 index 00000000..1b383266 --- /dev/null +++ b/src/polar-grid.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from '@jest/globals' + +import { + formatPolarAngle, + getPolarAngles, + getPolarDomains, + getPolarLabelLayout, + getPolarRayInterval, + getPolarRadiusDomain, + getPolarRadii, + validatePolarOptions +} from './polar-grid' +import { PolarOptions } from './types' + +describe('polar grid', () => { + it('keeps equal units when adjusting domains to the viewport', () => { + expect(getPolarDomains([-2, 2], [-2, 2], 400, 200)).toEqual({ xDomain: [-4, 4], yDomain: [-2, 2] }) + }) + + it('finds visible radii when the origin is outside the viewport', () => { + expect(getPolarRadiusDomain([3, 6], [4, 8])).toEqual([5, 10]) + }) + + it('supports explicit and equally spaced ticks', () => { + expect(getPolarRadii({ radialTicks: [1, 2, 2, 4] }, [0, 5])).toEqual([1, 2, 4]) + expect(getPolarAngles({ angularTicks: 4 })).toEqual([0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) + expect(getPolarAngles({ angularTicks: [0, 2 * Math.PI, -Math.PI, Math.PI] })).toEqual([0, Math.PI]) + }) + + it('adjusts automatic radial ticks to the available pixels', () => { + expect(getPolarRadii({}, [0, 3], 50)).toEqual([1, 2, 3]) + expect(getPolarRadii({}, [0, 3], 100)).toEqual([0.5, 1, 1.5, 2, 2.5, 3]) + }) + + it('preserves explicit radial ticks regardless of the available pixels', () => { + expect(getPolarRadii({ radialTicks: 5 }, [0, 3], 50)).toEqual([0.5, 1, 1.5, 2, 2.5, 3]) + expect(getPolarRadii({ radialTicks: [0.5, 1, 2, 3] }, [0, 3], 50)).toEqual([0.5, 1, 2, 3]) + }) + + it('formats angles in radians and degrees', () => { + expect(formatPolarAngle(Math.PI / 2, 'radians')).toBe('π/2') + expect(formatPolarAngle(Math.PI, 'degrees')).toBe('180°') + }) + + it('uses the viewport edge for polar rays', () => { + expect(getPolarRayInterval([50, 50], [1, 0], 100, 100)?.[1]).toBe(50) + expect(getPolarRayInterval([50, 50], [Math.SQRT1_2, -Math.SQRT1_2], 100, 100)?.[1]).toBeCloseTo(70.7107) + }) + + it('only keeps ray directions that intersect the viewport', () => { + expect(getPolarRayInterval([50, 150], [0, -1], 100, 100)).toEqual([50, 150]) + expect(getPolarRayInterval([50, 150], [0, 1], 100, 100)).toBeUndefined() + }) + + it('keeps labels on the outer visible ring', () => { + const finite = getPolarLabelLayout([50, 50], [1, 0], [10, 20, 60], 100, 100) + expect(finite.position).toEqual([70, 50]) + expect(finite.textAnchor).toBe('start') + expect(getPolarLabelLayout([50, 150], [0, 1], [20], 100, 100)).toBeUndefined() + }) + + it.each([ + { radiusDomain: [-1, 2] }, + { radiusDomain: [2, 1] }, + { radialTicks: 0 }, + { angularTicks: 1.5 }, + { radialTicks: [-1] }, + { angularTicks: [NaN] } + ])('rejects invalid polar options %j', (polar) => { + expect(() => + validatePolarOptions({ target: '#plot', xAxis: { type: 'linear' }, yAxis: { type: 'linear' }, polar }) + ).toThrow() + }) + + it('rejects logarithmic axes in polar mode', () => { + expect(() => validatePolarOptions({ target: '#plot', xAxis: { type: 'log' }, yAxis: { type: 'linear' } })).toThrow( + 'linear axes' + ) + }) +}) diff --git a/src/polar-grid.ts b/src/polar-grid.ts new file mode 100644 index 00000000..b224e08d --- /dev/null +++ b/src/polar-grid.ts @@ -0,0 +1,287 @@ +import { format as d3Format } from 'd3-format' +import { scaleLinear as d3ScaleLinear } from 'd3-scale' +import { select as d3Select } from 'd3-selection' +import type { FunctionPlotOptions, PolarOptions } from './types' +import type { ChartMeta } from './chart' + +const TWO_PI = 2 * Math.PI +const DEFAULT_POLAR_ANGLE_UNIT = 'radians' + +/** Reject domains, ticks and axis types that cannot define a polar grid. */ +export function validatePolarOptions(options: FunctionPlotOptions) { + if (options.xAxis.type !== 'linear' || options.yAxis.type !== 'linear') { + throw new Error('polar coordinate system only supports linear axes') + } + + for (const domain of [options.xAxis.domain, options.yAxis.domain, options.polar?.radiusDomain]) { + if (domain && (domain.length !== 2 || domain.some((value) => !Number.isFinite(value)) || domain[0] >= domain[1])) { + throw new Error('polar domains must contain two finite ascending values') + } + } + + const polar = options.polar + if (polar?.radiusDomain?.[0] < 0) { + throw new Error('polar radiusDomain must be non-negative') + } + + for (const ticks of [polar?.radialTicks, polar?.angularTicks]) { + if (ticks !== undefined && !Array.isArray(ticks) && (!Number.isInteger(ticks) || ticks < 1)) { + throw new Error('polar tick counts must be positive integers') + } + if (Array.isArray(ticks) && ticks.some((value) => !Number.isFinite(value))) { + throw new Error('polar tick values must be finite numbers') + } + } + + if (Array.isArray(polar?.radialTicks) && polar.radialTicks.some((value) => value < 0)) { + throw new Error('polar radial ticks must be non-negative') + } + if (polar?.angleUnit && polar.angleUnit !== 'radians' && polar.angleUnit !== 'degrees') { + throw new Error(`unsupported polar angle unit ${polar.angleUnit}`) + } +} + +/** Expand the narrower domain around its center so both axes use the same units per pixel. */ +export function getPolarDomains(xDomain: number[], yDomain: number[], width: number, height: number) { + const unitsPerPixel = Math.max((xDomain[1] - xDomain[0]) / width, (yDomain[1] - yDomain[0]) / height) + const xCenter = (xDomain[0] + xDomain[1]) / 2 + const yCenter = (yDomain[0] + yDomain[1]) / 2 + return { + xDomain: [xCenter - (width * unitsPerPixel) / 2, xCenter + (width * unitsPerPixel) / 2], + yDomain: [yCenter - (height * unitsPerPixel) / 2, yCenter + (height * unitsPerPixel) / 2] + } +} + +/** Find the nearest and farthest distances from the origin to a rectangular viewport. */ +export function getPolarRadiusDomain(xDomain: number[], yDomain: number[]): [number, number] { + const xMin = Math.min(...xDomain) + const xMax = Math.max(...xDomain) + const yMin = Math.min(...yDomain) + const yMax = Math.max(...yDomain) + const nearestX = Math.max(xMin, Math.min(0, xMax)) + const nearestY = Math.max(yMin, Math.min(0, yMax)) + return [ + Math.hypot(nearestX, nearestY), + Math.hypot(Math.max(Math.abs(xMin), Math.abs(xMax)), Math.max(Math.abs(yMin), Math.abs(yMax))) + ] +} + +/** + * Select radial ticks inside the configured and visible radius bounds. + * Automatic ticks target roughly 50 pixels of spacing; explicit ticks take precedence. + */ +export function getPolarRadii(polar: PolarOptions, visibleDomain: [number, number], pixelsPerUnit?: number) { + const radiusDomain = polar.radiusDomain || visibleDomain + const minimum = Math.max(radiusDomain[0], visibleDomain[0]) + const maximum = Math.min(radiusDomain[1], visibleDomain[1]) + if (minimum > maximum) return [] + let values: number[] + if (Array.isArray(polar.radialTicks)) { + values = polar.radialTicks + } else { + let tickCount = polar.radialTicks || 5 + if (polar.radialTicks === undefined && pixelsPerUnit !== undefined) { + const pixelRange = (maximum - minimum) * pixelsPerUnit + tickCount = Math.max(1, Math.round(pixelRange / 50)) + } + values = d3ScaleLinear().domain([minimum, maximum]).ticks(tickCount) + } + return Array.from(new Set(values)) + .filter((value) => value > 0 && value >= minimum && value <= maximum) + .sort((first, second) => first - second) +} + +/** Normalize explicit angles or distribute a tick count over one revolution. */ +export function getPolarAngles(polar: PolarOptions) { + if (Array.isArray(polar.angularTicks)) { + const normalized = polar.angularTicks.map((angle) => ((angle % TWO_PI) + TWO_PI) % TWO_PI) + return normalized.filter( + (angle, index) => normalized.findIndex((other) => Math.abs(other - angle) < 1e-10) === index + ) + } + const count = polar.angularTicks || 12 + return Array.from({ length: count }, (_, index) => (index * TWO_PI) / count) +} + +/** Format common multiples of pi as fractions, falling back to decimal radians. */ +export function formatPolarAngle(angle: number, unit: 'radians' | 'degrees') { + if (unit === 'degrees') return `${Number(((angle * 180) / Math.PI).toFixed(2))}°` + for (const denominator of [1, 2, 3, 4, 6, 8, 12, 16]) { + const numerator = Math.round((angle / Math.PI) * denominator) + if (Math.abs(angle / Math.PI - numerator / denominator) < 1e-10) { + if (numerator === 0) return '0' + const prefix = numerator === 1 ? '' : numerator === -1 ? '-' : String(numerator) + return `${prefix}π${denominator === 1 ? '' : `/${denominator}`}` + } + } + return Number(angle.toFixed(3)).toString() +} + +/** + * Intersect a ray from center with the viewport rectangle. For each axis, + * solve 0 <= center + direction * distance <= extent, then intersect the two + * distance intervals with distance >= 0. + * + * +---------+ viewport + * | / | + * | / | ray enters at start and leaves at end + * +--/------+ + * center + */ +export function getPolarRayInterval( + center: [number, number], + direction: [number, number], + width: number, + height: number +) { + let minimum = -Infinity + let maximum = Infinity + for (const [origin, delta, extent] of [ + [center[0], direction[0], width], + [center[1], direction[1], height] + ]) { + if (Math.abs(delta) < 1e-10) { + if (origin < 0 || origin > extent) return undefined + continue + } + const first = (0 - origin) / delta + const second = (extent - origin) / delta + minimum = Math.max(minimum, Math.min(first, second)) + maximum = Math.min(maximum, Math.max(first, second)) + } + const start = Math.max(0, minimum) + return maximum > start ? [start, maximum] : undefined +} + +/** Select the outermost visible ring from ascending pixel radii and anchor its label away from the ray. */ +export function getPolarLabelLayout( + center: [number, number], + direction: [number, number], + radii: number[], + width: number, + height: number +) { + const interval = getPolarRayInterval(center, direction, width, height) + if (!interval) return undefined + const radius = radii.filter((value) => value >= interval[0] && value <= interval[1]).pop() + if (radius === undefined) return undefined + const position: [number, number] = [center[0] + direction[0] * radius, center[1] + direction[1] * radius] + const textAnchor = direction[0] > 1e-10 ? 'start' : direction[0] < -1e-10 ? 'end' : 'middle' + return { position, textAnchor } +} + +/** Render clipped circles/rays, with angular labels fitted inside the SVG viewport. */ +export function renderPolarGrid(canvas: any, meta: ChartMeta, options: FunctionPlotOptions) { + const enabled = options.coordinateSystem === 'polar' && options.polar?.grid !== false + if (!enabled) { + canvas.selectAll('g.polar-grid').remove() + return + } + + // Join the grid before plotted content; only the lines need the plot clip. + const selection = canvas.selectAll('g.polar-grid').data([options.polar || {}]) + const grid = selection + .merge(selection.enter().insert('g', '.content, .zoom-and-drag').attr('class', 'polar-grid')) + .attr('clip-path', null) + .attr('pointer-events', 'none') + const clippedGrid = grid.selectAll(':scope > g.polar-grid-lines').data([0]) + const clippedGridEnter = clippedGrid.enter().append('g').attr('class', 'polar-grid-lines') + // Reuse the SVG's plot clip, which can outlive the Chart instance that created it. + const clipId = canvas.select('clipPath[id]').attr('id') + const lines = clippedGrid.merge(clippedGridEnter).attr('clip-path', 'url(#' + clipId + ')') + const polar = options.polar || {} + const xScale = meta.xScale + const yScale = meta.yScale + const visibleDomain = getPolarRadiusDomain(xScale.domain(), yScale.domain()) + const center: [number, number] = [xScale(0), yScale(0)] + const xUnit = xScale(1) - center[0] + const yUnit = yScale(1) - center[1] + const pixelsPerUnit = Math.abs(xUnit) + const radii = getPolarRadii(polar, visibleDomain, pixelsPerUnit) + const angles = getPolarAngles(polar) + const getRayGeometry = (angle: number) => { + const radius = polar.radiusDomain?.[1] === undefined ? undefined : Math.min(polar.radiusDomain[1], visibleDomain[1]) + const direction: [number, number] = [Math.cos(angle) * Math.sign(xUnit), Math.sin(angle) * Math.sign(yUnit)] + const interval = getPolarRayInterval(center, direction, meta.width, meta.height) + if (!interval) return undefined + const rayLength = radius === undefined ? interval[1] : Math.min(radius * pixelsPerUnit, interval[1]) + return rayLength > interval[0] ? { direction, rayLength } : undefined + } + const visibleAngles = angles.filter((angle) => getRayGeometry(angle)) + + // Circle and ray joins stay inside the clipped grid group. + const circles = lines.selectAll('circle.polar-grid-circle').data(radii, (value: number) => value) + circles.exit().remove() + circles + .merge(circles.enter().append('circle').attr('class', 'polar-grid-circle')) + .attr('cx', center[0]) + .attr('cy', center[1]) + .attr('r', (value: number) => value * pixelsPerUnit) + .attr('fill', 'none') + .attr('stroke', 'currentColor') + .attr('opacity', 0.2) + + const rays = lines.selectAll('line.polar-grid-ray').data(visibleAngles, (value: number) => value) + rays.exit().remove() + rays + .merge(rays.enter().append('line').attr('class', 'polar-grid-ray')) + .attr('x1', center[0]) + .attr('y1', center[1]) + .attr('x2', (angle: number) => { + const ray = getRayGeometry(angle) + return center[0] + ray.direction[0] * ray.rayLength + }) + .attr('y2', (angle: number) => { + const ray = getRayGeometry(angle) + return center[1] + ray.direction[1] * ray.rayLength + }) + .attr('stroke', 'currentColor') + .attr('opacity', 0.2) + + // Labels join the outer group so their text can extend beyond the plot clip. + const radiusLabels = grid.selectAll('text.polar-radius-label').data(radii, (value: number) => value) + radiusLabels.exit().remove() + radiusLabels + .merge(radiusLabels.enter().append('text').attr('class', 'polar-radius-label')) + .attr('x', (value: number) => xScale(value)) + .attr('y', center[1]) + .attr('text-anchor', 'middle') + .attr('dy', '-0.8em') + .attr('fill', 'currentColor') + .text(polar.radiusTickFormat || d3Format('~g')) + + const labels: any[] = [] + if (polar.angularLabels && radii.length) { + const pixelRadii = radii.map((value) => value * pixelsPerUnit) + for (const angle of visibleAngles) { + const direction: [number, number] = [Math.cos(angle) * Math.sign(xUnit), Math.sin(angle) * Math.sign(yUnit)] + const layout = getPolarLabelLayout(center, direction, pixelRadii, meta.width, meta.height) + if (layout) labels.push({ angle, direction, ...layout }) + } + } + const angleLabels = grid.selectAll('text.polar-angle-label').data(labels, (value: any) => value.angle) + angleLabels.exit().remove() + angleLabels + .merge(angleLabels.enter().append('text').attr('class', 'polar-angle-label')) + .attr('x', (value: any) => value.position[0]) + .attr('y', (value: any) => value.position[1]) + .attr('dx', (value: any) => value.direction[0] * 8) + .attr('dy', (value: any) => value.direction[1] * 8) + .attr('text-anchor', (value: any) => value.textAnchor) + .attr('dominant-baseline', 'middle') + .attr('fill', 'currentColor') + .text((value: any) => { + const unit = polar.angleUnit || DEFAULT_POLAR_ANGLE_UNIT + const angle = unit === 'degrees' ? (value.angle * 180) / Math.PI : value.angle + return polar.angleTickFormat ? polar.angleTickFormat(angle) : formatPolarAngle(value.angle, unit) + }) + .each(function (this: SVGTextElement, value: any) { + // Keep the chosen ring anchor, adjusting only text that crosses the SVG viewport. + const bounds = this.getBBox() + const dx = Math.max(0, -bounds.x) - Math.max(0, bounds.x + bounds.width - meta.width) + const dy = Math.max(0, -bounds.y) - Math.max(0, bounds.y + bounds.height - meta.height) + d3Select(this) + .attr('dx', value.direction[0] * 8 + dx) + .attr('dy', value.direction[1] * 8 + dy) + }) +} diff --git a/src/types.ts b/src/types.ts index ac04983b..fdd16106 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,51 @@ export interface FunctionPlotOptionsAxis { position?: 'sticky' | 'left' | 'bottom' } +export interface PolarOptions { + /** + * True to display the polar grid. Defaults to true in polar mode. + */ + grid?: boolean + + /** + * True to display angular labels on the outermost visible radial grid circle. + * Defaults to false. + */ + angularLabels?: boolean + + /** + * Non-negative ascending bounds for grid radii. Also seeds the initial viewport + * when xAxis.domain or yAxis.domain is omitted. + */ + radiusDomain?: [number, number] + + /** + * Approximate radial tick count, or explicit non-negative tick values. + * When omitted, the count adapts to the available pixel space. + */ + radialTicks?: number | number[] + + /** + * Number of equally spaced angular ticks, or explicit angles in radians. + */ + angularTicks?: number | number[] + + /** + * Unit used by the default angular tick formatter. Defaults to radians. + */ + angleUnit?: 'radians' | 'degrees' + + /** + * Formatter for radial tick labels. + */ + radiusTickFormat?: (value: number) => string + + /** + * Formatter for angular tick labels. The value is expressed using angleUnit. + */ + angleTickFormat?: (value: number) => string +} + export interface FunctionPlotTip { /** * True to display a vertical line on mouseover @@ -298,11 +343,22 @@ export interface FunctionPlotOptions { */ id?: string + /** + * @private + * Last rendered coordinate system, preserved when callers mutate options. + */ + lastCoordinateSystem?: 'cartesian' | 'polar' + /** * A css selector or DOM node of the parent element that will contain the graph */ target: string | HTMLElement + /** + * The coordinate system used to render the chart. Defaults to cartesian. + */ + coordinateSystem?: 'cartesian' | 'polar' + /** * The chart title */ @@ -328,6 +384,11 @@ export interface FunctionPlotOptions { */ yAxis?: FunctionPlotOptionsAxis + /** + * Polar coordinate system configuration. + */ + polar?: PolarOptions + /** * The x-axis domain, internally state used to preserve the x-domain across multiple calls to function plot */ diff --git a/test/e2e/__image_snapshots__/graphs-test-js-function-plot-should-render-a-polar-grid-1-snap.png b/test/e2e/__image_snapshots__/graphs-test-js-function-plot-should-render-a-polar-grid-1-snap.png new file mode 100644 index 00000000..3a07a609 Binary files /dev/null and b/test/e2e/__image_snapshots__/graphs-test-js-function-plot-should-render-a-polar-grid-1-snap.png differ diff --git a/test/e2e/graphs.test.js b/test/e2e/graphs.test.js index 3eefca5a..647626b2 100644 --- a/test/e2e/graphs.test.js +++ b/test/e2e/graphs.test.js @@ -82,6 +82,27 @@ functionPlot({ expect(image).toMatchImageSnapshot(matchSnapshotConfig) }) + it('should render a polar grid', async () => { + await page.evaluate(` +functionPlot({ + target: '#playground', + coordinateSystem: 'polar', + polar: { + radiusDomain: [0, 3], + angularTicks: 8, + angularLabels: true + }, + data: [{ + r: '2 * sin(4 * theta)', + fnType: 'polar', + graphType: 'polyline' + }] +}) + `) + const image = Buffer.from(await page.screenshot()) + expect(image).toMatchImageSnapshot(matchSnapshotConfig) + }) + it('should render distinct domains', async () => { await page.evaluate(` functionPlot({ diff --git a/test/e2e/polar-grid.test.js b/test/e2e/polar-grid.test.js new file mode 100644 index 00000000..d48aeca1 --- /dev/null +++ b/test/e2e/polar-grid.test.js @@ -0,0 +1,362 @@ +const puppeteer = require('puppeteer') + +describe('Polar coordinate system', () => { + let browser + let page + + beforeAll(async () => { + browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }) + page = await browser.newPage() + await page.setViewport({ width: 1000, height: 1000 }) + await page.goto('http://localhost:4444/jest-function-plot.html') + }) + + beforeEach(async () => { + await page.evaluate(() => { + document.querySelector('#playground').innerHTML = '' + }) + }) + + afterAll(async () => { + await browser?.close() + }) + + async function zoomAt(x = 0.5, y = 0.5) { + const before = await page.evaluate( + (x, y) => { + const bounds = document.querySelector('.zoom-and-drag').getBoundingClientRect() + const mouseX = Math.round(bounds.left + bounds.width * x) + const mouseY = Math.round(bounds.top + bounds.height * y) + return { + scale: document.querySelector('.zoom-and-drag').__zoom.k, + x: mouseX, + y: mouseY, + anchor: [mouseX - bounds.left, mouseY - bounds.top] + } + }, + x, + y + ) + await page.mouse.move(before.x, before.y) + await page.mouse.wheel({ deltaY: -120 }) + // Wait for the wheel event's redraw instead of using a fixed delay. + await page.waitForFunction((scale) => document.querySelector('.zoom-and-drag').__zoom.k > scale, {}, before.scale) + return before.anchor + } + + async function scaleState() { + return page.evaluate(() => { + const chart = window.polarChart + const meta = chart.meta + const transform = chart.draggable.node().__zoom + return { + x: meta.width / (meta.xScale.domain()[1] - meta.xScale.domain()[0]), + y: meta.height / (meta.yScale.domain()[1] - meta.yScale.domain()[0]), + domains: [meta.xScale.domain(), meta.yScale.domain()], + origin: [meta.xScale(0), meta.yScale(0)], + transform: { k: transform.k, x: transform.x, y: transform.y } + } + }) + } + + async function labelsInsideViewport(selector) { + return page.$eval( + 'svg', + (svg, selector) => { + const viewport = svg.getBoundingClientRect() + return Array.from(svg.querySelectorAll(selector)).map((node) => { + const bounds = node.getBoundingClientRect() + // SVG text coordinates can round by a fraction of a pixel at the viewport edge. + return ( + bounds.width > 0 && + bounds.height > 0 && + bounds.left >= viewport.left - 0.01 && + bounds.right <= viewport.right + 0.01 && + bounds.top >= viewport.top - 0.01 && + bounds.bottom <= viewport.bottom + 0.01 + ) + }) + }, + selector + ) + } + + it('keeps polar functions Cartesian by default', async () => { + const result = await page.evaluate(() => { + functionPlot({ + target: '#playground', + data: [{ r: '2', fnType: 'polar', graphType: 'polyline' }] + }) + return { + polarGrid: document.querySelectorAll('.polar-grid').length, + visibleAxes: Array.from(document.querySelectorAll('.axis')).every( + (node) => getComputedStyle(node).display !== 'none' + ), + origins: document.querySelectorAll('.origin').length + } + }) + + expect(result).toEqual({ polarGrid: 0, visibleAxes: true, origins: 2 }) + }) + + it('renders polar layers and reuses their shared clip on rebuild', async () => { + const result = await page.evaluate(() => { + const options = () => ({ + target: '#playground', + coordinateSystem: 'polar', + polar: { radiusDomain: [0, 3], radialTicks: [1, 2, 3], angularTicks: 4, angularLabels: true }, + data: [{ r: '2 * sin(4 * theta)', fnType: 'polar', graphType: 'polyline' }] + }) + functionPlot(options()) + const svg = document.querySelector('svg') + svg.style.overflow = 'visible' + functionPlot(options()) + const grid = document.querySelector('.polar-grid') + const clip = grid.querySelector('.polar-grid-lines').getAttribute('clip-path') + return { + circles: grid.querySelectorAll('.polar-grid-circle').length, + rays: grid.querySelectorAll('.polar-grid-ray').length, + labels: Array.from(grid.querySelectorAll('.polar-angle-label')).map((node) => node.textContent), + reusedSvg: svg === document.querySelector('svg'), + clipExists: !!document.getElementById(clip.slice(5, -1)), + sharedClip: clip === document.querySelector('.content').getAttribute('clip-path'), + labelsUnclipped: grid.getAttribute('clip-path') === null, + axesHidden: Array.from(document.querySelectorAll('.axis')).every( + (node) => getComputedStyle(node).display === 'none' + ), + curve: !!document.querySelector('.graph path')?.getAttribute('d'), + gridBeforeContent: !!( + grid.compareDocumentPosition(document.querySelector('.content')) & Node.DOCUMENT_POSITION_FOLLOWING + ) + } + }) + + expect(result).toEqual({ + circles: 3, + rays: 4, + labels: ['0', 'π/2', 'π', '3π/2'], + reusedSvg: true, + clipExists: true, + sharedClip: true, + labelsUnclipped: true, + axesHidden: true, + curve: true, + gridBeforeContent: true + }) + }) + + it('ends unbounded polar rays at the plot viewport', async () => { + const result = await page.evaluate(() => { + functionPlot({ + target: '#playground', + coordinateSystem: 'polar', + polar: { angularTicks: [0] }, + data: [{ r: '1', fnType: 'polar', graphType: 'polyline' }] + }) + const ray = document.querySelector('.polar-grid-ray') + const clip = document.querySelector('.clip') + return { + rayEnd: Number(ray.getAttribute('x2')), + viewportWidth: Number(clip.getAttribute('width')) + } + }) + + expect(result.rayEnd).toBe(result.viewportWidth) + }) + + it('hides angles whose rays are outside the viewport after panning', async () => { + const result = await page.evaluate(() => { + functionPlot({ + target: '#playground', + coordinateSystem: 'polar', + polar: { angularTicks: 4, angularLabels: true }, + xAxis: { domain: [-2, 2] }, + yAxis: { domain: [1, 5] }, + data: [{ r: '1', fnType: 'polar', graphType: 'polyline' }] + }) + return { + labels: Array.from(document.querySelectorAll('.polar-angle-label')).map((node) => node.textContent), + rays: document.querySelectorAll('.polar-grid-ray').length + } + }) + + expect(result.labels).toEqual(['π/2']) + expect(result.rays).toBe(1) + }) + + it('hides angular labels by default', async () => { + const result = await page.evaluate(() => { + functionPlot({ + target: '#playground', + coordinateSystem: 'polar', + polar: { radialTicks: [1, 2], angularTicks: 4 }, + data: [{ r: '1', fnType: 'polar', graphType: 'polyline' }] + }) + return document.querySelectorAll('.polar-angle-label').length + }) + + expect(result).toBe(0) + }) + + it.each([false, true])( + 'preserves polar scales after resizing and zooming (previous zoom: %s)', + async (previousZoom) => { + await page.evaluate(() => { + window.polarOptions = { + target: '#playground', + width: 400, + height: 400, + coordinateSystem: 'polar', + polar: { radiusDomain: [0, 3], radialTicks: [2] }, + data: [{ r: '2', fnType: 'polar', graphType: 'polyline' }] + } + window.polarChart = functionPlot(window.polarOptions) + }) + if (previousZoom) await zoomAt(0.25, 0.25) + const before = await scaleState() + await page.evaluate(() => { + window.polarOptions.width = 800 + window.polarChart = functionPlot(window.polarOptions) + }) + const resized = await scaleState() + expect(resized.transform).toEqual(before.transform) + expect(resized.x).toBeCloseTo(resized.y) + + await zoomAt() + const zoomed = await scaleState() + const factor = zoomed.transform.k / resized.transform.k + expect(zoomed.x).toBeCloseTo(zoomed.y) + expect(zoomed.x / resized.x).toBeCloseTo(factor) + const geometry = await page.evaluate(() => { + const curve = document.querySelector('.graph path').getBBox() + const circle = document.querySelector('.polar-grid-circle').getBBox() + return { curveWidth: curve.width, curveHeight: curve.height, diameter: circle.width } + }) + expect(geometry.curveWidth).toBeCloseTo(geometry.diameter, 1) + expect(geometry.curveHeight).toBeCloseTo(geometry.diameter, 1) + } + ) + + it.each([ + ['cartesian', true], + ['polar', true], + ['cartesian', false], + ['polar', false] + ])('preserves zoom when switching from %s (reuse options: %s)', async (coordinateSystem, reuseOptions) => { + await page.evaluate((coordinateSystem) => { + window.polarOptions = { + target: '#playground', + width: 550, + height: 350, + coordinateSystem, + data: [{ fn: 'x', graphType: 'polyline' }] + } + window.polarChart = functionPlot(window.polarOptions) + }, coordinateSystem) + await zoomAt(0.25, 0.25) + + for (const mode of [coordinateSystem === 'polar' ? 'cartesian' : 'polar', coordinateSystem]) { + const previous = await scaleState() + await page.evaluate( + (mode, reuseOptions) => { + const previous = window.polarOptions + // New options omit internal metadata while reusing the previous SVG. + const options = reuseOptions + ? previous + : { + target: previous.target, + width: previous.width, + height: previous.height, + xAxis: previous.xAxis, + yAxis: previous.yAxis, + data: previous.data + } + options.coordinateSystem = mode + window.polarOptions = options + window.polarChart = functionPlot(options) + }, + mode, + reuseOptions + ) + const rebuilt = await scaleState() + expect(rebuilt.transform).toEqual(previous.transform) + + // Applying the same transform must leave the rebuilt viewport unchanged. + await page.evaluate(() => { + const chart = window.polarChart + chart.emit('zoom', { transform: chart.draggable.node().__zoom }) + chart.draw() + }) + const unchanged = await scaleState() + for (const axis of [0, 1]) { + for (const bound of [0, 1]) { + expect(unchanged.domains[axis][bound]).toBeCloseTo(rebuilt.domains[axis][bound], 10) + } + } + + const anchor = await zoomAt(0.25, 0.25) + const zoomed = await scaleState() + const factor = zoomed.transform.k / rebuilt.transform.k + expect(zoomed.x / rebuilt.x).toBeCloseTo(factor) + expect(zoomed.y / rebuilt.y).toBeCloseTo(factor) + for (const axis of [0, 1]) { + expect(zoomed.origin[axis]).toBeCloseTo(anchor[axis] + (rebuilt.origin[axis] - anchor[axis]) * factor) + } + } + }) + + it.each(['cartesian', 'polar'])('keeps the title and hover legend inside the %s SVG', async (coordinateSystem) => { + await page.evaluate((coordinateSystem) => { + const options = { + target: '#playground', + width: 400, + height: 400, + coordinateSystem, + title: 'Plot title', + polar: { radiusDomain: [0, 3] }, + data: [{ fn: 'x', graphType: 'polyline', title: 'line' }] + } + functionPlot(options) + const chart = functionPlot(options) + chart.tip.move({ x: 1, y: 1 }) + }, coordinateSystem) + expect(await labelsInsideViewport('.title, .top-right-legend')).toEqual([true, true]) + }) + + it.each([false, true])("fits angle labels on each ray's outermost ring (custom format: %s)", async (customFormat) => { + const result = await page.evaluate((customFormat) => { + functionPlot({ + target: '#playground', + width: 600, + height: 400, + coordinateSystem: 'polar', + xAxis: { domain: [-3, 3] }, + yAxis: { domain: [-2, 2] }, + polar: { + radialTicks: [1, 2, 3], + angularTicks: 4, + angularLabels: true, + angleTickFormat: customFormat ? (angle) => `angle = ${angle.toFixed(2)}` : undefined + }, + data: [] + }) + // The horizontal ray reaches ring 3; the vertical ray only reaches ring 2. + const labels = document.querySelectorAll('.polar-angle-label') + return { zeroX: Number(labels[0].getAttribute('x')), halfPiY: Number(labels[1].getAttribute('y')) } + }, customFormat) + expect(result.zeroX).toBeCloseTo(600) + expect(result.halfPiY).toBeCloseTo(0) + expect(await labelsInsideViewport('.polar-angle-label')).toEqual([true, true, true, true]) + }) + + it('rejects logarithmic axes in polar mode', async () => { + const error = await page.evaluate(() => { + try { + functionPlot({ target: '#playground', coordinateSystem: 'polar', xAxis: { type: 'log' } }) + } catch (error) { + return String(error) + } + }) + expect(error).toContain('polar coordinate system only supports linear axes') + }) +}) diff --git a/test/types.ts b/test/types.ts index f3304a35..8e204597 100644 --- a/test/types.ts +++ b/test/types.ts @@ -19,3 +19,25 @@ functionPlot({ } ] }) + +functionPlot({ + target: '#playground', + coordinateSystem: 'polar', + polar: { + grid: true, + radiusDomain: [0, 3], + radialTicks: 5, + angularTicks: 12, + angularLabels: true, + angleUnit: 'degrees', + radiusTickFormat: (value) => value.toFixed(1), + angleTickFormat: (value) => value + '°' + }, + data: [ + { + r: '2 * sin(4 * theta)', + fnType: 'polar', + graphType: 'polyline' + } + ] +})