mirror of
https://github.com/makeplane/plane.git
synced 2025-12-24 23:59:40 +01:00
feat: introduced stacked bar chart and tree map chart. (#6305)
This commit is contained in:
53
packages/types/src/charts.d.ts
vendored
Normal file
53
packages/types/src/charts.d.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
export type TStackItem<T extends string> = {
|
||||
key: T;
|
||||
fillClassName: string;
|
||||
textClassName: string;
|
||||
dotClassName?: string;
|
||||
showPercentage?: boolean;
|
||||
};
|
||||
|
||||
export type TStackChartData<K extends string, T extends string> = {
|
||||
[key in K]: string | number;
|
||||
} & Record<T, any>;
|
||||
|
||||
export type TStackedBarChartProps<K extends string, T extends string> = {
|
||||
data: TStackChartData<K, T>[];
|
||||
stacks: TStackItem<T>[];
|
||||
xAxis: {
|
||||
key: keyof TStackChartData<K, T>;
|
||||
label: string;
|
||||
};
|
||||
yAxis: {
|
||||
key: keyof TStackChartData<K, T>;
|
||||
label: string;
|
||||
domain?: [number, number];
|
||||
allowDecimals?: boolean;
|
||||
};
|
||||
barSize?: number;
|
||||
className?: string;
|
||||
tickCount?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
export type TreeMapItem = {
|
||||
name: string;
|
||||
value: number;
|
||||
textClassName?: string;
|
||||
icon?: React.ReactElement;
|
||||
} & (
|
||||
| {
|
||||
fillColor: string;
|
||||
}
|
||||
| {
|
||||
fillClassName: string;
|
||||
}
|
||||
);
|
||||
|
||||
export type TreeMapChartProps = {
|
||||
data: TreeMapItem[];
|
||||
className?: string;
|
||||
isAnimationActive?: boolean;
|
||||
};
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@@ -37,3 +37,4 @@ export * from "./command-palette";
|
||||
export * from "./timezone";
|
||||
export * from "./activity";
|
||||
export * from "./epics";
|
||||
export * from "./charts";
|
||||
|
||||
63
web/core/components/core/charts/stacked-bar-chart/bar.tsx
Normal file
63
web/core/components/core/charts/stacked-bar-chart/bar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { TStackChartData } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// Helper to calculate percentage
|
||||
const calculatePercentage = <K extends string, T extends string>(
|
||||
data: TStackChartData<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);
|
||||
};
|
||||
|
||||
export const CustomStackBar = React.memo<any>((props: any) => {
|
||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
||||
// Calculate text position
|
||||
const MIN_BAR_HEIGHT_FOR_INTERNAL = 14; // Minimum height needed to show text inside
|
||||
const TEXT_PADDING = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL - height / 2));
|
||||
const textY = y + height - TEXT_PADDING; // Position inside bar if tall enough
|
||||
// derived values
|
||||
const RADIUS = 2;
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
|
||||
if (!height) return null;
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${x + RADIUS},${y + height}
|
||||
L${x + RADIUS},${y}
|
||||
Q${x},${y} ${x},${y + RADIUS}
|
||||
L${x},${y + height - RADIUS}
|
||||
Q${x},${y + height} ${x + RADIUS},${y + height}
|
||||
L${x + width - RADIUS},${y + height}
|
||||
Q${x + width},${y + height} ${x + width},${y + height - RADIUS}
|
||||
L${x + width},${y + RADIUS}
|
||||
Q${x + width},${y} ${x + width - RADIUS},${y}
|
||||
L${x + RADIUS},${y}
|
||||
`}
|
||||
className={cn("transition-colors duration-200", fill)}
|
||||
fill="currentColor"
|
||||
/>
|
||||
{showPercentage &&
|
||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL &&
|
||||
currentBarPercentage !== undefined &&
|
||||
!Number.isNaN(currentBarPercentage) && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={textY}
|
||||
textAnchor="middle"
|
||||
className={cn("text-xs font-medium", textClassName)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{currentBarPercentage}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
CustomStackBar.displayName = "CustomStackBar";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
130
web/core/components/core/charts/stacked-bar-chart/root.tsx
Normal file
130
web/core/components/core/charts/stacked-bar-chart/root.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { TStackedBarChartProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local components
|
||||
import { CustomStackBar } from "./bar";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "./tick";
|
||||
import { CustomTooltip } from "./tooltip";
|
||||
|
||||
// Common classnames
|
||||
const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
|
||||
|
||||
export const StackedBarChart = React.memo(
|
||||
<K extends string, T extends string>({
|
||||
data,
|
||||
stacks,
|
||||
xAxis,
|
||||
yAxis,
|
||||
barSize = 40,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
}: TStackedBarChartProps<K, T>) => {
|
||||
// derived values
|
||||
const stackKeys = React.useMemo(() => stacks.map((stack) => stack.key), [stacks]);
|
||||
const stackDotClassNames = React.useMemo(
|
||||
() => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}),
|
||||
[stacks]
|
||||
);
|
||||
|
||||
const renderBars = React.useMemo(
|
||||
() =>
|
||||
stacks.map((stack) => (
|
||||
<Bar
|
||||
key={stack.key}
|
||||
dataKey={stack.key}
|
||||
stackId="a"
|
||||
fill={stack.fillClassName}
|
||||
shape={(props: any) => (
|
||||
<CustomStackBar
|
||||
{...props}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={stack.textClassName}
|
||||
showPercentage={stack.showPercentage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
[stackKeys, stacks]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
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 ?? false}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
stackKeys={stackKeys}
|
||||
stackDotClassNames={stackDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderBars}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
StackedBarChart.displayName = "StackedBarChart";
|
||||
23
web/core/components/core/charts/stacked-bar-chart/tick.tsx
Normal file
23
web/core/components/core/charts/stacked-bar-chart/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";
|
||||
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TStackedBarChartProps = {
|
||||
active: boolean | undefined;
|
||||
label: string | undefined;
|
||||
payload: any[] | undefined;
|
||||
stackKeys: string[];
|
||||
stackDotClassNames: Record<string, string>;
|
||||
};
|
||||
|
||||
export const CustomTooltip = React.memo(
|
||||
({ active, label, payload, stackKeys, stackDotClassNames }: TStackedBarChartProps) => {
|
||||
// derived values
|
||||
const filteredPayload = payload?.filter((item: any) => item.dataKey && stackKeys.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: any) => (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
{stackDotClassNames[item?.dataKey] && (
|
||||
<div className={cn("size-2 rounded-full", stackDotClassNames[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
web/core/components/core/charts/tree-map/index.ts
Normal file
1
web/core/components/core/charts/tree-map/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
119
web/core/components/core/charts/tree-map/map-content.tsx
Normal file
119
web/core/components/core/charts/tree-map/map-content.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
const AVG_WIDTH_RATIO = 0.7;
|
||||
|
||||
const isTruncateRequired = (text: string, maxWidth: number, fontSize: number) => {
|
||||
// Approximate width per character (this is an estimation)
|
||||
const avgCharWidth = fontSize * AVG_WIDTH_RATIO;
|
||||
const maxChars = Math.floor(maxWidth / avgCharWidth);
|
||||
|
||||
return text.length > maxChars;
|
||||
};
|
||||
|
||||
const truncateText = (text: string, maxWidth: number, fontSize: number) => {
|
||||
// Approximate width per character (this is an estimation)
|
||||
const avgCharWidth = fontSize * AVG_WIDTH_RATIO;
|
||||
const maxChars = Math.floor(maxWidth / avgCharWidth);
|
||||
if (text.length > maxChars) {
|
||||
return text.slice(0, maxChars - 2) + "...";
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
export const CustomTreeMapContent = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
name,
|
||||
value,
|
||||
fillColor,
|
||||
fillClassName,
|
||||
textClassName,
|
||||
icon,
|
||||
}: any) => {
|
||||
const RADIUS = 10;
|
||||
const PADDING = 5;
|
||||
// Apply padding to dimensions
|
||||
const pX = x + PADDING;
|
||||
const pY = y + PADDING;
|
||||
const pWidth = width - PADDING * 2;
|
||||
const pHeight = height - PADDING * 2;
|
||||
// Text padding from the left edge
|
||||
const TEXT_PADDING_LEFT = 12;
|
||||
const TEXT_PADDING_RIGHT = 12;
|
||||
// Icon size and spacing
|
||||
const ICON_SIZE = 16;
|
||||
const ICON_TEXT_GAP = 6;
|
||||
// Available width for the text
|
||||
const iconSpace = icon ? ICON_SIZE + ICON_TEXT_GAP : 0;
|
||||
const availableWidth = pWidth - TEXT_PADDING_LEFT - TEXT_PADDING_RIGHT - iconSpace;
|
||||
// Truncate text based on available width
|
||||
// 12.6px for text-sm
|
||||
const isTextTruncated = typeof name === "string" ? isTruncateRequired(name, availableWidth, 12.6) : name;
|
||||
const truncatedName = typeof name === "string" ? truncateText(name, availableWidth, 12.6) : name;
|
||||
|
||||
if (!name) return; // To remove the total count
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${pX + RADIUS},${pY}
|
||||
L${pX + pWidth - RADIUS},${pY}
|
||||
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + RADIUS}
|
||||
L${pX + pWidth},${pY + pHeight - RADIUS}
|
||||
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - RADIUS},${pY + pHeight}
|
||||
L${pX + RADIUS},${pY + pHeight}
|
||||
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - RADIUS}
|
||||
L${pX},${pY + RADIUS}
|
||||
Q${pX},${pY} ${pX + RADIUS},${pY}
|
||||
`}
|
||||
className={cn("transition-colors duration-200 hover:opacity-90 cursor-pointer", fillClassName)}
|
||||
fill={fillColor ?? "currentColor"}
|
||||
/>
|
||||
<Tooltip tooltipContent={name} className="outline-none" disabled={!isTextTruncated}>
|
||||
<g>
|
||||
{icon && (
|
||||
<foreignObject
|
||||
x={pX + TEXT_PADDING_LEFT}
|
||||
y={pY + TEXT_PADDING_LEFT}
|
||||
width={ICON_SIZE}
|
||||
height={ICON_SIZE}
|
||||
className={textClassName || "text-custom-text-300"}
|
||||
>
|
||||
{React.cloneElement(icon, {
|
||||
className: cn("size-4", icon?.props?.className),
|
||||
"aria-hidden": true,
|
||||
})}
|
||||
</foreignObject>
|
||||
)}
|
||||
<text
|
||||
x={pX + TEXT_PADDING_LEFT + (icon ? ICON_SIZE + ICON_TEXT_GAP : 0)}
|
||||
y={pY + TEXT_PADDING_LEFT * 2}
|
||||
textAnchor="start"
|
||||
className={cn(
|
||||
"text-sm font-light truncate max-w-[90%] tracking-wider",
|
||||
textClassName || "text-custom-text-300"
|
||||
)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{truncatedName}
|
||||
</text>
|
||||
</g>
|
||||
</Tooltip>
|
||||
<text
|
||||
x={pX + TEXT_PADDING_LEFT}
|
||||
y={pY + pHeight - TEXT_PADDING_LEFT}
|
||||
textAnchor="start"
|
||||
className={cn("text-xs", textClassName || "text-custom-text-300")}
|
||||
fill="currentColor"
|
||||
>
|
||||
{value?.toLocaleString()}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
30
web/core/components/core/charts/tree-map/root.tsx
Normal file
30
web/core/components/core/charts/tree-map/root.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { Treemap, ResponsiveContainer } from "recharts";
|
||||
// plane imports
|
||||
import { TreeMapChartProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { CustomTreeMapContent } from "./map-content";
|
||||
|
||||
export const TreeMapChart = React.memo((props: TreeMapChartProps) => {
|
||||
const { data, className = "w-full h-96", isAnimationActive = false } = 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}
|
||||
/>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TreeMapChart.displayName = "TreeMapChart";
|
||||
Reference in New Issue
Block a user