Skip to content

Add an opt-in polar grid to the 1.x branch - #373

Open
wwwaker wants to merge 3 commits into
mauriciopoppe:1.xfrom
wwwaker:codex/polar-grid-release-1.25
Open

wwwaker wants to merge 3 commits into
mauriciopoppe:1.xfrom
wwwaker:codex/polar-grid-release-1.25

Conversation

@wwwaker

@wwwaker wwwaker commented Sep 29, 2026

Copy link
Copy Markdown

What this changes

  • Adds polar grid rendering and configurable radial/angular ticks.
  • Keeps grid lines and plotted curves within the same plot clipping area.
  • Makes angular labels opt-in (polar.angularLabels: true), using radians by default.

Open design question

When angular labels are enabled, the current implementation selects a
visible radial ring independently for each angle. After panning or zooming,
labels may therefore appear on different rings.
image

I think we should select one shared ring, favor an outer ring with enough
visible space for labels, and hide labels that do not fit (I learn it from Desmos). I would appreciate
your thoughts on that rule.

Validation

  • TypeScript compilation and 14 polar unit tests pass.
  • Browser E2E tests have been added.

@wwwaker

wwwaker commented Sep 29, 2026

Copy link
Copy Markdown
Author

Actually, I’ve considered three approaches to positioning angular tick labels:

  1. Place each label where its ray meets the viewport boundary (the approach used in my previous commit).
  2. For each ray, place its label where it intersects that ray’s outermost visible grid circle (the current implementation).
  3. Select one suitable grid circle for the entire viewport, and show labels only where that circle intersects a ray within the viewport (similar to Desmos’s approach).
image image image

@mauriciopoppe mauriciopoppe left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please change the base branch to 1.x, the reason is that this is a feature so I'd need to create the branch release-1.26.

Comment thread README.md Outdated

[`Check the available options in the docs`](https://mauriciopoppe.github.io/function-plot/docs/functions/default-1.html)

Polar functions use Cartesian coordinates by default. To display a polar grid,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the examples are in site/js/site.js, please move this example alongside a comment above it, the dev server running in localhost:8080 should display it.

Comment thread src/chart.ts Outdated
throw Error('axis type ' + axis.type + ' unsupported')
})(this.options.yAxis))

if (this.options.coordinateSystem === 'polar') {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a little weird to have two locations where xDomain & yDomain are set (the block above this one and this one).

I'm thinking we could create a function that returns the xDomain and yDomain, and within that function call getPolarDomains if needed or fallback to the other cartesian domain logic.

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 }
}

Caller:

const { xDomain, yDomain } = getDomains(
  this.options,
  this.meta.width,
  this.meta.height
)

this.meta.xDomain = xDomain
this.meta.yDomain = yDomain

Comment thread src/chart.ts Outdated
.selectAll('text.x.axis-label')
.data(function (d: FunctionPlotOptions) {
return [d.xAxis.label].filter(Boolean)
return d.coordinateSystem === 'polar' ? [] : [d.xAxis.label].filter(Boolean)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.isPolarCoordinateSystem() instead (where self = outer scope).

Comment thread src/chart.ts Outdated
.selectAll('text.y.axis-label')
.data(function (d: FunctionPlotOptions) {
return [d.yAxis.label].filter(Boolean)
return d.coordinateSystem === 'polar' ? [] : [d.yAxis.label].filter(Boolean)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.isPolarCoordinateSystem() instead (where self = outer scope).

Comment thread src/chart.ts
.attr('stroke', 'black')
.attr('opacity', 0.2)
yOrigin.merge(yOriginEnter).attr('d', this.line)
yOrigin.exit().remove()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this!

Comment thread src/polar-grid.ts
const polar = options.polar || {}
const xScale = meta.xScale
const yScale = meta.yScale
const visibleDomain = getPolarRadiusDomain(xScale.domain(), yScale.domain())

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these variables are used within functions e.g. radius is only used by getRayGeometry, please limit the scope of a variable to the smallest scope possible, review this for all the consts you defined here.

Comment thread src/polar-grid.ts
const TWO_PI = 2 * Math.PI
const DEFAULT_POLAR_ANGLE_UNIT = 'radians'

export function validatePolarOptions(options: FunctionPlotOptions) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a docstring in all the functions, if possible add some explanations with ascii graphs about the math.

Comment thread src/types.ts
position?: 'sticky' | 'left' | 'bottom'
}

export interface PolarOptions {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API wise looks clean, I explored the following but I think a similar version it would be part of the v2 (main) branch instead.

interface AxisOptions {
  domain?: [number, number]
  label?: string
}

interface CartesianAxisOptions extends AxisOptions {
  type?: 'linear' | 'log'
  invert?: boolean
  position?: 'sticky' | 'left' | 'bottom'
}

interface PolarRadiusAxisOptions extends AxisOptions {
  ticks?: number | number[]
  tickFormat?: (radius: number) => string
}

interface PolarAngleAxisOptions extends AxisOptions {
  ticks?: number | number[]
  unit?: 'radians' | 'degrees'
  tickFormat?: (angle: number) => string
  labels?: boolean
}

interface PolarOptions {
  grid?: boolean
  radiusAxis?: PolarRadiusAxisOptions
  angleAxis?: PolarAngleAxisOptions
}

@@ -0,0 +1,187 @@
const puppeteer = require('puppeteer')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test to test/e2e/graphs.test.js too to see a screenshot

await browser?.close()
})

it('renders a true polar grid while preserving Cartesian defaults', async () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are good e2e tests, in this codebase I just did screenshot tests and that was enough but checking dom properties seems like a good idea.

@wwwaker
wwwaker marked this pull request as draft September 30, 2026 05:03
@wwwaker
wwwaker changed the base branch from release-1.25 to 1.x September 30, 2026 05:05
@wwwaker

wwwaker commented Sep 30, 2026 •

Copy link
Copy Markdown
Author

Thank you very much for the detailed review and helpful suggestions.
I pushed a follow-up commit addressing the review feedback. The changes include the domain calculation refactor, polar canvas dimension tests, join/enter/exit comments, and the screenshot test.

@wwwaker
wwwaker marked this pull request as ready for review September 30, 2026 07:20

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants