Skip to content

Commit cddaaaa

Browse files
committed
WIP: needs comparison, text styling, tooltip, partial periods
1 parent e643b00 commit cddaaaa

File tree

2 files changed

+462
-14
lines changed

2 files changed

+462
-14
lines changed
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import React, { ReactNode, useEffect, useRef } from 'react'
2+
import * as d3 from 'd3'
3+
import { UIMode, useTheme } from '../../theme-context'
4+
import {
5+
FormattableMetric,
6+
MetricFormatterShort
7+
} from '../reports/metric-formatter'
8+
import { DashboardPeriod } from '../../dashboard-time-periods'
9+
import dateFormatter from './date-formatter'
10+
11+
const height = 368
12+
const marginTop = 16
13+
const marginRight = 4
14+
const marginBottom = 32
15+
const marginLeft = 32
16+
17+
type ResultItem = {
18+
dimensions: [string] // one item
19+
metrics: null | [number] // one item
20+
comparison: unknown
21+
}
22+
type MainGraphResponse = {
23+
results: Array<ResultItem | null>
24+
meta: { time_labels: string[] }
25+
query: {
26+
interval: string
27+
date_range: string
28+
dimensions: [string] // one item
29+
metrics: [string] // one item
30+
}
31+
}
32+
type GraphDatum = { value: number; date: string }
33+
34+
type XPos = number
35+
type YPos = number
36+
type SeriesId = string
37+
type Point = [XPos, YPos, SeriesId]
38+
39+
type MainGraphData = MainGraphResponse & { period: DashboardPeriod }
40+
41+
export const MainGraph = ({
42+
width,
43+
data
44+
}: {
45+
width: number
46+
data: MainGraphData
47+
}) => {
48+
const { mode } = useTheme()
49+
const { primaryGradient } = paletteByTheme[mode]
50+
const svgRef = useRef<SVGSVGElement | null>(null)
51+
52+
useEffect(() => {
53+
if (!svgRef.current) {
54+
return
55+
}
56+
57+
const interval = data.query.dimensions[0].split('time:')[1]
58+
const period = data.period
59+
60+
const remappedData = remapToGraphData(data)
61+
62+
const minDate = remappedData[0].date
63+
const maxDate = remappedData[remappedData.length - 1].date
64+
65+
const hasMultipleYears = minDate.split('-')[0] !== maxDate.split('-')[0]
66+
67+
// Declare the x (horizontal position) scale.
68+
// It's a simple linear axis, one unit for every time bucket
69+
// because the BE is sending equal length buckets
70+
const x = d3.scaleLinear(
71+
[0, remappedData.length - 1],
72+
[marginLeft, width - marginRight]
73+
)
74+
75+
// Declare the y (vertical position) scale.
76+
const yMin = 0
77+
// TODO: find highest item during remapping
78+
const yMax = remappedData.reduce(
79+
(acc, current) =>
80+
current.value && current.value > acc ? current.value : acc,
81+
0
82+
)
83+
84+
const yDomain = yMax > yMin ? [yMin, yMax] : [yMin, yMin + 1]
85+
const y = d3.scaleLinear(yDomain, [height - marginBottom, marginTop]).nice()
86+
87+
const points: Point[] = remappedData.map((d, index) => [
88+
x(index),
89+
y(d.value),
90+
'v'
91+
])
92+
93+
const groups = d3.rollup(
94+
points,
95+
(point) => Object.assign(point, { z: point[0][2] }),
96+
(point) => point[2]
97+
)
98+
99+
// Create the SVG container.
100+
const svg = d3.select(svgRef.current)
101+
102+
// TODO: make dynamic
103+
const maxXTicks = 8
104+
const xTickCount =
105+
remappedData.length % 7 === 0
106+
? Math.min(7 + 1, maxXTicks)
107+
: Math.min(remappedData.length, maxXTicks)
108+
109+
// Add the x-axis.
110+
svg
111+
.append('g')
112+
.attr('transform', `translate(0,${height - marginBottom})`)
113+
.call(
114+
d3
115+
.axisBottom(x)
116+
.ticks(xTickCount)
117+
.tickSize(0)
118+
.tickFormat((index) =>
119+
getXLabel(remappedData[index.valueOf()].date, {
120+
shouldShowYear: hasMultipleYears,
121+
period,
122+
interval
123+
})
124+
)
125+
)
126+
.call((g) => g.select('.domain').remove())
127+
.call((g) => g.selectAll('.tick text').attr('class', tickClass))
128+
129+
// Add the y-axis, remove the domain line, add grid lines and a label.
130+
// TODO: make dynamic
131+
// const maxYTicks = 8
132+
const yTickCount = 8
133+
svg
134+
.append('g')
135+
.attr('transform', `translate(${marginLeft}, 0)`)
136+
.call(
137+
d3
138+
.axisLeft(y)
139+
.tickFormat((v) =>
140+
MetricFormatterShort[data.query.metrics[0] as FormattableMetric](v)
141+
)
142+
.ticks(yTickCount)
143+
.tickSize(0)
144+
)
145+
.call((g) => g.select('.domain').remove())
146+
.call((g) =>
147+
g
148+
.selectAll('.tick')
149+
.attr('class', 'tick group')
150+
.selectAll('.text')
151+
.attr('class', tickClass)
152+
)
153+
.call((g) =>
154+
g
155+
.selectAll('.tick line')
156+
.clone()
157+
.attr('x2', width - marginLeft - marginRight)
158+
.attr('class', tickLineClass)
159+
)
160+
161+
const addGradient = (): string => {
162+
// add gradient
163+
const id = 'areaGradient'
164+
const grad = svg
165+
.append('defs')
166+
.append('linearGradient')
167+
.attr('id', id)
168+
.attr('x1', '0%')
169+
.attr('y1', '0%') // top
170+
.attr('x2', '0%')
171+
.attr('y2', `100%`) // bottom
172+
173+
grad
174+
.append('stop')
175+
.attr('offset', '0%')
176+
.attr('stop-color', primaryGradient[0][0])
177+
.attr('stop-opacity', primaryGradient[0][1])
178+
179+
grad
180+
.append('stop')
181+
.attr('offset', '100%')
182+
.attr('stop-color', primaryGradient[1][0])
183+
.attr('stop-opacity', primaryGradient[1][1])
184+
return id
185+
}
186+
187+
const paintUnderLine = (
188+
gradientId: string,
189+
y1Accessor: (d: GraphDatum, index: number) => number
190+
) => {
191+
const area = d3
192+
.area<GraphDatum>()
193+
.x((_d, index) => x(index))
194+
.y0(height - marginBottom) // bottom edge
195+
.y1(y1Accessor) // top edge follows the data
196+
197+
// draw the filled area with the gradient
198+
svg
199+
.append('path')
200+
.datum(remappedData)
201+
.attr('fill', `url(#${gradientId})`)
202+
.attr('d', area)
203+
}
204+
205+
const drawLine = () => {
206+
const line = d3.line<Point>()
207+
208+
svg
209+
.append('g')
210+
.attr('fill', 'none')
211+
.attr('class', 'stroke-[#6366f1] stroke-2 z-1')
212+
.attr('stroke-linejoin', 'round')
213+
.attr('stroke-linecap', 'round')
214+
.selectAll('path')
215+
.data(groups.values())
216+
.join('path')
217+
.attr('d', line)
218+
}
219+
220+
const drawDot = () => {
221+
const dot = svg.append('g').attr('display', 'none')
222+
dot.append('circle').attr('r', 2.5).attr('class', 'fill-[#6366f1]')
223+
return dot
224+
}
225+
226+
const gradientId = addGradient()
227+
paintUnderLine(gradientId, (d) => y(d.value))
228+
drawLine()
229+
const dot = drawDot()
230+
231+
svg
232+
.on('pointermove', (event) => {
233+
const [xPointer] = d3.pointer(event)
234+
const closestIndexToPointer = d3
235+
.bisector((dataPoint: Point) => dataPoint[0])
236+
.center(points, xPointer)
237+
const [x, y, _k] = points[closestIndexToPointer]
238+
dot.attr('transform', `translate(${x},${y})`).attr('display', null)
239+
})
240+
.on('pointerleave', () => {
241+
dot.attr('display', 'none')
242+
})
243+
.on('touchstart', (event) => event.preventDefault())
244+
245+
return () => {
246+
svg.selectAll('*').remove()
247+
}
248+
}, [primaryGradient, width, data])
249+
250+
return (
251+
<div
252+
className="relative flex justify-center items-center w-full"
253+
style={{ height: height, maxWidth: width }}
254+
>
255+
<svg
256+
ref={svgRef}
257+
viewBox={`0 0 ${width} ${height}`}
258+
className="w-full h-auto"
259+
/>
260+
</div>
261+
)
262+
}
263+
264+
export const MainGraphContainer = React.forwardRef<
265+
HTMLDivElement,
266+
{ children: ReactNode }
267+
>((props, ref) => {
268+
return (
269+
<div className="relative my-4 h-92 w-full z-0" ref={ref}>
270+
{props.children}
271+
</div>
272+
)
273+
})
274+
275+
const getXLabel = (
276+
xValue: '__blank__' | string,
277+
{
278+
shouldShowYear,
279+
period,
280+
interval
281+
}: { shouldShowYear: boolean; interval: string; period: DashboardPeriod }
282+
) => {
283+
if (xValue == '__blank__') return ''
284+
285+
if (interval === 'hour' && period !== 'day') {
286+
const date = dateFormatter({
287+
interval: 'day',
288+
longForm: false,
289+
period: period,
290+
shouldShowYear,
291+
isPeriodFull: false
292+
})(xValue)
293+
294+
const hour = dateFormatter({
295+
interval: interval,
296+
longForm: false,
297+
period: period,
298+
shouldShowYear,
299+
isPeriodFull: false
300+
})(xValue)
301+
302+
// Returns a combination of date and hour. This is because
303+
// small intervals like hour may return multiple days
304+
// depending on the queried period.
305+
return `${date}, ${hour}`
306+
}
307+
308+
if (interval === 'minute' && period !== 'realtime') {
309+
return dateFormatter({
310+
interval: 'hour',
311+
longForm: false,
312+
period: period,
313+
isPeriodFull: false,
314+
shouldShowYear: false
315+
})(xValue)
316+
}
317+
318+
return dateFormatter({
319+
interval: interval,
320+
longForm: false,
321+
period: period,
322+
shouldShowYear,
323+
isPeriodFull: false
324+
})(xValue)
325+
}
326+
327+
const remapToGraphData = (data: MainGraphData): GraphDatum[] =>
328+
data.meta.time_labels.map((label, _i) => {
329+
const dataPoint = data.results.find((d) => d?.dimensions[0] === label)
330+
const value = (dataPoint?.metrics && dataPoint.metrics[0]) ?? null
331+
return fillMissingValue({
332+
value,
333+
date: label
334+
})
335+
})
336+
337+
function fillMissingValue<
338+
D extends { value: null | number | { value: number } }
339+
>(d: D): D & { value: number } {
340+
if (d.value === null) {
341+
return { ...d, value: 0 }
342+
}
343+
// Revenue metrics are returned as objects with a `value` property
344+
if (typeof d.value === 'object' && d.value.hasOwnProperty('value')) {
345+
return { ...d, value: d.value.value }
346+
}
347+
return d as D & { value: number }
348+
}
349+
350+
const paletteByTheme = {
351+
[UIMode.dark]: {
352+
primaryGradient: [
353+
['#4f46e5', 0.15],
354+
['#4f46e5', 0]
355+
],
356+
secondaryGradient: [
357+
['#4f46e5', 0.05],
358+
['#4f46e5', 0]
359+
]
360+
},
361+
[UIMode.light]: {
362+
primaryGradient: [
363+
['#4f46e5', 0.15],
364+
['#4f46e5', 0]
365+
],
366+
secondaryGradient: [
367+
['#4f46e5', 0.05],
368+
['#4f46e5', 0]
369+
]
370+
}
371+
} as const
372+
373+
const tickLineClass =
374+
'stroke-[#ececee] dark:stroke-[#27272a75] group-first:stroke-[#a1a1aa]'
375+
const tickClass = 'fill-currentColor dark:fill-[#a1a1aa]'

0 commit comments

Comments
 (0)