[WEB-3697] chore: chart components (#6835)
This commit is contained in:
parent
869c755065
commit
471fefce8b
14 changed files with 688 additions and 237 deletions
|
|
@ -1,2 +1,2 @@
|
||||||
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||||
export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
|
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, ComposedChart, CartesianGrid } from "recharts";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||||
import { TAreaChartProps } from "@plane/types";
|
import { TAreaChartProps } from "@plane/types";
|
||||||
// local components
|
// local components
|
||||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
import { getLegendProps } from "../components/legend";
|
||||||
import { CustomTooltip } from "../tooltip";
|
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||||
|
import { CustomTooltip } from "../components/tooltip";
|
||||||
|
|
||||||
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
|
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -16,107 +16,174 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
|
||||||
areas,
|
areas,
|
||||||
xAxis,
|
xAxis,
|
||||||
yAxis,
|
yAxis,
|
||||||
className = "w-full h-96",
|
className,
|
||||||
|
legend,
|
||||||
|
margin,
|
||||||
tickCount = {
|
tickCount = {
|
||||||
x: undefined,
|
x: undefined,
|
||||||
y: 10,
|
y: 10,
|
||||||
},
|
},
|
||||||
showTooltip = true,
|
showTooltip = true,
|
||||||
|
comparisonLine,
|
||||||
} = props;
|
} = props;
|
||||||
|
// states
|
||||||
|
const [activeArea, setActiveArea] = useState<string | null>(null);
|
||||||
|
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||||
// derived values
|
// derived values
|
||||||
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
|
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
|
||||||
const itemDotClassNames = useMemo(
|
const itemLabels: Record<string, string> = useMemo(
|
||||||
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
|
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}),
|
||||||
[areas]
|
[areas]
|
||||||
);
|
);
|
||||||
|
const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]);
|
||||||
|
|
||||||
const renderAreas = useMemo(
|
const renderAreas = useMemo(
|
||||||
() =>
|
() =>
|
||||||
areas.map((area) => (
|
areas.map((area) => (
|
||||||
<Area
|
<Area
|
||||||
key={area.key}
|
key={area.key}
|
||||||
type="monotone"
|
type={area.smoothCurves ? "monotone" : "linear"}
|
||||||
dataKey={area.key}
|
dataKey={area.key}
|
||||||
stackId={area.stackId}
|
stackId={area.stackId}
|
||||||
className={area.className}
|
fill={area.fill}
|
||||||
stroke="inherit"
|
opacity={!!activeLegend && activeLegend !== area.key ? 0.1 : 1}
|
||||||
fill="inherit"
|
fillOpacity={area.fillOpacity}
|
||||||
|
strokeOpacity={area.strokeOpacity}
|
||||||
|
stroke={area.strokeColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
style={area.style}
|
||||||
|
dot={
|
||||||
|
area.showDot
|
||||||
|
? {
|
||||||
|
fill: area.fill,
|
||||||
|
fillOpacity: 1,
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
activeDot={{
|
||||||
|
stroke: area.fill,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveArea(area.key)}
|
||||||
|
onMouseLeave={() => setActiveArea(null)}
|
||||||
|
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
[areas]
|
[activeLegend, areas]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// create comparison line data for straight line from origin to last point
|
||||||
|
const comparisonLineData = useMemo(() => {
|
||||||
|
if (!data || data.length === 0) return [];
|
||||||
|
// get the last data point
|
||||||
|
const lastPoint = data[data.length - 1];
|
||||||
|
// for the y-value in the last point, use its yAxis key value
|
||||||
|
const lastYValue = lastPoint[yAxis.key] || 0;
|
||||||
|
// create data for a straight line that has points at each x-axis position
|
||||||
|
return data.map((item, index) => {
|
||||||
|
// calculate the y value for this point on the straight line
|
||||||
|
// using linear interpolation between (0,0) and (last_x, last_y)
|
||||||
|
const ratio = index / (data.length - 1);
|
||||||
|
const interpolatedValue = ratio * lastYValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
[xAxis.key]: item[xAxis.key],
|
||||||
|
comparisonLine: interpolatedValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [data, xAxis.key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<CoreAreaChart
|
<ComposedChart
|
||||||
width={500}
|
|
||||||
height={300}
|
|
||||||
data={data}
|
data={data}
|
||||||
margin={{
|
margin={{
|
||||||
top: 5,
|
top: margin?.top === undefined ? 5 : margin.top,
|
||||||
right: 30,
|
right: margin?.right === undefined ? 30 : margin.right,
|
||||||
left: 20,
|
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||||
bottom: 5,
|
left: margin?.left === undefined ? 20 : margin.left,
|
||||||
}}
|
}}
|
||||||
reverseStackOrder
|
|
||||||
>
|
>
|
||||||
|
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey={xAxis.key}
|
dataKey={xAxis.key}
|
||||||
tick={(props) => <CustomXAxisTick {...props} />}
|
tick={(props) => <CustomXAxisTick {...props} />}
|
||||||
tickLine={{
|
tickLine={false}
|
||||||
stroke: "currentColor",
|
axisLine={false}
|
||||||
className: AXIS_LINE_CLASSNAME,
|
label={
|
||||||
}}
|
xAxis.label && {
|
||||||
axisLine={{
|
value: xAxis.label,
|
||||||
stroke: "currentColor",
|
dy: 28,
|
||||||
className: AXIS_LINE_CLASSNAME,
|
className: AXIS_LABEL_CLASSNAME,
|
||||||
}}
|
}
|
||||||
label={{
|
}
|
||||||
value: xAxis.label,
|
|
||||||
dy: 28,
|
|
||||||
className: LABEL_CLASSNAME,
|
|
||||||
}}
|
|
||||||
tickCount={tickCount.x}
|
tickCount={tickCount.x}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
domain={yAxis.domain}
|
domain={yAxis.domain}
|
||||||
tickLine={{
|
tickLine={false}
|
||||||
stroke: "currentColor",
|
axisLine={false}
|
||||||
className: AXIS_LINE_CLASSNAME,
|
label={
|
||||||
}}
|
yAxis.label && {
|
||||||
axisLine={{
|
value: yAxis.label,
|
||||||
stroke: "currentColor",
|
angle: -90,
|
||||||
className: AXIS_LINE_CLASSNAME,
|
position: "bottom",
|
||||||
}}
|
offset: -24,
|
||||||
label={{
|
dx: -16,
|
||||||
value: yAxis.label,
|
className: AXIS_LABEL_CLASSNAME,
|
||||||
angle: -90,
|
}
|
||||||
position: "bottom",
|
}
|
||||||
offset: -24,
|
|
||||||
dx: -16,
|
|
||||||
className: LABEL_CLASSNAME,
|
|
||||||
}}
|
|
||||||
tick={(props) => <CustomYAxisTick {...props} />}
|
tick={(props) => <CustomYAxisTick {...props} />}
|
||||||
tickCount={tickCount.y}
|
tickCount={tickCount.y}
|
||||||
allowDecimals={!!yAxis.allowDecimals}
|
allowDecimals={!!yAxis.allowDecimals}
|
||||||
/>
|
/>
|
||||||
|
{legend && (
|
||||||
|
// @ts-expect-error recharts types are not up to date
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => itemLabels[value]}
|
||||||
|
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||||
|
onMouseLeave={() => setActiveLegend(null)}
|
||||||
|
{...getLegendProps(legend)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
cursor={{
|
||||||
|
stroke: "rgba(var(--color-text-300))",
|
||||||
|
strokeDasharray: "4 4",
|
||||||
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
content={({ active, label, payload }) => (
|
content={({ active, label, payload }) => (
|
||||||
<CustomTooltip
|
<CustomTooltip
|
||||||
active={active}
|
active={active}
|
||||||
|
activeKey={activeArea}
|
||||||
label={label}
|
label={label}
|
||||||
payload={payload}
|
payload={payload}
|
||||||
itemKeys={itemKeys}
|
itemKeys={itemKeys}
|
||||||
itemDotClassNames={itemDotClassNames}
|
itemLabels={itemLabels}
|
||||||
|
itemDotColors={itemDotColors}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{renderAreas}
|
{renderAreas}
|
||||||
</CoreAreaChart>
|
{comparisonLine && (
|
||||||
|
<Line
|
||||||
|
data={comparisonLineData}
|
||||||
|
type="linear"
|
||||||
|
dataKey="comparisonLine"
|
||||||
|
stroke={comparisonLine.strokeColor}
|
||||||
|
fill={comparisonLine.strokeColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray={comparisonLine.dashedLine ? "4 4" : "none"}
|
||||||
|
activeDot={false}
|
||||||
|
legendType="none"
|
||||||
|
name="Comparison line"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,25 @@ const calculatePercentage = <K extends string, T extends string>(
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
|
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
|
||||||
const BAR_BORDER_RADIUS = 2; // Border radius for each bar
|
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar
|
||||||
|
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
|
||||||
|
|
||||||
export const CustomBar = React.memo((props: any) => {
|
export const CustomBar = React.memo((props: any) => {
|
||||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
const {
|
||||||
|
opacity,
|
||||||
|
fill,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dataKey,
|
||||||
|
stackKeys,
|
||||||
|
payload,
|
||||||
|
textClassName,
|
||||||
|
showPercentage,
|
||||||
|
showTopBorderRadius,
|
||||||
|
showBottomBorderRadius,
|
||||||
|
} = props;
|
||||||
// Calculate text position
|
// Calculate text position
|
||||||
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
||||||
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
|
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
|
||||||
|
|
@ -34,24 +49,28 @@ export const CustomBar = React.memo((props: any) => {
|
||||||
// bar percentage is a number
|
// bar percentage is a number
|
||||||
!Number.isNaN(currentBarPercentage);
|
!Number.isNaN(currentBarPercentage);
|
||||||
|
|
||||||
|
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
|
||||||
|
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
|
||||||
|
|
||||||
if (!height) return null;
|
if (!height) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<path
|
<path
|
||||||
d={`
|
d={`
|
||||||
M${x + BAR_BORDER_RADIUS},${y + height}
|
M${x},${y + topBorderRadius}
|
||||||
L${x + BAR_BORDER_RADIUS},${y}
|
Q${x},${y} ${x + topBorderRadius},${y}
|
||||||
Q${x},${y} ${x},${y + BAR_BORDER_RADIUS}
|
L${x + width - topBorderRadius},${y}
|
||||||
L${x},${y + height - BAR_BORDER_RADIUS}
|
Q${x + width},${y} ${x + width},${y + topBorderRadius}
|
||||||
Q${x},${y + height} ${x + BAR_BORDER_RADIUS},${y + height}
|
L${x + width},${y + height - bottomBorderRadius}
|
||||||
L${x + width - BAR_BORDER_RADIUS},${y + height}
|
Q${x + width},${y + height} ${x + width - bottomBorderRadius},${y + height}
|
||||||
Q${x + width},${y + height} ${x + width},${y + height - BAR_BORDER_RADIUS}
|
L${x + bottomBorderRadius},${y + height}
|
||||||
L${x + width},${y + BAR_BORDER_RADIUS}
|
Q${x},${y + height} ${x},${y + height - bottomBorderRadius}
|
||||||
Q${x + width},${y} ${x + width - BAR_BORDER_RADIUS},${y}
|
Z
|
||||||
L${x + BAR_BORDER_RADIUS},${y}
|
`}
|
||||||
`}
|
className="transition-opacity duration-200"
|
||||||
className={cn("transition-colors duration-200", fill)}
|
fill={fill}
|
||||||
fill="currentColor"
|
opacity={opacity}
|
||||||
/>
|
/>
|
||||||
{showText && (
|
{showText && (
|
||||||
<text
|
<text
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import {
|
||||||
|
BarChart as CoreBarChart,
|
||||||
|
Bar,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Legend,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||||
import { TBarChartProps } from "@plane/types";
|
import { TBarChartProps } from "@plane/types";
|
||||||
// local components
|
// local components
|
||||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
import { getLegendProps } from "../components/legend";
|
||||||
import { CustomTooltip } from "../tooltip";
|
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||||
|
import { CustomTooltip } from "../components/tooltip";
|
||||||
import { CustomBar } from "./bar";
|
import { CustomBar } from "./bar";
|
||||||
|
|
||||||
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
||||||
|
|
@ -18,19 +28,25 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||||
xAxis,
|
xAxis,
|
||||||
yAxis,
|
yAxis,
|
||||||
barSize = 40,
|
barSize = 40,
|
||||||
className = "w-full h-96",
|
className,
|
||||||
|
legend,
|
||||||
|
margin,
|
||||||
tickCount = {
|
tickCount = {
|
||||||
x: undefined,
|
x: undefined,
|
||||||
y: 10,
|
y: 10,
|
||||||
},
|
},
|
||||||
showTooltip = true,
|
showTooltip = true,
|
||||||
} = props;
|
} = props;
|
||||||
|
// states
|
||||||
|
const [activeBar, setActiveBar] = useState<string | null>(null);
|
||||||
|
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||||
// derived values
|
// derived values
|
||||||
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
|
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
|
||||||
const stackDotClassNames = useMemo(
|
const stackLabels: Record<string, string> = useMemo(
|
||||||
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
|
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}),
|
||||||
[bars]
|
[bars]
|
||||||
);
|
);
|
||||||
|
const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]);
|
||||||
|
|
||||||
const renderBars = useMemo(
|
const renderBars = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -39,18 +55,29 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||||
key={bar.key}
|
key={bar.key}
|
||||||
dataKey={bar.key}
|
dataKey={bar.key}
|
||||||
stackId={bar.stackId}
|
stackId={bar.stackId}
|
||||||
fill={bar.fillClassName}
|
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
|
||||||
shape={(shapeProps: any) => (
|
fill={bar.fill}
|
||||||
<CustomBar
|
shape={(shapeProps: any) => {
|
||||||
{...shapeProps}
|
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||||
stackKeys={stackKeys}
|
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||||
textClassName={bar.textClassName}
|
|
||||||
showPercentage={bar.showPercentage}
|
return (
|
||||||
/>
|
<CustomBar
|
||||||
)}
|
{...shapeProps}
|
||||||
|
stackKeys={stackKeys}
|
||||||
|
textClassName={bar.textClassName}
|
||||||
|
showPercentage={bar.showPercentage}
|
||||||
|
showTopBorderRadius={!!showTopBorderRadius}
|
||||||
|
showBottomBorderRadius={!!showBottomBorderRadius}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||||
|
onMouseEnter={() => setActiveBar(bar.key)}
|
||||||
|
onMouseLeave={() => setActiveBar(null)}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
[stackKeys, bars]
|
[activeLegend, stackKeys, bars]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -58,60 +85,71 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<CoreBarChart
|
<CoreBarChart
|
||||||
data={data}
|
data={data}
|
||||||
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
margin={{
|
||||||
|
top: margin?.top === undefined ? 5 : margin.top,
|
||||||
|
right: margin?.right === undefined ? 30 : margin.right,
|
||||||
|
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||||
|
left: margin?.left === undefined ? 20 : margin.left,
|
||||||
|
}}
|
||||||
barSize={barSize}
|
barSize={barSize}
|
||||||
className="recharts-wrapper"
|
className="recharts-wrapper"
|
||||||
>
|
>
|
||||||
|
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey={xAxis.key}
|
dataKey={xAxis.key}
|
||||||
tick={(props) => <CustomXAxisTick {...props} />}
|
tick={(props) => <CustomXAxisTick {...props} />}
|
||||||
tickLine={{
|
tickLine={false}
|
||||||
stroke: "currentColor",
|
axisLine={false}
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
axisLine={{
|
|
||||||
stroke: "currentColor",
|
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
label={{
|
label={{
|
||||||
value: xAxis.label,
|
value: xAxis.label,
|
||||||
dy: 28,
|
dy: 28,
|
||||||
className: LABEL_CLASSNAME,
|
className: AXIS_LABEL_CLASSNAME,
|
||||||
}}
|
}}
|
||||||
tickCount={tickCount.x}
|
tickCount={tickCount.x}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
domain={yAxis.domain}
|
domain={yAxis.domain}
|
||||||
tickLine={{
|
tickLine={false}
|
||||||
stroke: "currentColor",
|
axisLine={false}
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
axisLine={{
|
|
||||||
stroke: "currentColor",
|
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
label={{
|
label={{
|
||||||
value: yAxis.label,
|
value: yAxis.label,
|
||||||
angle: -90,
|
angle: -90,
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
offset: -24,
|
offset: -24,
|
||||||
dx: -16,
|
dx: -16,
|
||||||
className: LABEL_CLASSNAME,
|
className: AXIS_LABEL_CLASSNAME,
|
||||||
}}
|
}}
|
||||||
tick={(props) => <CustomYAxisTick {...props} />}
|
tick={(props) => <CustomYAxisTick {...props} />}
|
||||||
tickCount={tickCount.y}
|
tickCount={tickCount.y}
|
||||||
allowDecimals={!!yAxis.allowDecimals}
|
allowDecimals={!!yAxis.allowDecimals}
|
||||||
/>
|
/>
|
||||||
|
{legend && (
|
||||||
|
// @ts-expect-error recharts types are not up to date
|
||||||
|
<Legend
|
||||||
|
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||||
|
onMouseLeave={() => setActiveLegend(null)}
|
||||||
|
formatter={(value) => stackLabels[value]}
|
||||||
|
{...getLegendProps(legend)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
cursor={{
|
||||||
|
fill: "currentColor",
|
||||||
|
className: "text-custom-background-90/80 cursor-pointer",
|
||||||
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
content={({ active, label, payload }) => (
|
content={({ active, label, payload }) => (
|
||||||
<CustomTooltip
|
<CustomTooltip
|
||||||
active={active}
|
active={active}
|
||||||
label={label}
|
label={label}
|
||||||
payload={payload}
|
payload={payload}
|
||||||
|
activeKey={activeBar}
|
||||||
itemKeys={stackKeys}
|
itemKeys={stackKeys}
|
||||||
itemDotClassNames={stackDotClassNames}
|
itemLabels={stackLabels}
|
||||||
|
itemDotColors={stackDotColors}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
77
packages/propel/src/charts/components/legend.tsx
Normal file
77
packages/propel/src/charts/components/legend.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React from "react";
|
||||||
|
import { LegendProps } from "recharts";
|
||||||
|
// plane imports
|
||||||
|
import { TChartLegend } from "@plane/types";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
|
|
||||||
|
export const getLegendProps = (args: TChartLegend): LegendProps => {
|
||||||
|
const { align, layout, verticalAlign } = args;
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
align,
|
||||||
|
verticalAlign,
|
||||||
|
wrapperStyle: {
|
||||||
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
|
...(layout === "vertical"
|
||||||
|
? {
|
||||||
|
top: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
content: <CustomLegend {...args} />,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomLegend = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> &
|
||||||
|
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
|
||||||
|
TChartLegend
|
||||||
|
>((props, ref) => {
|
||||||
|
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;
|
||||||
|
|
||||||
|
if (!payload?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center px-4 overflow-scroll vertical-scrollbar scrollbar-sm", {
|
||||||
|
"max-h-full flex-col items-start py-4": layout === "vertical",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{payload.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn("flex items-center gap-1.5 text-custom-text-300 text-sm font-medium whitespace-nowrap", {
|
||||||
|
"px-2": layout === "horizontal",
|
||||||
|
"py-2": layout === "vertical",
|
||||||
|
"pl-0 pt-0": index === 0,
|
||||||
|
"pr-0 pb-0": index === payload.length - 1,
|
||||||
|
"cursor-pointer": !!props.onClick,
|
||||||
|
})}
|
||||||
|
onClick={(e) => onClick?.(item, index, e)}
|
||||||
|
onMouseEnter={(e) => onMouseEnter?.(item, index, e)}
|
||||||
|
onMouseLeave={(e) => onMouseLeave?.(item, index, e)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 size-2 rounded-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* @ts-expect-error recharts types are not up to date */}
|
||||||
|
{formatter?.(item.value, { value: item.value }, index) ?? item.payload?.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CustomLegend.displayName = "CustomLegend";
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// Common classnames
|
// Common classnames
|
||||||
const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize";
|
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
|
||||||
|
|
||||||
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
|
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
|
||||||
<g transform={`translate(${x},${y})`}>
|
<g transform={`translate(${x},${y})`}>
|
||||||
60
packages/propel/src/charts/components/tooltip.tsx
Normal file
60
packages/propel/src/charts/components/tooltip.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import React from "react";
|
||||||
|
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
// plane imports
|
||||||
|
import { Card, ECardSpacing } from "@plane/ui";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active: boolean | undefined;
|
||||||
|
activeKey?: string | null;
|
||||||
|
label: string | undefined;
|
||||||
|
payload: Payload<ValueType, NameType>[] | undefined;
|
||||||
|
itemKeys: string[];
|
||||||
|
itemLabels: Record<string, string>;
|
||||||
|
itemDotColors: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomTooltip = React.memo((props: Props) => {
|
||||||
|
const { active, activeKey, label, payload, itemKeys, itemLabels, itemDotColors } = props;
|
||||||
|
// derived values
|
||||||
|
const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`));
|
||||||
|
|
||||||
|
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="flex flex-col max-h-[40vh] w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||||
|
spacing={ECardSpacing.SM}
|
||||||
|
>
|
||||||
|
<p className="flex-shrink-0 text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 truncate">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{filteredPayload.map((item) => {
|
||||||
|
if (!item.dataKey) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item?.dataKey}
|
||||||
|
className={cn("flex items-center gap-2 text-xs transition-opacity", {
|
||||||
|
"opacity-20": activeKey && item.dataKey !== activeKey,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
{itemDotColors[item?.dataKey] && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 size-2 rounded-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: itemDotColors[item?.dataKey],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-custom-text-300 truncate">{itemLabels[item?.dataKey]}:</span>
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0 font-medium text-custom-text-200">{item?.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CustomTooltip.displayName = "CustomTooltip";
|
||||||
|
|
@ -1,107 +1,154 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
LineChart as CoreLineChart,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||||
import { TLineChartProps } from "@plane/types";
|
import { TLineChartProps } from "@plane/types";
|
||||||
// local components
|
// local components
|
||||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
import { getLegendProps } from "../components/legend";
|
||||||
import { CustomTooltip } from "../tooltip";
|
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||||
|
import { CustomTooltip } from "../components/tooltip";
|
||||||
|
|
||||||
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
|
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
lines,
|
lines,
|
||||||
|
margin,
|
||||||
xAxis,
|
xAxis,
|
||||||
yAxis,
|
yAxis,
|
||||||
className = "w-full h-96",
|
className,
|
||||||
tickCount = {
|
tickCount = {
|
||||||
x: undefined,
|
x: undefined,
|
||||||
y: 10,
|
y: 10,
|
||||||
},
|
},
|
||||||
|
legend,
|
||||||
showTooltip = true,
|
showTooltip = true,
|
||||||
} = props;
|
} = props;
|
||||||
|
// states
|
||||||
|
const [activeLine, setActiveLine] = useState<string | null>(null);
|
||||||
|
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||||
// derived values
|
// derived values
|
||||||
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
|
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
|
||||||
const itemDotClassNames = useMemo(
|
const itemLabels: Record<string, string> = useMemo(
|
||||||
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}),
|
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}),
|
||||||
[lines]
|
[lines]
|
||||||
);
|
);
|
||||||
|
const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]);
|
||||||
|
|
||||||
const renderLines = useMemo(
|
const renderLines = useMemo(
|
||||||
() =>
|
() =>
|
||||||
lines.map((line) => (
|
lines.map((line) => (
|
||||||
<Line key={line.key} dataKey={line.key} type="monotone" className={line.className} stroke="inherit" />
|
<Line
|
||||||
|
key={line.key}
|
||||||
|
dataKey={line.key}
|
||||||
|
type={line.smoothCurves ? "monotone" : "linear"}
|
||||||
|
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||||
|
opacity={!!activeLegend && activeLegend !== line.key ? 0.1 : 1}
|
||||||
|
fill={line.fill}
|
||||||
|
stroke={line.stroke}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray={line.dashedLine ? "4 4" : "none"}
|
||||||
|
dot={
|
||||||
|
line.showDot
|
||||||
|
? {
|
||||||
|
fill: line.fill,
|
||||||
|
fillOpacity: 1,
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
activeDot={{
|
||||||
|
stroke: line.fill,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveLine(line.key)}
|
||||||
|
onMouseLeave={() => setActiveLine(null)}
|
||||||
|
/>
|
||||||
)),
|
)),
|
||||||
[lines]
|
[activeLegend, lines]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<CoreLineChart
|
<CoreLineChart
|
||||||
width={500}
|
|
||||||
height={300}
|
|
||||||
data={data}
|
data={data}
|
||||||
margin={{
|
margin={{
|
||||||
top: 5,
|
top: margin?.top === undefined ? 5 : margin.top,
|
||||||
right: 30,
|
right: margin?.right === undefined ? 30 : margin.right,
|
||||||
left: 20,
|
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||||
bottom: 5,
|
left: margin?.left === undefined ? 20 : margin.left,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey={xAxis.key}
|
dataKey={xAxis.key}
|
||||||
tick={(props) => <CustomXAxisTick {...props} />}
|
tick={(props) => <CustomXAxisTick {...props} />}
|
||||||
tickLine={{
|
tickLine={false}
|
||||||
stroke: "currentColor",
|
axisLine={false}
|
||||||
className: AXIS_LINE_CLASSNAME,
|
label={
|
||||||
}}
|
xAxis.label && {
|
||||||
axisLine={{
|
value: xAxis.label,
|
||||||
stroke: "currentColor",
|
dy: 28,
|
||||||
className: AXIS_LINE_CLASSNAME,
|
className: AXIS_LABEL_CLASSNAME,
|
||||||
}}
|
}
|
||||||
label={{
|
}
|
||||||
value: xAxis.label,
|
|
||||||
dy: 28,
|
|
||||||
className: LABEL_CLASSNAME,
|
|
||||||
}}
|
|
||||||
tickCount={tickCount.x}
|
tickCount={tickCount.x}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
domain={yAxis.domain}
|
domain={yAxis.domain}
|
||||||
tickLine={{
|
tickLine={false}
|
||||||
stroke: "currentColor",
|
axisLine={false}
|
||||||
className: AXIS_LINE_CLASSNAME,
|
label={
|
||||||
}}
|
yAxis.label && {
|
||||||
axisLine={{
|
value: yAxis.label,
|
||||||
stroke: "currentColor",
|
angle: -90,
|
||||||
className: AXIS_LINE_CLASSNAME,
|
position: "bottom",
|
||||||
}}
|
offset: -24,
|
||||||
label={{
|
dx: -16,
|
||||||
value: yAxis.label,
|
className: AXIS_LABEL_CLASSNAME,
|
||||||
angle: -90,
|
}
|
||||||
position: "bottom",
|
}
|
||||||
offset: -24,
|
|
||||||
dx: -16,
|
|
||||||
className: LABEL_CLASSNAME,
|
|
||||||
}}
|
|
||||||
tick={(props) => <CustomYAxisTick {...props} />}
|
tick={(props) => <CustomYAxisTick {...props} />}
|
||||||
tickCount={tickCount.y}
|
tickCount={tickCount.y}
|
||||||
allowDecimals={!!yAxis.allowDecimals}
|
allowDecimals={!!yAxis.allowDecimals}
|
||||||
/>
|
/>
|
||||||
|
{legend && (
|
||||||
|
// @ts-expect-error recharts types are not up to date
|
||||||
|
<Legend
|
||||||
|
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||||
|
onMouseLeave={() => setActiveLegend(null)}
|
||||||
|
formatter={(value) => itemLabels[value]}
|
||||||
|
{...getLegendProps(legend)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
cursor={{
|
||||||
|
stroke: "rgba(var(--color-text-300))",
|
||||||
|
strokeDasharray: "4 4",
|
||||||
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
content={({ active, label, payload }) => (
|
content={({ active, label, payload }) => (
|
||||||
<CustomTooltip
|
<CustomTooltip
|
||||||
active={active}
|
active={active}
|
||||||
|
activeKey={activeLine}
|
||||||
label={label}
|
label={label}
|
||||||
payload={payload}
|
payload={payload}
|
||||||
itemKeys={itemKeys}
|
itemKeys={itemKeys}
|
||||||
itemDotClassNames={itemDotClassNames}
|
itemLabels={itemLabels}
|
||||||
|
itemDotColors={itemDotColors}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
31
packages/propel/src/charts/pie-chart/active-shape.tsx
Normal file
31
packages/propel/src/charts/pie-chart/active-shape.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Sector } from "recharts";
|
||||||
|
|
||||||
|
export const CustomActiveShape = React.memo((props: any) => {
|
||||||
|
const { cx, cy, cornerRadius, innerRadius, outerRadius, startAngle, endAngle, fill } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
cornerRadius={cornerRadius}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
cornerRadius={cornerRadius}
|
||||||
|
innerRadius={outerRadius + 6}
|
||||||
|
outerRadius={outerRadius + 10}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,45 +1,145 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
import { Cell, PieChart as CorePieChart, Label, Legend, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { TPieChartProps } from "@plane/types";
|
import { TPieChartProps } from "@plane/types";
|
||||||
// local components
|
// local components
|
||||||
|
import { getLegendProps } from "../components/legend";
|
||||||
|
import { CustomActiveShape } from "./active-shape";
|
||||||
import { CustomPieChartTooltip } from "./tooltip";
|
import { CustomPieChartTooltip } from "./tooltip";
|
||||||
|
|
||||||
export const PieChart = React.memo(<K extends string, T extends string>(props: TPieChartProps<K, T>) => {
|
export const PieChart = React.memo(<K extends string, T extends string>(props: TPieChartProps<K, T>) => {
|
||||||
const { data, dataKey, cells, className = "w-full h-96", innerRadius, outerRadius, showTooltip = true } = props;
|
const {
|
||||||
|
data,
|
||||||
|
dataKey,
|
||||||
|
cells,
|
||||||
|
className,
|
||||||
|
innerRadius,
|
||||||
|
legend,
|
||||||
|
margin,
|
||||||
|
outerRadius,
|
||||||
|
showTooltip = true,
|
||||||
|
showLabel,
|
||||||
|
customLabel,
|
||||||
|
centerLabel,
|
||||||
|
cornerRadius,
|
||||||
|
paddingAngle,
|
||||||
|
tooltipLabel,
|
||||||
|
} = props;
|
||||||
|
// states
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||||
|
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||||
|
|
||||||
const renderCells = useMemo(
|
const renderCells = useMemo(
|
||||||
() => cells.map((cell) => <Cell key={cell.key} className={cell.className} style={cell.style} />),
|
() =>
|
||||||
[cells]
|
cells.map((cell, index) => (
|
||||||
|
<Cell
|
||||||
|
key={cell.key}
|
||||||
|
className="transition-opacity duration-200"
|
||||||
|
fill={cell.fill}
|
||||||
|
opacity={!!activeLegend && activeLegend !== cell.key ? 0.1 : 1}
|
||||||
|
style={{
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveIndex(index)}
|
||||||
|
onMouseLeave={() => setActiveIndex(null)}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
[activeLegend, cells]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<CorePieChart
|
<CorePieChart
|
||||||
width={500}
|
|
||||||
height={300}
|
|
||||||
data={data}
|
data={data}
|
||||||
margin={{
|
margin={{
|
||||||
top: 5,
|
top: margin?.top === undefined ? 5 : margin.top,
|
||||||
right: 30,
|
right: margin?.right === undefined ? 30 : margin.right,
|
||||||
left: 20,
|
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||||
bottom: 5,
|
left: margin?.left === undefined ? 20 : margin.left,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pie data={data} dataKey={dataKey} cx="50%" cy="50%" innerRadius={innerRadius} outerRadius={outerRadius}>
|
<Pie
|
||||||
|
activeIndex={activeIndex === null ? undefined : activeIndex}
|
||||||
|
onMouseLeave={() => setActiveIndex(null)}
|
||||||
|
data={data}
|
||||||
|
dataKey={dataKey}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
blendStroke
|
||||||
|
activeShape={<CustomActiveShape />}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
cornerRadius={cornerRadius}
|
||||||
|
paddingAngle={paddingAngle}
|
||||||
|
labelLine={false}
|
||||||
|
label={
|
||||||
|
showLabel
|
||||||
|
? ({ payload, ...props }) => (
|
||||||
|
<text
|
||||||
|
className="text-sm font-medium transition-opacity duration-200"
|
||||||
|
cx={props.cx}
|
||||||
|
cy={props.cy}
|
||||||
|
x={props.x}
|
||||||
|
y={props.y}
|
||||||
|
textAnchor={props.textAnchor}
|
||||||
|
dominantBaseline={props.dominantBaseline}
|
||||||
|
fill="rgba(var(--color-text-200))"
|
||||||
|
opacity={!!activeLegend && activeLegend !== payload.key ? 0.1 : 1}
|
||||||
|
>
|
||||||
|
{customLabel?.(payload.count) ?? payload.count}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{renderCells}
|
{renderCells}
|
||||||
|
{centerLabel && (
|
||||||
|
<Label
|
||||||
|
value={centerLabel.text}
|
||||||
|
fill={centerLabel.fill}
|
||||||
|
position="center"
|
||||||
|
opacity={activeLegend ? 0.1 : 1}
|
||||||
|
style={centerLabel.style}
|
||||||
|
className={centerLabel.className}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Pie>
|
</Pie>
|
||||||
|
{legend && (
|
||||||
|
// @ts-expect-error recharts types are not up to date
|
||||||
|
<Legend
|
||||||
|
onMouseEnter={(payload) => {
|
||||||
|
// @ts-expect-error recharts types are not up to date
|
||||||
|
const key: string | undefined = payload.payload?.key;
|
||||||
|
if (!key) return;
|
||||||
|
setActiveLegend(key);
|
||||||
|
setActiveIndex(null);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setActiveLegend(null)}
|
||||||
|
{...getLegendProps(legend)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
cursor={{
|
||||||
|
fill: "currentColor",
|
||||||
|
className: "text-custom-background-90/80 cursor-pointer",
|
||||||
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
content={({ active, payload }) => {
|
content={({ active, payload }) => {
|
||||||
if (!active || !payload || !payload.length) return null;
|
if (!active || !payload || !payload.length) return null;
|
||||||
const cellData = cells.find((c) => c.key === payload[0].name);
|
const cellData = cells.find((c) => c.key === payload[0].payload.key);
|
||||||
if (!cellData) return null;
|
if (!cellData) return null;
|
||||||
return <CustomPieChartTooltip dotClassName={cellData.dotClassName} label={dataKey} payload={payload} />;
|
const label = tooltipLabel
|
||||||
|
? typeof tooltipLabel === "function"
|
||||||
|
? tooltipLabel(payload[0]?.payload?.payload)
|
||||||
|
: tooltipLabel
|
||||||
|
: dataKey;
|
||||||
|
return <CustomPieChartTooltip dotColor={cellData.fill} label={label} payload={payload} />;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,36 @@ import React from "react";
|
||||||
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { Card, ECardSpacing } from "@plane/ui";
|
import { Card, ECardSpacing } from "@plane/ui";
|
||||||
import { cn } from "@plane/utils";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
dotClassName?: string;
|
dotColor?: string;
|
||||||
label: string;
|
label: string;
|
||||||
payload: Payload<ValueType, NameType>[];
|
payload: Payload<ValueType, NameType>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomPieChartTooltip = React.memo((props: Props) => {
|
export const CustomPieChartTooltip = React.memo((props: Props) => {
|
||||||
const { dotClassName, label, payload } = props;
|
const { dotColor, label, payload } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
<Card
|
||||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
className="flex flex-col max-h-[40vh] w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||||
|
spacing={ECardSpacing.SM}
|
||||||
|
>
|
||||||
|
<p className="flex-shrink-0 text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 truncate">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
{payload?.map((item) => (
|
{payload?.map((item) => (
|
||||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||||
<div className={cn("size-2 rounded-full", dotClassName)} />
|
<div className="flex items-center gap-2 truncate">
|
||||||
<span className="text-custom-text-300">{item?.name}:</span>
|
<div
|
||||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
className="flex-shrink-0 size-2 rounded-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: dotColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-custom-text-300 truncate">{item?.name}:</span>
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0 font-medium text-custom-text-200">{item?.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
|
||||||
// plane imports
|
|
||||||
import { Card, ECardSpacing } from "@plane/ui";
|
|
||||||
import { cn } from "@plane/utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
active: boolean | undefined;
|
|
||||||
label: string | undefined;
|
|
||||||
payload: Payload<ValueType, NameType>[] | undefined;
|
|
||||||
itemKeys: string[];
|
|
||||||
itemDotClassNames: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CustomTooltip = React.memo((props: Props) => {
|
|
||||||
const { active, label, payload, itemKeys, itemDotClassNames } = props;
|
|
||||||
// derived values
|
|
||||||
const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`));
|
|
||||||
|
|
||||||
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
|
||||||
return (
|
|
||||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
|
||||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
{filteredPayload.map((item) => {
|
|
||||||
if (!item.dataKey) return null;
|
|
||||||
return (
|
|
||||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
|
||||||
{itemDotClassNames[item?.dataKey] && (
|
|
||||||
<div className={cn("size-2 rounded-full", itemDotClassNames[item?.dataKey])} />
|
|
||||||
)}
|
|
||||||
<span className="text-custom-text-300">{item?.name}:</span>
|
|
||||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
CustomTooltip.displayName = "CustomTooltip";
|
|
||||||
|
|
@ -31,6 +31,9 @@ export const TreeMapChart = React.memo((props: TreeMapChartProps) => {
|
||||||
fill: "currentColor",
|
fill: "currentColor",
|
||||||
className: "text-custom-background-90/80 cursor-pointer",
|
className: "text-custom-background-90/80 cursor-pointer",
|
||||||
}}
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Treemap>
|
</Treemap>
|
||||||
|
|
|
||||||
73
packages/types/src/charts.d.ts
vendored
73
packages/types/src/charts.d.ts
vendored
|
|
@ -1,3 +1,16 @@
|
||||||
|
export type TChartLegend = {
|
||||||
|
align: "left" | "center" | "right";
|
||||||
|
verticalAlign: "top" | "middle" | "bottom";
|
||||||
|
layout: "horizontal" | "vertical";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TChartMargin = {
|
||||||
|
top?: number;
|
||||||
|
right?: number;
|
||||||
|
bottom?: number;
|
||||||
|
left?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TChartData<K extends string, T extends string> = {
|
export type TChartData<K extends string, T extends string> = {
|
||||||
// required key
|
// required key
|
||||||
[key in K]: string | number;
|
[key in K]: string | number;
|
||||||
|
|
@ -7,15 +20,19 @@ type TChartProps<K extends string, T extends string> = {
|
||||||
data: TChartData<K, T>[];
|
data: TChartData<K, T>[];
|
||||||
xAxis: {
|
xAxis: {
|
||||||
key: keyof TChartData<K, T>;
|
key: keyof TChartData<K, T>;
|
||||||
label: string;
|
label?: string;
|
||||||
|
strokeColor?: string;
|
||||||
};
|
};
|
||||||
yAxis: {
|
yAxis: {
|
||||||
key: keyof TChartData<K, T>;
|
|
||||||
label: string;
|
|
||||||
domain?: [number, number];
|
|
||||||
allowDecimals?: boolean;
|
allowDecimals?: boolean;
|
||||||
|
domain?: [number, number];
|
||||||
|
key: keyof TChartData<K, T>;
|
||||||
|
label?: string;
|
||||||
|
strokeColor?: string;
|
||||||
};
|
};
|
||||||
className?: string;
|
className?: string;
|
||||||
|
legend?: TChartLegend;
|
||||||
|
margin?: TChartMargin;
|
||||||
tickCount?: {
|
tickCount?: {
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
|
|
@ -25,11 +42,13 @@ type TChartProps<K extends string, T extends string> = {
|
||||||
|
|
||||||
export type TBarItem<T extends string> = {
|
export type TBarItem<T extends string> = {
|
||||||
key: T;
|
key: T;
|
||||||
fillClassName: string;
|
label: string;
|
||||||
|
fill: string;
|
||||||
textClassName: string;
|
textClassName: string;
|
||||||
dotClassName?: string;
|
|
||||||
showPercentage?: boolean;
|
showPercentage?: boolean;
|
||||||
stackId: string;
|
stackId: string;
|
||||||
|
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
|
||||||
|
showBottomBorderRadius?: (barKey: string, payload: any) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||||
|
|
@ -39,9 +58,13 @@ export type TBarChartProps<K extends string, T extends string> = TChartProps<K,
|
||||||
|
|
||||||
export type TLineItem<T extends string> = {
|
export type TLineItem<T extends string> = {
|
||||||
key: T;
|
key: T;
|
||||||
className?: string;
|
label: string;
|
||||||
|
dashedLine: boolean;
|
||||||
|
fill: string;
|
||||||
|
showDot: boolean;
|
||||||
|
smoothCurves: boolean;
|
||||||
|
stroke: string;
|
||||||
style?: Record<string, string | number>;
|
style?: Record<string, string | number>;
|
||||||
dotClassName?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TLineChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
export type TLineChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||||
|
|
@ -50,31 +73,49 @@ export type TLineChartProps<K extends string, T extends string> = TChartProps<K,
|
||||||
|
|
||||||
export type TAreaItem<T extends string> = {
|
export type TAreaItem<T extends string> = {
|
||||||
key: T;
|
key: T;
|
||||||
|
label: string;
|
||||||
stackId: string;
|
stackId: string;
|
||||||
className?: string;
|
fill: string;
|
||||||
|
fillOpacity: number;
|
||||||
|
showDot: boolean;
|
||||||
|
smoothCurves: boolean;
|
||||||
|
strokeColor: string;
|
||||||
|
strokeOpacity: number;
|
||||||
style?: Record<string, string | number>;
|
style?: Record<string, string | number>;
|
||||||
dotClassName?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAreaChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
export type TAreaChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||||
areas: TAreaItem<T>[];
|
areas: TAreaItem<T>[];
|
||||||
|
comparisonLine?: {
|
||||||
|
dashedLine: boolean;
|
||||||
|
strokeColor: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCellItem<T extends string> = {
|
export type TCellItem<T extends string> = {
|
||||||
key: T;
|
key: T;
|
||||||
className?: string;
|
fill: string;
|
||||||
style?: Record<string, string | number>;
|
|
||||||
dotClassName?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TPieChartProps<K extends string, T extends string> = Pick<
|
export type TPieChartProps<K extends string, T extends string> = Pick<
|
||||||
TChartProps<K, T>,
|
TChartProps<K, T>,
|
||||||
"className" | "data" | "showTooltip"
|
"className" | "data" | "showTooltip" | "legend" | "margin"
|
||||||
> & {
|
> & {
|
||||||
dataKey: T;
|
dataKey: T;
|
||||||
cells: TCellItem<T>[];
|
cells: TCellItem<T>[];
|
||||||
innerRadius?: number;
|
innerRadius?: number | string;
|
||||||
outerRadius?: number;
|
outerRadius?: number | string;
|
||||||
|
cornerRadius?: number;
|
||||||
|
paddingAngle?: number;
|
||||||
|
showLabel: boolean;
|
||||||
|
customLabel?: (value: any) => string;
|
||||||
|
centerLabel?: {
|
||||||
|
className?: string;
|
||||||
|
fill: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
text?: string | number;
|
||||||
|
};
|
||||||
|
tooltipLabel?: string | ((payload: any) => string);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TreeMapItem = {
|
export type TreeMapItem = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue