Skip to content

Refactor main graph to d3.js#6159

Open
apata wants to merge 30 commits intomain-graph-v2-backendfrom
main-graph-to-api-v2--double-headed
Open

Refactor main graph to d3.js#6159
apata wants to merge 30 commits intomain-graph-v2-backendfrom
main-graph-to-api-v2--double-headed

Conversation

@apata
Copy link
Copy Markdown
Contributor

@apata apata commented Mar 12, 2026

Refactors line graph to Typescript, from chart.js library to d3.js library. Depends on the new query backend. #6173

The rationale for switching the library:

  • we need d3.js for the complicated world map (can't be done with chart.js)

  • having two charting libraries increases the size of dashboard javascript and therefore makes the website take longer to load

  • Test side-by-side

  • Remove console.log of "effect running"

  • Delete line-graph.js (-301)

  • Delete graph-util.js (-127)

  • Delete graph-tooltip.js (-216)

  • Delete date-formatter.js (-115)

Changes

  • Tooltip is aligned to top left of finger always, but constrained within the chart left to right
  • Mobile interactivity (drag left and right to see tooltip, release finger to zoom in on currently shown period)
  • Stops x tick texts from rotating, adds subtle x ticks

Fixes

  • Fixes issue with invalid x axis labels on minute resolution charts
  • Fixes issue with ambiguous labels in tooltip when comparing year over year

Tests

  • Automated tests have been added

Changelog

  • Entry has been added to changelog

Documentation

  • This change does not need a documentation update

Dark mode

  • The UI has been tested both in dark and light mode

@apata apata added the preview label Mar 12, 2026
@github-actions
Copy link
Copy Markdown

Preview environment👷🏼‍♀️🏗️
PR-6159

@apata apata force-pushed the main-graph-to-api-v2--double-headed branch 2 times, most recently from cddaaaa to 2d27729 Compare March 12, 2026 16:03
This was referenced Mar 17, 2026
@apata apata removed the preview label Apr 1, 2026
@apata apata force-pushed the main-graph-to-api-v2--double-headed branch from 916c50b to 91df8c6 Compare April 1, 2026 11:26
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 1, 2026

CLA assistant check
All committers have signed the CLA.

@apata apata changed the base branch from main-graph-to-api-v2 to main-graph-v2-backend April 1, 2026 14:50
@apata apata added the preview label Apr 1, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

Preview environment👷🏼‍♀️🏗️
PR-6159

@apata apata force-pushed the main-graph-to-api-v2--double-headed branch 3 times, most recently from b590145 to c37fa8f Compare April 7, 2026 17:44
@apata apata force-pushed the main-graph-to-api-v2--double-headed branch 2 times, most recently from 47d0f68 to df87958 Compare April 13, 2026 08:18
@apata apata changed the title WIP: Line graph UI rewire Refactor main graph to d3.js Apr 13, 2026
@apata apata marked this pull request as ready for review April 13, 2026 08:49
@apata apata requested a review from a team April 13, 2026 08:49
@apata apata force-pushed the main-graph-to-api-v2--double-headed branch from 7257733 to 6a3120a Compare April 13, 2026 19:50
Comment on lines +6 to +13
const browserDateFormat = Intl.DateTimeFormat(navigator.language, {
hour: 'numeric'
})

export function is12HourClock() {
return browserDateFormat.resolvedOptions().hour12
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copied from date-formatter.js that was deleted.

Comment on lines +44 to +46
// enter delay on mobile is needed to prevent the tooltip from entering when the user starts to y-pan
// but the y-pan is not yet certain
enter={isTouchDevice ? 'transition-opacity duration-0 delay-150' : ''}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a nice improvement on mobile! Holding a finger a bit longer on the graph is quite intuitive and it's much better to not let any tooltips show up to just disturb the experience.

The only thing that's bothering me a bit is that text outside the graph container (precisely the Channels tab text) gets selected when I tap-and-hold inside the graph container, which in turn makes the OS render a magnifying glass for text selection right next to my finger (instead of the actual tooltip). When I move my finger a bit, it displays the tooltip though.

^ something to fix another day I guess. Nice work!

metrics: MetricValues // one item
}

export type MetricValues = [null] | [number] | [RevenueMetricValue]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we also export a MetricValue type here? We might even want to use a single type across the whole dashboard in the future, but for now we could just export it from here:

Suggested change
export type MetricValues = [null] | [number] | [RevenueMetricValue]
export type MetricValue = null | number | RevenueMetricValue
export type MetricValues = [MetricValue]

Then the inlined RevenueMetricValue | number | null types in main-graph.tsx and main-graph-data.ts could be replaced with MetricValue as well.

Comment on lines +102 to +104
// can't be done in a single pass with remapAndFillData
// because we need the xLabels formatting parameters to be known
const remappedDataInGraphFormat = remappedData.map(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If I'm reading this right, the only reason for not being able to find yMax in a single pass with remapAndFillData is knowing how to evaluate shouldShowDate and shouldShowYear, which come from first/last time label from main/comparison results.

Since meta.time_labels are the source of truth of x-labels, couldn't we just do:

mainFirstLabel = meta.time_labels[0]
mainComparisonLabel = meta.comparison_time_labels[0]

(and same for the last labels of both)?

statsQuery.date_range = DashboardPeriod.realtime_30m
}

statsQuery.include.present_index = true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is unused now, right? Not pushing to get rid of it yet, but just wanted to flag it. Let's see if the new dotted line definition works out first...

getChange
}: {
data: MainGraphResponse
getNumericValue: (metrics: RevenueMetricValue | number | null) => number
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is one of those cases (but there are more) where we could replace the inlined type with a proper MetricValue. Also, metricValue would be a better argument name in this function too:

Suggested change
getNumericValue: (metrics: RevenueMetricValue | number | null) => number
getNumericValue: (metricValue: MetricValue) => number

Comment on lines +73 to +75
const isPartial = partialTimeLabels.find((l) => l === timeLabel)
? true
: false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this would do the trick too?

Suggested change
const isPartial = partialTimeLabels.find((l) => l === timeLabel)
? true
: false
const isPartial = partialTimeLabels.includes(timeLabel)


export type LineSegment = {
startIndexInclusive: number
stopIndexExclusive: number
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking suggestion (feel free to ignore): Maybe use shorter names here. E.g.: fromIndex and toIndex without specifying whether the index is inclusive or exclusive?

}

/**
* Creates segments from points of main series.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
* Creates segments from points of main series.
* Creates segments from points of a series.

Used for comparisons too.

* No line is drawn from or to gaps in the data.
*/
export function getLineSegments(data: SeriesValue[]): LineSegment[] {
return data.reduce((segments: LineSegment[], curr, i) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Using reduce here makes it really difficult (at least for me) to understand this function. Had a long pause reading it. How about a simple for i loop?

I think it can also be simplified due to not needing to account for gaps appearing in the middle of the series anymore. Meaning that we can just break out of the loop on the first encounter of a !isDefined datapoint.

width: number
marginRight: number
minClientX: number
maxAttempts?: number
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this doesn't need to be an argument? It should always be a fixed value of 2, right? First try to render the y axis labels, and if it didn't fit, do it a second time with a correct width that fits all labels.

Comment on lines +295 to +297
if (typeof onPointerMove !== 'function') {
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This appears to be redundant because GraphProps doesn't allow it to not be a function.

.attr('offset', '100%')
.attr('stop-color', stopBottom.color)
.attr('stop-opacity', stopBottom.opacity)
return id
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The return value is unused. Could make this function return void.

Comment on lines +502 to +508
new Array(maxXTicks).fill(null).forEach((_v, i) => {
const tickValues = scale.ticks(maxXTicks - i)
if (tickValues.every(isWholeNumber)) {
result.add(JSON.stringify(tickValues))
}
})
return result
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's replace with a simple for i loop?

for (let i = 0; i < maxXTicks; i++) {
  const tickValues = scale.ticks(maxXTicks - i)
  if (tickValues.every(isWholeNumber)) {
    result.add(JSON.stringify(tickValues))
  }
}

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants