feat: init
This commit is contained in:
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";
|
||||
205
packages/propel/src/charts/area-chart/root.tsx
Normal file
205
packages/propel/src/charts/area-chart/root.tsx
Normal 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";
|
||||
184
packages/propel/src/charts/bar-chart/bar.tsx
Normal file
184
packages/propel/src/charts/bar-chart/bar.tsx
Normal 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";
|
||||
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";
|
||||
209
packages/propel/src/charts/bar-chart/root.tsx
Normal file
209
packages/propel/src/charts/bar-chart/root.tsx
Normal 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";
|
||||
78
packages/propel/src/charts/components/legend.tsx
Normal file
78
packages/propel/src/charts/components/legend.tsx
Normal 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";
|
||||
45
packages/propel/src/charts/components/tick.tsx
Normal file
45
packages/propel/src/charts/components/tick.tsx
Normal 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";
|
||||
59
packages/propel/src/charts/components/tooltip.tsx
Normal file
59
packages/propel/src/charts/components/tooltip.tsx
Normal 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";
|
||||
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";
|
||||
181
packages/propel/src/charts/line-chart/root.tsx
Normal file
181
packages/propel/src/charts/line-chart/root.tsx
Normal 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";
|
||||
32
packages/propel/src/charts/pie-chart/active-shape.tsx
Normal file
32
packages/propel/src/charts/pie-chart/active-shape.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
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";
|
||||
151
packages/propel/src/charts/pie-chart/root.tsx
Normal file
151
packages/propel/src/charts/pie-chart/root.tsx
Normal 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";
|
||||
40
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal file
40
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal 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";
|
||||
1
packages/propel/src/charts/radar-chart/index.ts
Normal file
1
packages/propel/src/charts/radar-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
95
packages/propel/src/charts/radar-chart/root.tsx
Normal file
95
packages/propel/src/charts/radar-chart/root.tsx
Normal 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 };
|
||||
1
packages/propel/src/charts/scatter-chart/index.ts
Normal file
1
packages/propel/src/charts/scatter-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
166
packages/propel/src/charts/scatter-chart/root.tsx
Normal file
166
packages/propel/src/charts/scatter-chart/root.tsx
Normal 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";
|
||||
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 "../../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>
|
||||
);
|
||||
};
|
||||
44
packages/propel/src/charts/tree-map/root.tsx
Normal file
44
packages/propel/src/charts/tree-map/root.tsx
Normal 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";
|
||||
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 "../../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";
|
||||
Reference in New Issue
Block a user