Skip to content

Instantly share code, notes, and snippets.

@coderdiaz
Created May 24, 2024 02:07
Show Gist options
  • Save coderdiaz/09b74826ebd0f436f6f6a846ba8b560c to your computer and use it in GitHub Desktop.
Save coderdiaz/09b74826ebd0f436f6f6a846ba8b560c to your computer and use it in GitHub Desktop.
An example of BiAxialLineChart using Tremor Raw LineChart
// An example of BiAxialLineChart
// using the Tremor Raw LineChart [v0.0.0]
//
// New props
// yRightCategory -> Category to show according to the yAxisRight
// yAxisRightLabel -> Label to be showed in yAxisRight
//
// Concerns:
// It's limited to only one category associated to yAxisRight if you want to support
// multiples categories only change it to array instead of single string.
"use client"
import React from "react"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import {
CartesianGrid,
Dot,
Label,
Line,
Legend as RechartsLegend,
LineChart as RechartsLineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { AxisDomain } from "recharts/types/util/types"
import {
AvailableChartColors,
AvailableChartColorsKeys,
constructCategoryColors,
getColorClassName,
getYAxisDomain,
hasOnlyOneValueForKey,
} from "@/lib/chartUtils"
import { useOnWindowResize } from "../../hooks/useOnWindowResize"
import { cn as cx } from "@/lib/utils"
//#region Legend
interface LegendItemProps {
name: string
color: AvailableChartColorsKeys
onClick?: (name: string, color: AvailableChartColorsKeys) => void
activeLegend?: string
}
const LegendItem = ({
name,
color,
onClick,
activeLegend,
}: LegendItemProps) => {
const hasOnValueChange = !!onClick
return (
<li
className={cx(
// base
"group inline-flex flex-nowrap items-center gap-1.5 whitespace-nowrap rounded px-2 py-1 transition",
hasOnValueChange
? "cursor-pointer hover:bg-accent-100"
: "cursor-default",
)}
onClick={(e) => {
e.stopPropagation()
onClick?.(name, color)
}}
>
<span
className={cx(
"h-2.5 w-2.5 shrink-0 rounded-full",
getColorClassName(color, "bg"),
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
aria-hidden={true}
/>
<p
className={cx(
// base
"truncate whitespace-nowrap text-xs",
// text color
"text-accent-500",
hasOnValueChange &&
"group-hover:text-accent-900",
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
>
{name}
</p>
</li>
)
}
interface ScrollButtonProps {
icon: React.ElementType
onClick?: () => void
disabled?: boolean
}
const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => {
const Icon = icon
const [isPressed, setIsPressed] = React.useState(false)
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
React.useEffect(() => {
if (isPressed) {
intervalRef.current = setInterval(() => {
onClick?.()
}, 300)
} else {
clearInterval(intervalRef.current as NodeJS.Timeout)
}
return () => clearInterval(intervalRef.current as NodeJS.Timeout)
}, [isPressed, onClick])
React.useEffect(() => {
if (disabled) {
clearInterval(intervalRef.current as NodeJS.Timeout)
setIsPressed(false)
}
}, [disabled])
return (
<button
type="button"
className={cx(
// base
"group inline-flex size-5 items-center truncate rounded transition",
disabled
? "cursor-not-allowed text-accent-400"
: "cursor-pointer text-accent-700 hover:bg-accent-100 hover:text-accent-900",
)}
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onClick?.()
}}
onMouseDown={(e) => {
e.stopPropagation()
setIsPressed(true)
}}
onMouseUp={(e) => {
e.stopPropagation()
setIsPressed(false)
}}
>
<Icon className="size-full" aria-hidden="true" />
</button>
)
}
interface LegendProps extends React.OlHTMLAttributes<HTMLOListElement> {
categories: string[]
colors?: AvailableChartColorsKeys[]
onClickLegendItem?: (category: string, color: string) => void
activeLegend?: string
enableLegendSlider?: boolean
}
type HasScrollProps = {
left: boolean
right: boolean
}
const Legend = React.forwardRef<HTMLOListElement, LegendProps>((props, ref) => {
const {
categories,
colors = AvailableChartColors,
className,
onClickLegendItem,
activeLegend,
enableLegendSlider = false,
...other
} = props
const scrollableRef = React.useRef<HTMLInputElement>(null)
const [hasScroll, setHasScroll] = React.useState<HasScrollProps | null>(null)
const [isKeyDowned, setIsKeyDowned] = React.useState<string | null>(null)
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
const checkScroll = React.useCallback(() => {
const scrollable = scrollableRef?.current
if (!scrollable) return
const hasLeftScroll = scrollable.scrollLeft > 0
const hasRightScroll =
scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft
setHasScroll({ left: hasLeftScroll, right: hasRightScroll })
}, [setHasScroll])
const scrollToTest = React.useCallback(
(direction: "left" | "right") => {
const element = scrollableRef?.current
const width = element?.clientWidth ?? 0
if (element && enableLegendSlider) {
element.scrollTo({
left:
direction === "left"
? element.scrollLeft - width
: element.scrollLeft + width,
behavior: "smooth",
})
setTimeout(() => {
checkScroll()
}, 400)
}
},
[enableLegendSlider, checkScroll],
)
React.useEffect(() => {
const keyDownHandler = (key: string) => {
if (key === "ArrowLeft") {
scrollToTest("left")
} else if (key === "ArrowRight") {
scrollToTest("right")
}
}
if (isKeyDowned) {
keyDownHandler(isKeyDowned)
intervalRef.current = setInterval(() => {
keyDownHandler(isKeyDowned)
}, 300)
} else {
clearInterval(intervalRef.current as NodeJS.Timeout)
}
return () => clearInterval(intervalRef.current as NodeJS.Timeout)
}, [isKeyDowned, scrollToTest])
const keyDown = (e: KeyboardEvent) => {
e.stopPropagation()
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault()
setIsKeyDowned(e.key)
}
}
const keyUp = (e: KeyboardEvent) => {
e.stopPropagation()
setIsKeyDowned(null)
}
React.useEffect(() => {
const scrollable = scrollableRef?.current
if (enableLegendSlider) {
checkScroll()
scrollable?.addEventListener("keydown", keyDown)
scrollable?.addEventListener("keyup", keyUp)
}
return () => {
scrollable?.removeEventListener("keydown", keyDown)
scrollable?.removeEventListener("keyup", keyUp)
}
}, [checkScroll, enableLegendSlider])
return (
<ol
ref={ref}
className={cx("relative overflow-hidden", className)}
{...other}
>
<div
ref={scrollableRef}
tabIndex={0}
className={cx(
"flex h-full",
enableLegendSlider
? hasScroll?.right || hasScroll?.left
? "snap-mandatory items-center overflow-auto pl-4 pr-12 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: ""
: "flex-wrap",
)}
>
{categories.map((category, index) => (
<LegendItem
key={`item-${index}`}
name={category}
color={colors[index] as AvailableChartColorsKeys}
onClick={onClickLegendItem}
activeLegend={activeLegend}
/>
))}
</div>
{enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
<>
<div
className={cx(
// base
"absolute bottom-0 right-0 top-0 flex h-full items-center justify-center pr-1",
// background color
"bg-accent-1100",
)}
>
<ScrollButton
icon={ArrowLeftIcon}
onClick={() => {
setIsKeyDowned(null)
scrollToTest("left")
}}
disabled={!hasScroll?.left}
/>
<ScrollButton
icon={ArrowRightIcon}
onClick={() => {
setIsKeyDowned(null)
scrollToTest("right")
}}
disabled={!hasScroll?.right}
/>
</div>
</>
) : null}
</ol>
)
})
Legend.displayName = "Legend"
const ChartLegend = (
{ payload }: any,
categoryColors: Map<string, AvailableChartColorsKeys>,
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
activeLegend: string | undefined,
onClick?: (category: string, color: string) => void,
enableLegendSlider?: boolean,
) => {
const legendRef = React.useRef<HTMLDivElement>(null)
useOnWindowResize(() => {
const calculateHeight = (height: number | undefined) =>
height ? Number(height) + 15 : 60
setLegendHeight(calculateHeight(legendRef.current?.clientHeight))
})
const filteredPayload = payload.filter((item: any) => item.type !== "none")
return (
<div ref={legendRef} className="flex items-center justify-end">
<Legend
categories={filteredPayload.map((entry: any) => entry.value)}
colors={filteredPayload.map((entry: any) =>
categoryColors.get(entry.value),
)}
onClickLegendItem={onClick}
activeLegend={activeLegend}
enableLegendSlider={enableLegendSlider}
/>
</div>
)
}
//#region Tooltip
interface ChartTooltipRowProps {
value: string
name: string
color: string
}
const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => (
<div className="flex items-center justify-between space-x-8">
<div className="flex items-center space-x-2">
<span
aria-hidden="true"
className={cx("h-[3px] w-3.5 shrink-0 rounded-full", color)}
/>
<p
className={cx(
// commmon
"whitespace-nowrap text-right",
// text color
"text-accent-500",
)}
>
{name}
</p>
</div>
<p
className={cx(
// base
"whitespace-nowrap text-right font-medium tabular-nums",
// text color
"text-accent-400",
)}
>
{value}
</p>
</div>
)
interface ChartTooltipProps {
active: boolean | undefined
payload: any
label: string
categoryColors: Map<string, string>
valueFormatter: (value: number) => string
}
const ChartTooltip = ({
active,
payload,
label,
categoryColors,
valueFormatter,
}: ChartTooltipProps) => {
if (active && payload) {
const filteredPayload = payload.filter((item: any) => item.type !== "none")
return (
<div
className={cx(
// base
"rounded-md border text-sm shadow-md",
// border color
"border-accent-1100",
// background color
"bg-accent-1200",
)}
>
<div
className={cx(
// base
"border-b border-accent-1000 px-4 py-2",
)}
>
<p
className={cx(
// base
"font-medium",
// text color
"text-accent-500",
)}
>
{label}
</p>
</div>
<div className={cx("space-y-1 px-4 py-2")}>
{filteredPayload.map(
(
{ value, name }: { value: number; name: string },
index: number,
) => (
<ChartTooltipRow
key={`id-${index}`}
value={valueFormatter(value)}
name={name}
color={getColorClassName(
categoryColors.get(name) as AvailableChartColorsKeys,
"bg",
)}
/>
),
)}
</div>
</div>
)
}
return null
}
//#region LineChart
interface ActiveDot {
index?: number
dataKey?: string
}
type BaseEventProps = {
eventType: "dot" | "category"
categoryClicked: string
[key: string]: number | string
}
type LineChartEventProps = BaseEventProps | null | undefined
export interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: Record<string, any>[]
index: string
categories: string[]
colors?: AvailableChartColorsKeys[]
valueFormatter?: (value: number) => string
startEndOnly?: boolean
showXAxis?: boolean
showYAxis?: boolean
showGridLines?: boolean
yAxisWidth?: number
intervalType?: "preserveStartEnd" | "equidistantPreserveStart"
showTooltip?: boolean
showLegend?: boolean
autoMinValue?: boolean
minValue?: number
maxValue?: number
allowDecimals?: boolean
onValueChange?: (value: LineChartEventProps) => void
enableLegendSlider?: boolean
tickGap?: number
connectNulls?: boolean
xAxisLabel?: string
yAxisLabel?: string
yRightCategory?: string
yAxisRightLabel?: string
}
const BiAxialLineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
(props, ref) => {
const {
data = [],
categories = [],
index,
colors = AvailableChartColors,
valueFormatter = (value: number) => value.toString(),
startEndOnly = false,
showXAxis = true,
showYAxis = true,
showGridLines = true,
yAxisWidth = 56,
intervalType = "equidistantPreserveStart",
showTooltip = true,
showLegend = true,
autoMinValue = false,
minValue,
maxValue,
allowDecimals = true,
connectNulls = false,
className,
onValueChange,
enableLegendSlider = false,
tickGap = 5,
xAxisLabel,
yAxisLabel,
yRightCategory,
yAxisRightLabel,
...other
} = props
const paddingValue = !showXAxis && !showYAxis ? 0 : 20
const [legendHeight, setLegendHeight] = React.useState(60)
const [activeDot, setActiveDot] = React.useState<ActiveDot | undefined>(
undefined,
)
const [activeLegend, setActiveLegend] = React.useState<string | undefined>(
undefined,
)
const categoryColors = constructCategoryColors(categories, colors)
const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue)
const hasOnValueChange = !!onValueChange
function onDotClick(itemData: any, event: React.MouseEvent) {
event.stopPropagation()
if (!hasOnValueChange) return
if (
(itemData.index === activeDot?.index &&
itemData.dataKey === activeDot?.dataKey) ||
(hasOnlyOneValueForKey(data, itemData.dataKey) &&
activeLegend &&
activeLegend === itemData.dataKey)
) {
setActiveLegend(undefined)
setActiveDot(undefined)
onValueChange?.(null)
} else {
setActiveLegend(itemData.dataKey)
setActiveDot({
index: itemData.index,
dataKey: itemData.dataKey,
})
onValueChange?.({
eventType: "dot",
categoryClicked: itemData.dataKey,
...itemData.payload,
})
}
}
function onCategoryClick(dataKey: string) {
if (!hasOnValueChange) return
if (
(dataKey === activeLegend && !activeDot) ||
(hasOnlyOneValueForKey(data, dataKey) &&
activeDot &&
activeDot.dataKey === dataKey)
) {
setActiveLegend(undefined)
onValueChange?.(null)
} else {
setActiveLegend(dataKey)
onValueChange?.({
eventType: "category",
categoryClicked: dataKey,
})
}
setActiveDot(undefined)
}
return (
<div ref={ref} className={cx("h-80 w-full", className)} {...other}>
<ResponsiveContainer>
<RechartsLineChart
data={data}
onClick={
hasOnValueChange && (activeLegend || activeDot)
? () => {
setActiveDot(undefined)
setActiveLegend(undefined)
onValueChange?.(null)
}
: undefined
}
margin={{
bottom: xAxisLabel ? 30 : undefined,
left: yAxisLabel ? 0 : undefined,
right: yAxisLabel ? 5 : undefined,
top: 5,
}}
>
{showGridLines ? (
<CartesianGrid
className={cx("stroke-accent-1000 stroke-1")}
horizontal={true}
vertical={false}
/>
) : null}
<XAxis
padding={{ left: paddingValue, right: paddingValue }}
hide={!showXAxis}
dataKey={index}
interval={startEndOnly ? "preserveStartEnd" : intervalType}
tick={{ transform: "translate(0, 6)" }}
ticks={
startEndOnly
? [data[0][index], data[data.length - 1][index]]
: undefined
}
fill=""
stroke=""
className={cx(
// base
"text-xs",
// text fill
"fill-accent-700",
)}
tickLine={false}
axisLine={false}
minTickGap={tickGap}
>
{xAxisLabel && (
<Label
position="insideBottom"
offset={-20}
className="fill-accent-800 text-sm font-medium"
>
{xAxisLabel}
</Label>
)}
</XAxis>
<YAxis
width={yAxisWidth}
hide={!showYAxis}
axisLine={false}
tickLine={false}
yAxisId="left"
type="number"
domain={yAxisDomain as AxisDomain}
tick={{ transform: "translate(-3, 0)" }}
fill=""
stroke=""
className={cx(
// base
"text-xs",
// text fill
"fill-accent-700",
)}
tickFormatter={valueFormatter}
allowDecimals={allowDecimals}
>
{yAxisLabel && (
<Label
position="insideLeft"
style={{ textAnchor: "middle" }}
angle={-90}
offset={6}
className="fill-accent-800 text-sm font-medium"
>
{yAxisLabel}
</Label>
)}
</YAxis>
{yRightCategory && (
<YAxis
yAxisId="right"
orientation="right"
width={yAxisWidth}
hide={!showYAxis}
axisLine={false}
tickLine={false}
type="number"
domain={yAxisDomain as AxisDomain}
tick={{ transform: "translate(-3, 0)" }}
fill=""
stroke=""
className={cx(
// base
"text-xs",
// text fill
"fill-accent-700",
)}
tickFormatter={valueFormatter}
allowDecimals={allowDecimals}
>
{yAxisRightLabel && (
<Label
position="insideRight"
style={{ textAnchor: "middle" }}
angle={90}
offset={4}
className="fill-accent-800 text-sm font-medium"
>
{yAxisRightLabel}
</Label>
)}
</YAxis>
)}
<Tooltip
wrapperStyle={{ outline: "none" }}
isAnimationActive={true}
animationDuration={100}
cursor={{ stroke: "#4A2D2F", strokeWidth: 1 }}
offset={20}
position={{ y: 0 }}
content={
showTooltip ? (
({ active, payload, label }) => (
<ChartTooltip
active={active}
payload={payload}
label={label}
valueFormatter={valueFormatter}
categoryColors={categoryColors}
/>
)
) : (
<></>
)
}
/>
{showLegend ? (
<RechartsLegend
verticalAlign="top"
height={legendHeight}
content={({ payload }) =>
ChartLegend(
{ payload },
categoryColors,
setLegendHeight,
activeLegend,
hasOnValueChange
? (clickedLegendItem: string) =>
onCategoryClick(clickedLegendItem)
: undefined,
enableLegendSlider,
)
}
/>
) : null}
{categories.map((category) => (
<Line
className={cx(
getColorClassName(
categoryColors.get(category) as AvailableChartColorsKeys,
"stroke",
),
)}
strokeOpacity={
activeDot || (activeLegend && activeLegend !== category)
? 0.3
: 1
}
activeDot={(props: any) => {
const {
cx: cxCoord,
cy: cyCoord,
stroke,
strokeLinecap,
strokeLinejoin,
strokeWidth,
dataKey,
} = props
return (
<Dot
className={cx(
"stroke-accent-1200",
onValueChange ? "cursor-pointer" : "",
getColorClassName(
categoryColors.get(
dataKey,
) as AvailableChartColorsKeys,
"fill",
),
)}
cx={cxCoord}
cy={cyCoord}
r={5}
fill=""
stroke={stroke}
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
strokeWidth={strokeWidth}
onClick={(_, event) => onDotClick(props, event)}
/>
)
}}
dot={(props: any) => {
const {
stroke,
strokeLinecap,
strokeLinejoin,
strokeWidth,
cx: cxCoord,
cy: cyCoord,
dataKey,
index,
} = props
if (
(hasOnlyOneValueForKey(data, category) &&
!(
activeDot ||
(activeLegend && activeLegend !== category)
)) ||
(activeDot?.index === index &&
activeDot?.dataKey === category)
) {
return (
<Dot
key={index}
cx={cxCoord}
cy={cyCoord}
r={5}
stroke={stroke}
fill=""
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
strokeWidth={strokeWidth}
className={cx(
"stroke-accent-1200",
onValueChange ? "cursor-pointer" : "",
getColorClassName(
categoryColors.get(
dataKey,
) as AvailableChartColorsKeys,
"fill",
),
)}
/>
)
}
return <React.Fragment key={index}></React.Fragment>
}}
key={category}
name={category}
type="linear"
{...(category === yRightCategory ? { yAxisId: "right" } : {yAxisId: "left"})}
dataKey={category}
stroke=""
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
isAnimationActive={false}
connectNulls={connectNulls}
/>
))}
{/* hidden lines to increase clickable target area */}
{onValueChange
? categories.map((category) => (
<Line
className={cx("cursor-pointer")}
strokeOpacity={0}
key={category}
name={category}
type="linear"
{...(category === yRightCategory ? { yAxisId: "right" } : {yAxisId: "left"})}
dataKey={category}
stroke="transparent"
fill="transparent"
legendType="none"
tooltipType="none"
strokeWidth={12}
connectNulls={connectNulls}
onClick={(props: any, event) => {
event.stopPropagation()
const { name } = props
onCategoryClick(name)
}}
/>
))
: null}
</RechartsLineChart>
</ResponsiveContainer>
</div>
)
},
)
BiAxialLineChart.displayName = "BiAxialLineChart"
export { BiAxialLineChart, type LineChartEventProps }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment