Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save makarovas/d101affc101cca8061f13a809a50fc4b to your computer and use it in GitHub Desktop.
Save makarovas/d101affc101cca8061f13a809a50fc4b to your computer and use it in GitHub Desktop.
react-chartjs-2 component
import {Response as TotalUsersResponse} from ''
import {Response as WeeklyNumberOfSignupsByCountryResponse} from ''
import {Response as WeeklySignupsResponse} from ''
import {BarElement, CategoryScale, Chart as ChartJS, ChartData, LinearScale, Tooltip} from 'chart.js'
import ChartDataLabels from 'chartjs-plugin-datalabels'
import cn from 'classnames'
import {useOutsideAlerter} from 'common/hooks/useClickOutside'
import {Icon} from 'common/Icon'
import dayjs from 'dayjs'
import getConfig from 'next/config'
import * as R from 'ramda'
import React, {memo, useEffect, useMemo, useRef, useState} from 'react'
import {Bar, getElementAtEvent} from 'react-chartjs-2'
import {useQuery} from 'react-query'
const {publicRuntimeConfig} = getConfig()
const compactNumberFormatter = Intl.NumberFormat('en', {notation: 'compact'})
const allowedCountries = ['AR', 'CL', 'IN', 'KE', 'PT', 'ES', 'UG'] as const
type CountryCode = typeof allowedCountries[number]
const countryToColor = (country: CountryCode) => {
switch (country) {
case 'AR':
return '#E7AB78'
case 'CL':
return '#649CDD'
case 'IN':
return '#E7AB78'
case 'KE':
return '#EFC64A'
case 'PT':
return '#E1918E'
case 'ES':
return '#94BD5D'
case 'UG':
return '#515289'
default:
return ''
}
}
export type QueryResultRow = {
Createdat: string
FocusCountryOrRoW: string
Count: number
}
export type DatasetType = {
label: string
data: number[]
backgroundColor?: string
hoverBackgroundColor?: string
}
const groupByCountry = (dates: string[]) => (result: DatasetType[], row: QueryResultRow) => {
const dataset = result.find((item) => item.label === row.FocusCountryOrRoW)
const dataIndex = dates.findIndex((item) => dayjs(row.Createdat).isSame(item))
if (dataset) {
dataset.data[dataIndex] = row.Count
} else {
const data = new Array(dates.length).fill(0)
data[dataIndex] = row.Count
const newDataset = {
label: row.FocusCountryOrRoW,
data,
hoverBackgroundColor: countryToColor(row.FocusCountryOrRoW as CountryCode),
}
result.push(newDataset)
}
return result
}
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
export type BarDataItem = {value?: number; label?: string; color?: string}
const customCanvasBackgroundColorPlugin = {
id: 'customCanvasBackgroundColor',
beforeDraw: (chart: ChartJS, _: any, options: any) => {
const {ctx} = chart
ctx.save()
ctx.globalCompositeOperation = 'destination-over'
ctx.fillStyle = options.color
ctx.fillRect(0, 0, chart.width, chart.height)
ctx.restore()
},
}
export const GraphComponent = memo(function Graph(props: {clasName?: string}) {
const chartRef = useRef<ChartJS<'bar'>>(null)
const [data, setData] = useState<ChartData<'bar'>>({labels: [], datasets: []})
const [hovering, setHovering] = useState<number>()
const wrapperRef = useRef(null)
const {data: totalSignupsData} = useQuery<TotalUsersResponse>('totalSignupsData', () =>
fetch(`${publicRuntimeConfig.NEXT_PUBLIC_APP_URL}`).then((res) => res.json()),
)
const {data: weeklyNumberOfSignupsByCountry} = useQuery<WeeklyNumberOfSignupsByCountryResponse>(
'weeklyNumberOfSignupsByCountry',
() =>
fetch(`${publicRuntimeConfig.NEXT_PUBLIC_APP_URL}`).then((res) =>
res.json(),
),
)
const totalCount = useMemo(() => totalSignupsData?.payload?.count, [totalSignupsData?.payload?.count])
const {data: weeklySignupsData} = useQuery<WeeklySignupsResponse>('weeklySignupsData', () =>
fetch(`${publicRuntimeConfig.NEXT_PUBLIC_APP_URL}`).then((res) => res.json()),
)
const activeWeek = typeof hovering === 'number' ? data?.labels?.[hovering] : null
const countByWeek = useMemo(
() =>
typeof activeWeek === 'string'
? weeklySignupsData?.payload
?.find((item) => dayjs(item.Createdat).isSame(activeWeek, 'month'))
?.Count.toLocaleString('en')
: null,
[weeklySignupsData?.payload, activeWeek],
)
const dataFromQuery = useMemo(
() =>
(weeklyNumberOfSignupsByCountry?.payload ?? []).filter(
(row) =>
typeof row.FocusCountryOrRoW === 'string' && allowedCountries.includes(row.FocusCountryOrRoW as CountryCode),
),
[weeklyNumberOfSignupsByCountry?.payload],
)
const dates = useMemo(() => {
const datesAsString: string[] = []
dataFromQuery.forEach((row) => {
if (!datesAsString.includes(row.Createdat)) {
datesAsString.push(row.Createdat)
}
})
return datesAsString
.map((item) => dayjs(item))
.sort((dateOne, dateTwo) => (dayjs(dateOne).isAfter(dayjs(dateTwo)) ? 1 : -1))
.map((item) => item.format())
}, [dataFromQuery])
const datasets = useMemo(() => dataFromQuery.reduce(groupByCountry(dates), []), [dataFromQuery, dates])
useOutsideAlerter(wrapperRef, () => {
setHovering(undefined)
})
// for changing backgroundColor of not hovered items
useEffect(() => {
setData({
labels: dates,
datasets: datasets.map((value) => ({
...value,
backgroundColor: typeof hovering === 'number' ? '#D0D5DD' : '#000000',
borderRadius: 40,
})),
})
}, [datasets, dates, hovering])
const onClick = (event: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
if (chartRef.current) {
const bar = getElementAtEvent(chartRef.current, event)[0]
setHovering(bar?.index)
}
}
const [tooltip, setTooltip] = useState({
opacity: 0,
left: 0,
values: [] as Array<BarDataItem>,
})
return (
<div className={cn(props.className)}>
<div ref={wrapperRef} className="relative max-w-[1000px]">
<Bar
plugins={[customCanvasBackgroundColorPlugin, ChartDataLabels]}
options={{
layout: {
padding: 20,
},
plugins: {
legend: {
display: false,
},
datalabels: {
anchor: 'end',
align: 'top',
offset: 0,
formatter: (_value, context) => {
const datasetArray: number[] = []
context.chart.data.datasets.forEach((dataset) => {
if (typeof dataset.data[context.dataIndex] !== 'undefined') {
datasetArray.push(dataset.data[context.dataIndex] as number)
}
})
function totalSum(total: number, dataPoint: number) {
return total + dataPoint
}
let sum = datasetArray.reduce(totalSum, 0)
if (context.datasetIndex === datasetArray.length - 1) {
return compactNumberFormatter.format(sum)
}
return ''
},
font: {
family: 'Rubik, sans-serif',
size: 11,
weight: 'bold',
},
color: dates
.map(() => '#000000')
.map((item, index) => {
if (typeof hovering !== 'number' || index === hovering) {
return item
}
return '#D0D5DD'
}),
},
customCanvasBackgroundColor: {
color: '#F2F2F2',
},
tooltip: {
mode: 'index',
enabled: false,
position: 'nearest',
external: (context) => {
const {chart} = context
const tooltipModel = context.tooltip
if (!chart || !chartRef.current) {
return
}
if (tooltipModel.opacity === 0) {
if (tooltip.opacity !== 0) {
setTooltip((prev) => ({...prev, opacity: 0}))
}
return
}
const position = context.chart.canvas.getBoundingClientRect()
const newTooltipData = {
opacity: 1,
left: position.left + tooltipModel.caretX + 30,
values: tooltipModel.dataPoints.map((item) => ({
label: item.dataset.label,
value: item.raw as number,
color: item.dataset.hoverBackgroundColor as string,
})),
}
if (!R.equals(tooltip, newTooltipData)) {
setTooltip(newTooltipData)
}
},
},
},
datasets: {
bar: {
barThickness: 24,
barPercentage: 1,
},
},
responsive: true,
scales: {
x: {
ticks: {
callback: function (tickValue: number, index) {
return index % 4 === 0 ? dayjs(this.getLabelForValue(tickValue)).format("MMM d, YY'") : ''
},
autoSkip: false,
maxRotation: 0,
minRotation: 0,
color: '#000000',
font: {
family: 'Rubik, sans-serif',
weight: '600',
},
},
grid: {
display: false,
},
stacked: true,
},
y: {
grid: {
display: false,
},
stacked: true,
display: false,
},
},
hover: {
mode: 'index',
},
}}
data={data}
ref={chartRef}
onClick={onClick}
/>
</div>
<div
className="absolute pointer-events-none bg-white p-3 rounded-lg shadow-md"
style={{top: '30%', left: tooltip.left, opacity: tooltip.opacity}}
>
<div className="relative">
<p className="text-sm font-semibold text-[#183C4A] font-sora">Per Country</p>
<div className="mt-3 flex flex-col gap-2 font-rubik">
{tooltip.values.map((item) => (
<div key={item.label} className="flex items-center justify-between text-xs gap-8">
<div className="flex items-center gap-2">
<div style={{backgroundColor: item.color}} className="w-2 h-2 rounded" />
{item.label}
</div>
<div className="font-semibold flex items-center text-[#183C4A] gap-[2px]">
{item?.value?.toLocaleString('en').replace(/,/g, '.') || 0}
<Icon name="user" className="w-4 h-4 border-solid border-2 border-[#183C4A]" />
</div>
</div>
))}
</div>
<Icon
name="tooltip-arrow"
className="absolute h-6 w-4 rotate-180 top-0 bottom-0 my-auto mx-0 -left-5 text-white"
/>
</div>
</div>
<div className="flex flex-col gap-3">
<p className="font-sora font-semibold text-[160px] leading-[160px]">
<>
{activeWeek && countByWeek}
{!activeWeek && typeof totalCount === 'number' && totalCount.toLocaleString('en')}
{!activeWeek && typeof totalCount !== 'number' && 'Loading...'}
</>
</p>
<p className="text-667085 uppercase font-rubik font-medium">
{typeof activeWeek === 'string' ? (
<>
UNIQUE people onboarded{' '}
<span className="text-183c4a">week of {dayjs(activeWeek).format('MMM d, YYYY')}</span>
</>
) : (
'Unique people onboarded'
)}
</p>
</div>
</div>
)
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment