[WEB-3329] dev: new chart components (#6565)
* dev: new chart components * chore: separate out pie chart tooltip * chore: remove unused any types * chore: move chart components to propel package
This commit is contained in:
parent
1eb1e82fe4
commit
ce57c1423c
32 changed files with 679 additions and 409 deletions
1
packages/propel/src/charts/area-chart/index.ts
Normal file
1
packages/propel/src/charts/area-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
124
packages/propel/src/charts/area-chart/root.tsx
Normal file
124
packages/propel/src/charts/area-chart/root.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TAreaChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
|
||||
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
areas,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
|
||||
const itemDotClassNames = useMemo(
|
||||
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
|
||||
[areas]
|
||||
);
|
||||
|
||||
const renderAreas = useMemo(
|
||||
() =>
|
||||
areas.map((area) => (
|
||||
<Area
|
||||
key={area.key}
|
||||
type="monotone"
|
||||
dataKey={area.key}
|
||||
stackId={area.stackId}
|
||||
className={area.className}
|
||||
stroke="inherit"
|
||||
fill="inherit"
|
||||
/>
|
||||
)),
|
||||
[areas]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreAreaChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
reverseStackOrder
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemDotClassNames={itemDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderAreas}
|
||||
</CoreAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AreaChart.displayName = "AreaChart";
|
||||
70
packages/propel/src/charts/bar-chart/bar.tsx
Normal file
70
packages/propel/src/charts/bar-chart/bar.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { TChartData } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// Helper to calculate percentage
|
||||
const calculatePercentage = <K extends string, T extends string>(
|
||||
data: TChartData<K, T>,
|
||||
stackKeys: T[],
|
||||
currentKey: T
|
||||
): number => {
|
||||
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
|
||||
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
export const CustomBar = React.memo((props: any) => {
|
||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
||||
// Calculate text position
|
||||
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
|
||||
// derived values
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
const showText =
|
||||
// from props
|
||||
showPercentage &&
|
||||
// height of the bar is greater than or equal to the minimum height required to show the text
|
||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
|
||||
// bar percentage text has some value
|
||||
currentBarPercentage !== undefined &&
|
||||
// bar percentage is a number
|
||||
!Number.isNaN(currentBarPercentage);
|
||||
|
||||
if (!height) return null;
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${x + BAR_BORDER_RADIUS},${y + height}
|
||||
L${x + BAR_BORDER_RADIUS},${y}
|
||||
Q${x},${y} ${x},${y + BAR_BORDER_RADIUS}
|
||||
L${x},${y + height - BAR_BORDER_RADIUS}
|
||||
Q${x},${y + height} ${x + BAR_BORDER_RADIUS},${y + height}
|
||||
L${x + width - BAR_BORDER_RADIUS},${y + height}
|
||||
Q${x + width},${y + height} ${x + width},${y + height - BAR_BORDER_RADIUS}
|
||||
L${x + width},${y + BAR_BORDER_RADIUS}
|
||||
Q${x + width},${y} ${x + width - BAR_BORDER_RADIUS},${y}
|
||||
L${x + BAR_BORDER_RADIUS},${y}
|
||||
`}
|
||||
className={cn("transition-colors duration-200", fill)}
|
||||
fill="currentColor"
|
||||
/>
|
||||
{showText && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={textY}
|
||||
textAnchor="middle"
|
||||
className={cn("text-xs font-medium", textClassName)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{currentBarPercentage}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
CustomBar.displayName = "CustomBar";
|
||||
1
packages/propel/src/charts/bar-chart/index.ts
Normal file
1
packages/propel/src/charts/bar-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
125
packages/propel/src/charts/bar-chart/root.tsx
Normal file
125
packages/propel/src/charts/bar-chart/root.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TBarChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
import { CustomBar } from "./bar";
|
||||
|
||||
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
bars,
|
||||
xAxis,
|
||||
yAxis,
|
||||
barSize = 40,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
|
||||
const stackDotClassNames = useMemo(
|
||||
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
|
||||
[bars]
|
||||
);
|
||||
|
||||
const renderBars = useMemo(
|
||||
() =>
|
||||
bars.map((bar) => (
|
||||
<Bar
|
||||
key={bar.key}
|
||||
dataKey={bar.key}
|
||||
stackId={bar.stackId}
|
||||
fill={bar.fillClassName}
|
||||
shape={(shapeProps: any) => (
|
||||
<CustomBar
|
||||
{...shapeProps}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={bar.textClassName}
|
||||
showPercentage={bar.showPercentage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
[stackKeys, bars]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreBarChart
|
||||
data={data}
|
||||
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
||||
barSize={barSize}
|
||||
className="recharts-wrapper"
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={stackKeys}
|
||||
itemDotClassNames={stackDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderBars}
|
||||
</CoreBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
BarChart.displayName = "BarChart";
|
||||
1
packages/propel/src/charts/line-chart/index.ts
Normal file
1
packages/propel/src/charts/line-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
115
packages/propel/src/charts/line-chart/root.tsx
Normal file
115
packages/propel/src/charts/line-chart/root.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TLineChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
|
||||
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
lines,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
|
||||
const itemDotClassNames = useMemo(
|
||||
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}),
|
||||
[lines]
|
||||
);
|
||||
|
||||
const renderLines = useMemo(
|
||||
() =>
|
||||
lines.map((line) => (
|
||||
<Line key={line.key} dataKey={line.key} type="monotone" className={line.className} stroke="inherit" />
|
||||
)),
|
||||
[lines]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreLineChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemDotClassNames={itemDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderLines}
|
||||
</CoreLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
LineChart.displayName = "LineChart";
|
||||
1
packages/propel/src/charts/pie-chart/index.ts
Normal file
1
packages/propel/src/charts/pie-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
51
packages/propel/src/charts/pie-chart/root.tsx
Normal file
51
packages/propel/src/charts/pie-chart/root.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { TPieChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomPieChartTooltip } from "./tooltip";
|
||||
|
||||
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 renderCells = useMemo(
|
||||
() => cells.map((cell) => <Cell key={cell.key} className={cell.className} style={cell.style} />),
|
||||
[cells]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CorePieChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<Pie data={data} dataKey={dataKey} cx="50%" cy="50%" innerRadius={innerRadius} outerRadius={outerRadius}>
|
||||
{renderCells}
|
||||
</Pie>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const cellData = cells.find((c) => c.key === payload[0].name);
|
||||
if (!cellData) return null;
|
||||
return <CustomPieChartTooltip dotClassName={cellData.dotClassName} label={dataKey} payload={payload} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CorePieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
PieChart.displayName = "PieChart";
|
||||
31
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal file
31
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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 = {
|
||||
dotClassName?: string;
|
||||
label: string;
|
||||
payload: Payload<ValueType, NameType>[];
|
||||
};
|
||||
|
||||
export const CustomPieChartTooltip = React.memo((props: Props) => {
|
||||
const { dotClassName, label, payload } = props;
|
||||
|
||||
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>
|
||||
{payload?.map((item) => (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
<div className={cn("size-2 rounded-full", dotClassName)} />
|
||||
<span className="text-custom-text-300">{item?.name}:</span>
|
||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomPieChartTooltip.displayName = "CustomPieChartTooltip";
|
||||
23
packages/propel/src/charts/tick.tsx
Normal file
23
packages/propel/src/charts/tick.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
|
||||
// Common classnames
|
||||
const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize";
|
||||
|
||||
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
));
|
||||
CustomXAxisTick.displayName = "CustomXAxisTick";
|
||||
|
||||
export const CustomYAxisTick = React.memo<any>(({ x, y, payload }: any) => (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text dx={-10} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
));
|
||||
CustomYAxisTick.displayName = "CustomYAxisTick";
|
||||
41
packages/propel/src/charts/tooltip.tsx
Normal file
41
packages/propel/src/charts/tooltip.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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";
|
||||
1
packages/propel/src/charts/tree-map/index.ts
Normal file
1
packages/propel/src/charts/tree-map/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
276
packages/propel/src/charts/tree-map/map-content.tsx
Normal file
276
packages/propel/src/charts/tree-map/map-content.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import React, { useMemo } from "react";
|
||||
// plane imports
|
||||
import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
const LAYOUT = {
|
||||
PADDING: 2,
|
||||
RADIUS: 6,
|
||||
TEXT: {
|
||||
PADDING_LEFT: 8,
|
||||
PADDING_RIGHT: 8,
|
||||
VERTICAL_OFFSET: 20,
|
||||
ELLIPSIS_OFFSET: -4,
|
||||
FONT_SIZES: {
|
||||
SM: 12.6,
|
||||
XS: 10.8,
|
||||
},
|
||||
},
|
||||
ICON: {
|
||||
SIZE: 16,
|
||||
GAP: 6,
|
||||
},
|
||||
MIN_DIMENSIONS: {
|
||||
HEIGHT_FOR_BOTH: 42,
|
||||
HEIGHT_FOR_TOP: 35,
|
||||
HEIGHT_FOR_DOTS: 20,
|
||||
WIDTH_FOR_ICON: 30,
|
||||
WIDTH_FOR_DOTS: 15,
|
||||
},
|
||||
};
|
||||
|
||||
const calculateContentWidth = (text: string | number, fontSize: number): number => String(text).length * fontSize * 0.7;
|
||||
|
||||
const calculateTopSectionConfig = (effectiveWidth: number, name: string, hasIcon: boolean): TTopSectionConfig => {
|
||||
const iconWidth = hasIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
|
||||
const nameWidth = calculateContentWidth(name, LAYOUT.TEXT.FONT_SIZES.SM);
|
||||
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
|
||||
|
||||
// First check if we can show icon
|
||||
const canShowIcon = hasIcon && effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_ICON;
|
||||
|
||||
// If we can't even show icon, check if we can show dots
|
||||
if (!canShowIcon) {
|
||||
return {
|
||||
showIcon: false,
|
||||
showName: effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS,
|
||||
nameTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// We can show icon, now check if we have space for name
|
||||
const availableWidthForName = effectiveWidth - (canShowIcon ? iconWidth : 0) - totalPadding;
|
||||
const canShowFullName = availableWidthForName >= nameWidth;
|
||||
|
||||
return {
|
||||
showIcon: canShowIcon,
|
||||
showName: availableWidthForName > 0,
|
||||
nameTruncated: !canShowFullName,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateBottomSectionConfig = (
|
||||
effectiveWidth: number,
|
||||
effectiveHeight: number,
|
||||
value: number | undefined,
|
||||
label: string | undefined
|
||||
): TBottomSectionConfig => {
|
||||
// If height is not enough for bottom section
|
||||
if (effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_BOTH) {
|
||||
return {
|
||||
show: false,
|
||||
showValue: false,
|
||||
showLabel: false,
|
||||
labelTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate widths
|
||||
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
|
||||
const valueWidth = value ? calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) : 0;
|
||||
const labelWidth = label ? calculateContentWidth(label, LAYOUT.TEXT.FONT_SIZES.XS) + 4 : 0; // 4px for spacing
|
||||
const availableWidth = effectiveWidth - totalPadding;
|
||||
|
||||
// If we can't even show value
|
||||
if (availableWidth < Math.max(valueWidth, LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS)) {
|
||||
return {
|
||||
show: true,
|
||||
showValue: false,
|
||||
showLabel: false,
|
||||
labelTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// If we can show value but not full label
|
||||
const canShowFullLabel = availableWidth >= valueWidth + labelWidth;
|
||||
|
||||
return {
|
||||
show: true,
|
||||
showValue: true,
|
||||
showLabel: true,
|
||||
labelTruncated: !canShowFullLabel,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateVisibility = (
|
||||
width: number,
|
||||
height: number,
|
||||
hasIcon: boolean,
|
||||
name: string,
|
||||
value: number | undefined,
|
||||
label: string | undefined
|
||||
): TContentVisibility => {
|
||||
const effectiveWidth = width - LAYOUT.PADDING * 2;
|
||||
const effectiveHeight = height - LAYOUT.PADDING * 2;
|
||||
|
||||
// If extremely small, show only dots
|
||||
if (
|
||||
effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_DOTS ||
|
||||
effectiveWidth < LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS
|
||||
) {
|
||||
return {
|
||||
top: { showIcon: false, showName: false, nameTruncated: false },
|
||||
bottom: { show: false, showValue: false, showLabel: false, labelTruncated: false },
|
||||
};
|
||||
}
|
||||
|
||||
const topSection = calculateTopSectionConfig(effectiveWidth, name, hasIcon);
|
||||
const bottomSection = calculateBottomSectionConfig(effectiveWidth, effectiveHeight, value, label);
|
||||
|
||||
return {
|
||||
top: topSection,
|
||||
bottom: bottomSection,
|
||||
};
|
||||
};
|
||||
|
||||
const truncateText = (text: string | number, maxWidth: number, fontSize: number, reservedWidth: number = 0): string => {
|
||||
const availableWidth = maxWidth - reservedWidth;
|
||||
if (availableWidth <= 0) return "";
|
||||
|
||||
const avgCharWidth = fontSize * 0.7;
|
||||
const maxChars = Math.floor(availableWidth / avgCharWidth);
|
||||
const stringText = String(text);
|
||||
|
||||
if (maxChars <= 3) return "";
|
||||
if (stringText.length <= maxChars) return stringText;
|
||||
return `${stringText.slice(0, maxChars - 3)}...`;
|
||||
};
|
||||
|
||||
export const CustomTreeMapContent: React.FC<any> = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
fillColor,
|
||||
fillClassName,
|
||||
textClassName,
|
||||
icon,
|
||||
}) => {
|
||||
const dimensions = useMemo(() => {
|
||||
const pX = x + LAYOUT.PADDING;
|
||||
const pY = y + LAYOUT.PADDING;
|
||||
const pWidth = Math.max(0, width - LAYOUT.PADDING * 2);
|
||||
const pHeight = Math.max(0, height - LAYOUT.PADDING * 2);
|
||||
return { pX, pY, pWidth, pHeight };
|
||||
}, [x, y, width, height]);
|
||||
|
||||
const visibility = useMemo(
|
||||
() => calculateVisibility(width, height, !!icon, name, value, label),
|
||||
[width, height, icon, name, value, label]
|
||||
);
|
||||
|
||||
if (!name || width <= 0 || height <= 0) return null;
|
||||
|
||||
const renderContent = () => {
|
||||
const { pX, pY, pWidth, pHeight } = dimensions;
|
||||
const { top, bottom } = visibility;
|
||||
|
||||
const availableTextWidth = pWidth - LAYOUT.TEXT.PADDING_LEFT - LAYOUT.TEXT.PADDING_RIGHT;
|
||||
const iconSpace = top.showIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Background shape */}
|
||||
<path
|
||||
d={`
|
||||
M${pX + LAYOUT.RADIUS},${pY}
|
||||
L${pX + pWidth - LAYOUT.RADIUS},${pY}
|
||||
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + LAYOUT.RADIUS}
|
||||
L${pX + pWidth},${pY + pHeight - LAYOUT.RADIUS}
|
||||
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - LAYOUT.RADIUS},${pY + pHeight}
|
||||
L${pX + LAYOUT.RADIUS},${pY + pHeight}
|
||||
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - LAYOUT.RADIUS}
|
||||
L${pX},${pY + LAYOUT.RADIUS}
|
||||
Q${pX},${pY} ${pX + LAYOUT.RADIUS},${pY}
|
||||
`}
|
||||
className={cn("transition-colors duration-200 hover:opacity-90", fillClassName)}
|
||||
fill={fillColor ?? "currentColor"}
|
||||
/>
|
||||
|
||||
{/* Top section */}
|
||||
<g>
|
||||
{top.showIcon && icon && (
|
||||
<foreignObject
|
||||
x={pX + LAYOUT.TEXT.PADDING_LEFT}
|
||||
y={pY + LAYOUT.TEXT.PADDING_LEFT}
|
||||
width={LAYOUT.ICON.SIZE}
|
||||
height={LAYOUT.ICON.SIZE}
|
||||
className={textClassName || "text-custom-text-300"}
|
||||
>
|
||||
{React.cloneElement(icon, {
|
||||
className: cn("size-4", icon?.props?.className),
|
||||
"aria-hidden": true,
|
||||
})}
|
||||
</foreignObject>
|
||||
)}
|
||||
{top.showName && (
|
||||
<text
|
||||
x={pX + LAYOUT.TEXT.PADDING_LEFT + iconSpace}
|
||||
y={pY + LAYOUT.TEXT.VERTICAL_OFFSET}
|
||||
textAnchor="start"
|
||||
className={cn(
|
||||
"text-sm font-extralight tracking-wider select-none",
|
||||
textClassName || "text-custom-text-300"
|
||||
)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{top.nameTruncated ? truncateText(name, availableTextWidth, LAYOUT.TEXT.FONT_SIZES.SM, iconSpace) : name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Bottom section */}
|
||||
{bottom.show && (
|
||||
<g>
|
||||
{bottom.showValue && value !== undefined && (
|
||||
<text
|
||||
x={pX + LAYOUT.TEXT.PADDING_LEFT}
|
||||
y={pY + pHeight - LAYOUT.TEXT.PADDING_LEFT}
|
||||
textAnchor="start"
|
||||
className={cn(
|
||||
"text-sm font-extralight tracking-wider select-none",
|
||||
textClassName || "text-custom-text-300"
|
||||
)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{value.toLocaleString()}
|
||||
{bottom.showLabel && label && (
|
||||
<tspan dx={4}>
|
||||
{bottom.labelTruncated
|
||||
? truncateText(
|
||||
label,
|
||||
availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.SM) - 4,
|
||||
LAYOUT.TEXT.FONT_SIZES.SM
|
||||
)
|
||||
: label}
|
||||
</tspan>
|
||||
)}
|
||||
{!bottom.showLabel && label && <tspan dx={4}>...</tspan>}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={width} height={height} fill="transparent" />
|
||||
{renderContent()}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
41
packages/propel/src/charts/tree-map/root.tsx
Normal file
41
packages/propel/src/charts/tree-map/root.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from "react";
|
||||
import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { TreeMapChartProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { CustomTreeMapContent } from "./map-content";
|
||||
import { TreeMapTooltip } from "./tooltip";
|
||||
|
||||
export const TreeMapChart = React.memo((props: TreeMapChartProps) => {
|
||||
const { data, className = "w-full h-96", isAnimationActive = false, showTooltip = true } = props;
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Treemap
|
||||
data={data}
|
||||
nameKey="name"
|
||||
dataKey="value"
|
||||
stroke="currentColor"
|
||||
className="text-custom-background-100 bg-custom-background-100"
|
||||
content={<CustomTreeMapContent />}
|
||||
animationEasing="ease-out"
|
||||
isUpdateAnimationActive={isAnimationActive}
|
||||
animationBegin={100}
|
||||
animationDuration={500}
|
||||
>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
content={({ active, payload }) => <TreeMapTooltip active={active} payload={payload} />}
|
||||
cursor={{
|
||||
fill: "currentColor",
|
||||
className: "text-custom-background-90/80 cursor-pointer",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Treemap>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TreeMapChart.displayName = "TreeMapChart";
|
||||
29
packages/propel/src/charts/tree-map/tooltip.tsx
Normal file
29
packages/propel/src/charts/tree-map/tooltip.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from "react";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
|
||||
interface TreeMapTooltipProps {
|
||||
active: boolean | undefined;
|
||||
payload: any[] | undefined;
|
||||
}
|
||||
|
||||
export const TreeMapTooltip = React.memo(({ active, payload }: TreeMapTooltipProps) => {
|
||||
if (!active || !payload || !payload[0]?.payload) return null;
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col space-y-1.5" spacing={ECardSpacing.SM}>
|
||||
<div className="flex items-center gap-2 border-b border-custom-border-200 pb-2.5">
|
||||
{data?.icon}
|
||||
<p className="text-xs text-custom-text-100 font-medium capitalize">{data?.name}</p>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-custom-text-200">
|
||||
{data?.value.toLocaleString()}
|
||||
{data.label && ` ${data.label}`}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
TreeMapTooltip.displayName = "TreeMapTooltip";
|
||||
Loading…
Add table
Add a link
Reference in a new issue