feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,205 @@
"use client";
import React, { useMemo, useState } from "react";
import { Area, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, ComposedChart, CartesianGrid } from "recharts";
// plane imports
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TAreaChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
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>) => {
const {
data,
areas,
xAxis,
yAxis,
className,
legend,
margin,
tickCount = {
x: undefined,
y: 10,
},
customTicks,
showTooltip = true,
comparisonLine,
} = props;
// states
const [activeArea, setActiveArea] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const area of areas) {
keys.push(area.key);
labels[area.key] = area.label;
colors[area.key] = area.fill;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [areas]);
const renderAreas = useMemo(
() =>
areas.map((area) => (
<Area
key={area.key}
type={area.smoothCurves ? "monotone" : "linear"}
dataKey={area.key}
stackId={area.stackId}
fill={area.fill}
opacity={!!activeLegend && activeLegend !== area.key ? 0.1 : 1}
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"
/>
)),
[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, yAxis.key]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
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,
}}
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => {
const TickComponent = customTicks?.x || CustomXAxisTick;
return <TickComponent {...props} />;
}}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}
}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: yAxis.offset ?? -24,
dx: yAxis.dx ?? -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => {
const TickComponent = customTicks?.y || CustomYAxisTick;
return <TickComponent {...props} />;
}}
tickCount={tickCount.y}
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 && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeArea}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{renderAreas}
{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>
</div>
);
});
AreaChart.displayName = "AreaChart";

View File

@@ -0,0 +1,184 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// plane imports
import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
import { cn } from "../../utils/classname";
// Constants
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height required to show text inside bar
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for the top of bars
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for the bottom of bars
const DEFAULT_LOLLIPOP_LINE_WIDTH = 2; // Width of lollipop stick
const DEFAULT_LOLLIPOP_CIRCLE_RADIUS = 8; // Radius of lollipop circle
// Types
interface TShapeProps {
x: number;
y: number;
width: number;
height: number;
dataKey: string;
payload: any;
opacity?: number;
}
interface TBarProps extends TShapeProps {
fill: string;
stackKeys: string[];
textClassName?: string;
showPercentage?: boolean;
showTopBorderRadius?: boolean;
showBottomBorderRadius?: boolean;
dotted?: boolean;
}
// Helper Functions
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 getBarPath = (x: number, y: number, width: number, height: number, topRadius: number, bottomRadius: number) => `
M${x},${y + topRadius}
Q${x},${y} ${x + topRadius},${y}
L${x + width - topRadius},${y}
Q${x + width},${y} ${x + width},${y + topRadius}
L${x + width},${y + height - bottomRadius}
Q${x + width},${y + height} ${x + width - bottomRadius},${y + height}
L${x + bottomRadius},${y + height}
Q${x},${y + height} ${x},${y + height - bottomRadius}
Z
`;
const PercentageText = ({
x,
y,
percentage,
className,
}: {
x: number;
y: number;
percentage: number;
className?: string;
}) => (
<text x={x} y={y} textAnchor="middle" className={cn("text-xs font-medium", className)} fill="currentColor">
{percentage}%
</text>
);
// Base Components
const CustomBar = React.memo((props: TBarProps) => {
const {
opacity,
fill,
x,
y,
width,
height,
dataKey,
stackKeys,
payload,
textClassName,
showPercentage,
showTopBorderRadius,
showBottomBorderRadius,
} = props;
if (!height) return null;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
const textY = y + height - TEXT_PADDING_Y;
const showText =
showPercentage &&
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
currentBarPercentage !== undefined &&
!Number.isNaN(currentBarPercentage);
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
return (
<g>
<path
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
className="transition-opacity duration-200"
fill={fill}
opacity={opacity}
/>
{showText && (
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
)}
</g>
);
});
const CustomBarLollipop = React.memo((props: TBarProps) => {
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage, dotted } = props;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
return (
<g>
<line
x1={x + width / 2}
y1={y + height}
x2={x + width / 2}
y2={y}
stroke={fill}
strokeWidth={DEFAULT_LOLLIPOP_LINE_WIDTH}
strokeLinecap="round"
strokeDasharray={dotted ? "4 4" : "0"}
/>
<circle cx={x + width / 2} cy={y} r={DEFAULT_LOLLIPOP_CIRCLE_RADIUS} fill={fill} stroke="none" />
{showPercentage && (
<PercentageText x={x + width / 2} y={y} percentage={currentBarPercentage} className={textClassName} />
)}
</g>
);
});
// Shape Variants
/**
* Factory function to create shape variants with consistent props
* @param Component - The base component to render
* @param factoryProps - Additional props to pass to the component
* @returns A function that creates the shape with proper props
*/
const createShapeVariant =
(Component: React.ComponentType<TBarProps>, factoryProps?: Partial<TBarProps>) =>
(shapeProps: TShapeProps, bar: TBarItem<string>, stackKeys: string[]): React.ReactNode => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
return (
<Component
{...shapeProps}
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
{...factoryProps}
/>
);
};
export const barShapeVariants: Record<
TBarChartShapeVariant,
(props: TShapeProps, bar: TBarItem<string>, stackKeys: string[]) => React.ReactNode
> = {
bar: createShapeVariant(CustomBar), // Standard bar with rounded corners
lollipop: createShapeVariant(CustomBarLollipop), // Line with circle at top
"lollipop-dotted": createShapeVariant(CustomBarLollipop, { dotted: true }), // Dotted line lollipop variant
};
// Display names
CustomBar.displayName = "CustomBar";
CustomBarLollipop.displayName = "CustomBarLollipop";

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,209 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useCallback, useMemo, useState } from "react";
import {
BarChart as CoreBarChart,
Bar,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Legend,
CartesianGrid,
} from "recharts";
// plane imports
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TBarChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
import { barShapeVariants } from "./bar";
const DEFAULT_BAR_FILL_COLOR = "#000000";
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
const {
data,
bars,
xAxis,
yAxis,
barSize = 40,
className,
legend,
margin,
tickCount = {
x: undefined,
y: 10,
},
customTicks,
showTooltip = true,
customTooltipContent,
} = props;
// states
const [activeBar, setActiveBar] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const { stackKeys, stackLabels } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
for (const bar of bars) {
keys.push(bar.key);
labels[bar.key] = bar.label;
}
return { stackKeys: keys, stackLabels: labels };
}, [bars]);
// get bar color dynamically based on payload
const getBarColor = useCallback(
(payload: Record<string, string>[], barKey: string) => {
const bar = bars.find((b) => b.key === barKey);
if (!bar) return DEFAULT_BAR_FILL_COLOR;
if (typeof bar.fill === "function") {
const payloadItem = payload?.find((item) => item.dataKey === barKey);
if (payloadItem?.payload) {
try {
return bar.fill(payloadItem.payload);
} catch (error) {
console.error(error);
return DEFAULT_BAR_FILL_COLOR;
}
} else {
return DEFAULT_BAR_FILL_COLOR; // fallback color when no payload data
}
} else {
return bar.fill;
}
},
[bars]
);
// get all bar colors
const getAllBarColors = useCallback(
(payload: any[]) => {
const colors: Record<string, string> = {};
for (const bar of bars) {
colors[bar.key] = getBarColor(payload, bar.key);
}
return colors;
},
[bars, getBarColor]
);
const renderBars = useMemo(
() =>
bars.map((bar) => (
<Bar
key={bar.key}
dataKey={bar.key}
stackId={bar.stackId}
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
shape={(shapeProps: any) => {
const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"];
const node = shapeVariant(shapeProps, bar, stackKeys);
return React.isValidElement(node) ? node : <>{node}</>;
}}
className="[&_path]:transition-opacity [&_path]:duration-200"
onMouseEnter={() => setActiveBar(bar.key)}
onMouseLeave={() => setActiveBar(null)}
fill={getBarColor(data, bar.key)}
/>
)),
[activeLegend, stackKeys, bars, getBarColor, data]
);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreBarChart
data={data}
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}
className="recharts-wrapper"
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => {
const TickComponent = customTicks?.x || CustomXAxisTick;
return <TickComponent {...props} />;
}}
tickLine={false}
axisLine={false}
label={{
value: xAxis.label,
dy: xAxis.dy ?? 28,
className: AXIS_LABEL_CLASSNAME,
}}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={false}
axisLine={false}
label={{
value: yAxis.label,
angle: -90,
position: "bottom",
offset: yAxis.offset ?? -24,
dx: yAxis.dx ?? -16,
className: AXIS_LABEL_CLASSNAME,
}}
tick={(props) => {
const TickComponent = customTicks?.y || CustomYAxisTick;
return <TickComponent {...props} />;
}}
tickCount={tickCount.y}
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 && (
<Tooltip
cursor={{
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => {
if (customTooltipContent) return customTooltipContent({ active, label, payload });
return (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemLabels={stackLabels}
itemDotColors={getAllBarColors(payload || [])}
/>
);
}}
/>
)}
{renderBars}
</CoreBarChart>
</ResponsiveContainer>
</div>
);
});
BarChart.displayName = "BarChart";

View File

@@ -0,0 +1,78 @@
import React from "react";
import { LegendProps } from "recharts";
// plane imports
import { TChartLegend } from "@plane/types";
import { cn } from "../../utils/classname";
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",
}),
...args.wrapperStyles,
},
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";

View File

@@ -0,0 +1,45 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// Common classnames
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
export const CustomXAxisTick = React.memo<any>(({ x, y, payload, getLabel }: any) => (
<g transform={`translate(${x},${y})`}>
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{getLabel ? getLabel(payload.value) : 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";
export const CustomRadarAxisTick = React.memo<any>(({ x, y, payload, getLabel, cx, cy, offset = 16 }: any) => {
// Calculate direction vector from center to tick
const dx = x - cx;
const dy = y - cy;
// Normalize and apply offset
const length = Math.sqrt(dx * dx + dy * dy);
const normX = dx / length;
const normY = dy / length;
const labelX = x + normX * offset;
const labelY = y + normY * offset;
return (
<g transform={`translate(${labelX},${labelY})`}>
<text y={0} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{getLabel ? getLabel(payload.value) : payload.value}
</text>
</g>
);
});
CustomRadarAxisTick.displayName = "CustomRadarAxisTick";

View File

@@ -0,0 +1,59 @@
import React from "react";
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
import { Card, ECardSpacing } from "../../card";
import { cn } from "../../utils/classname";
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";

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useMemo, useState } from "react";
import {
CartesianGrid,
LineChart as CoreLineChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// plane imports
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TLineChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
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>) => {
const {
data,
lines,
margin,
xAxis,
yAxis,
className,
tickCount = {
x: undefined,
y: 10,
},
customTicks,
legend,
showTooltip = true,
customTooltipContent,
} = props;
// states
const [activeLine, setActiveLine] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const line of lines) {
keys.push(line.key);
labels[line.key] = line.label;
colors[line.key] = line.stroke;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [lines]);
const renderLines = useMemo(
() =>
lines.map((line) => (
<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)}
/>
)),
[activeLegend, lines]
);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreLineChart
data={data}
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,
}}
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => {
const TickComponent = customTicks?.x || CustomXAxisTick;
return <TickComponent {...props} />;
}}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}
}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: yAxis.dx ?? -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => {
const TickComponent = customTicks?.y || CustomYAxisTick;
return <TickComponent {...props} />;
}}
tickCount={tickCount.y}
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 && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => {
if (customTooltipContent) return customTooltipContent({ active, label, payload });
return (
<CustomTooltip
active={active}
activeKey={activeLine}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
);
}}
/>
)}
{renderLines}
</CoreLineChart>
</ResponsiveContainer>
</div>
);
});
LineChart.displayName = "LineChart";

View File

@@ -0,0 +1,32 @@
import React from "react";
import { Sector } from "recharts";
import { PieSectorDataItem } from "recharts/types/polar/Pie";
export const CustomActiveShape = React.memo((props: PieSectorDataItem) => {
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 ?? 0) + 6}
outerRadius={(outerRadius ?? 0) + 10}
fill={fill}
/>
</g>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,151 @@
"use client";
import React, { useMemo, useState } from "react";
import { Cell, PieChart as CorePieChart, Label, Legend, Pie, ResponsiveContainer, Tooltip } from "recharts";
// plane imports
import { TPieChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
import { CustomActiveShape } from "./active-shape";
import { CustomPieChartTooltip } from "./tooltip";
export const PieChart = React.memo(<K extends string, T extends string>(props: TPieChartProps<K, T>) => {
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(
() =>
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 (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CorePieChart
data={data}
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,
}}
>
<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}
{centerLabel && (
<Label
value={centerLabel.text}
fill={centerLabel.fill}
position="center"
opacity={activeLegend ? 0.1 : 1}
style={centerLabel.style}
className={centerLabel.className}
/>
)}
</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 && (
<Tooltip
cursor={{
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
wrapperStyle={{
pointerEvents: "none",
}}
content={({ active, payload }) => {
if (!active || !payload || !payload.length) return null;
const cellData = cells.find((c) => c.key === payload[0].payload.key);
if (!cellData) return null;
const label = tooltipLabel
? typeof tooltipLabel === "function"
? tooltipLabel(payload[0]?.payload?.payload)
: tooltipLabel
: dataKey;
return <CustomPieChartTooltip dotColor={cellData.fill} label={label} payload={payload} />;
}}
/>
)}
</CorePieChart>
</ResponsiveContainer>
</div>
);
});
PieChart.displayName = "PieChart";

View File

@@ -0,0 +1,40 @@
import React from "react";
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
// plane imports
import { Card, ECardSpacing } from "../../card";
type Props = {
dotColor?: string;
label: string;
payload: Payload<ValueType, NameType>[];
};
export const CustomPieChartTooltip = React.memo((props: Props) => {
const { dotColor, label, payload } = props;
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>
{payload?.map((item) => (
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
<div className="flex items-center gap-2 truncate">
<div
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>
))}
</Card>
);
});
CustomPieChartTooltip.displayName = "CustomPieChartTooltip";

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,95 @@
import { useMemo, useState } from "react";
import {
PolarGrid,
Radar,
RadarChart as CoreRadarChart,
ResponsiveContainer,
PolarAngleAxis,
Tooltip,
Legend,
} from "recharts";
import { TRadarChartProps } from "@plane/types";
import { getLegendProps } from "../components/legend";
import { CustomRadarAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
const RadarChart = <T extends string, K extends string>(props: TRadarChartProps<T, K>) => {
const { data, radars, margin, showTooltip, legend, className, angleAxis } = props;
// states
const [, setActiveIndex] = useState<number | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const radar of radars) {
keys.push(radar.key);
labels[radar.key] = radar.name;
colors[radar.key] = radar.stroke ?? radar.fill ?? "#000000";
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [radars]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreRadarChart cx="50%" cy="50%" outerRadius="80%" data={data} margin={margin}>
<PolarGrid stroke="rgba(var(--color-border-100), 0.9)" />
<PolarAngleAxis dataKey={angleAxis.key} tick={(props) => <CustomRadarAxisTick {...props} />} />
{showTooltip && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeLegend}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{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)}
/>
)}
{radars.map((radar) => (
<Radar
key={radar.key}
name={radar.name}
dataKey={radar.key}
stroke={radar.stroke}
fill={radar.fill}
fillOpacity={radar.fillOpacity}
dot={radar.dot}
/>
))}
</CoreRadarChart>
</ResponsiveContainer>
</div>
);
};
export { RadarChart };

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,166 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useMemo, useState } from "react";
import {
CartesianGrid,
ScatterChart as CoreScatterChart,
Legend,
Scatter,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// plane imports
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TScatterChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
export const ScatterChart = React.memo(<K extends string, T extends string>(props: TScatterChartProps<K, T>) => {
const {
data,
scatterPoints,
margin,
xAxis,
yAxis,
className,
customTicks,
tickCount = {
x: undefined,
y: 10,
},
legend,
showTooltip = true,
customTooltipContent,
} = props;
// states
const [activePoint, setActivePoint] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
//derived values
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const point of scatterPoints) {
keys.push(point.key);
labels[point.key] = point.label;
colors[point.key] = point.fill;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [scatterPoints]);
const renderPoints = useMemo(
() =>
scatterPoints.map((point) => (
<Scatter
key={point.key}
dataKey={point.key}
fill={point.fill}
stroke={point.stroke}
opacity={!!activeLegend && activeLegend !== point.key ? 0.1 : 1}
onMouseEnter={() => setActivePoint(point.key)}
onMouseLeave={() => setActivePoint(null)}
/>
)),
[activeLegend, scatterPoints]
);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreScatterChart
data={data}
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,
}}
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => {
const TickComponent = customTicks?.x || CustomXAxisTick;
return <TickComponent {...props} />;
}}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}
}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: yAxis.dx ?? -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => {
const TickComponent = customTicks?.y || CustomYAxisTick;
return <TickComponent {...props} />;
}}
tickCount={tickCount.y}
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 && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) =>
customTooltipContent ? (
customTooltipContent({ active, label, payload })
) : (
<CustomTooltip
active={active}
activeKey={activePoint}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)
}
/>
)}
{renderPoints}
</CoreScatterChart>
</ResponsiveContainer>
</div>
);
});
ScatterChart.displayName = "ScatterChart";

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,276 @@
import React, { useMemo } from "react";
// plane imports
import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
import { cn } from "../../utils/classname";
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>
);
};

View File

@@ -0,0 +1,44 @@
import React from "react";
import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
// plane imports
import { TreeMapChartProps } from "@plane/types";
// local imports
import { cn } from "../../utils/classname";
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",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
/>
)}
</Treemap>
</ResponsiveContainer>
</div>
);
});
TreeMapChart.displayName = "TreeMapChart";

View File

@@ -0,0 +1,29 @@
import React from "react";
// plane imports
import { Card, ECardSpacing } from "../../card";
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";