Last active
February 12, 2023 08:05
-
-
Save makarovas/d101affc101cca8061f13a809a50fc4b to your computer and use it in GitHub Desktop.
react-chartjs-2 component
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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