Skip to content

Commit

Permalink
[#noissue] feat: add axis and time
Browse files Browse the repository at this point in the history
  • Loading branch information
BillionaireDY authored and binDongKim committed Jun 4, 2024
1 parent 79829a4 commit cf789d6
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 140 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { FlameGraphConfigContext } from './FlameGraphConfigContext';

export interface FlameAxisProps {
width: number;
}

export const FlameAxis = ({ width }: FlameAxisProps) => {
const { config } = React.useContext(FlameGraphConfigContext);
const { padding, color, yAxisCount } = config;
const actualWidth = width - padding.left - padding.right;

return (
<>
{Array.from(Array(yAxisCount)).map((_, i) => {
return (
<line
key={i}
x1={(actualWidth / yAxisCount) * i + padding.left}
y1={0}
x2={(actualWidth / yAxisCount) * i + padding.left}
y2="100%"
stroke={color.axis}
/>
);
})}
<line
x1={width - padding.right}
y1={0}
x2={width - padding.right}
y2="100%"
stroke={color.axis}
/>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React from 'react';
import { throttle } from 'lodash';
import cloneDeep from 'lodash.clonedeep';
import { FlameNode, FlameNodeClickHandler, FlameNodeType } from './FlameNode';
import { FlameAxis } from './FlameAxis';
import { FlameGraphConfigContext, flameGraphDefaultConfig } from './FlameGraphConfigContext';
import { FlameTimeline } from './FlameTimeline';

export interface FlameGraphProps<T> {
data: FlameNodeType<T>[];
start?: number;
end?: number;
onClickNode?: FlameNodeClickHandler<T>;
}

export const FlameGraph = <T,>({ data, start = 0, end = 0, onClickNode }: FlameGraphProps<T>) => {
const widthOffset = end - start || 1;
const [config] = React.useState(flameGraphDefaultConfig);

const prevDepth = React.useRef(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const svgRef = React.useRef<SVGSVGElement>(null);
const [containerWidth, setWidth] = React.useState(0);
const containerHeight = getContainerHeight();
const widthRatio = (containerWidth - config.padding.left - config.padding.right) / widthOffset;

React.useEffect(() => {
if (containerRef.current) {
const throttledCalculateHeight = throttle(() => {
setWidth(containerRef.current?.clientWidth || containerWidth);
}, 200);

const resizeObserver = new ResizeObserver(() => {
throttledCalculateHeight();
});

resizeObserver.observe(containerRef.current);

return () => {
resizeObserver.disconnect();
};
}
}, []);

const styleNode = (node: FlameNodeType<T>, depth = 0, xOffset = 0, yOffset = 0) => {
const children = node.children || [];
const { height, padding } = config;
const widthPerNode = node.duration * widthRatio;
node.x = (node.start - start) * widthRatio + padding.left;
node.y = depth * height.node + yOffset + padding.top;
node.width = widthPerNode;
node.height = height.node;
let currentXOffset = xOffset;

children.forEach((child) => {
styleNode(child, depth + 1, currentXOffset, yOffset);
currentXOffset += widthPerNode;
});
};

function getContainerHeight() {
const { height, padding } = config;
return data.reduce((acc, curr) => {
return acc + (getMaxDepth(curr) + 1) * height.node + padding.group;
}, 2 * padding.bottom);
}

function getMaxDepth(node: FlameNodeType<T>, depth = 0) {
let MaxDepth = depth;

node.children.forEach((child) => {
const childDepth = getMaxDepth(child, depth + 1);

MaxDepth = Math.max(MaxDepth, childDepth);
});

return MaxDepth;
}

return (
<FlameGraphConfigContext.Provider value={{ config }}>
<div className="relative w-full h-full overflow-x-hidden" ref={containerRef}>
<svg width={containerWidth} height={config.height.timeline} className="shadow-md">
<FlameTimeline width={containerWidth} start={start} end={end} />
</svg>
<div className="w-full h-[calc(100%-3rem)] overflow-y-auto overflow-x-hidden">
<svg width={containerWidth} height={containerHeight} ref={svgRef}>
<FlameAxis width={containerWidth} />
{containerWidth &&
// group별 렌더링
data.map((node, i) => {
if (i === 0) prevDepth.current = 0;

const { height, padding, color } = config;
const newNode = cloneDeep(node);
const currentNodeDepth = getMaxDepth(newNode);
const yOffset = prevDepth.current * height.node + padding.group * i;

styleNode(newNode, 0, 0, yOffset);
prevDepth.current = currentNodeDepth + prevDepth.current + 1;

return (
<React.Fragment key={node.id}>
<FlameNode node={newNode} svgRef={svgRef} onClickNode={onClickNode} />
<line
x1={0}
y1={yOffset - padding.group / 2 + padding.top}
x2={containerWidth}
y2={yOffset - padding.group / 2 + padding.top}
stroke={color.axis}
/>
</React.Fragment>
);
})}
</svg>
</div>
</div>
</FlameGraphConfigContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import { colors } from '../../constant';

export const flameGraphDefaultConfig = {
height: {
node: 20,
timeline: 48,
},
padding: {
top: 8,
bottom: 0,
right: 10,
left: 10,
group: 20,
},
color: {
axis: colors.gray[300],
time: colors.gray[400],
},
yAxisCount: 10,
};

export const FlameGraphConfigContext = React.createContext<{
config: typeof flameGraphDefaultConfig;
// setConfig: React.Dispatch<React.SetStateAction<typeof flameGraphDefaultConfig>>;
}>({
config: flameGraphDefaultConfig,
// setConfig: () => {},
});
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
import React from 'react';
import { getRandomColor, getDarkenHexColor, getContrastingTextColor } from '../../../lib/colors';
import { FlameNode as FlameNodeType } from './FlameGraph';
import { getRandomColor, getDarkenHexColor, getContrastingTextColor } from '../../lib/colors';

export interface FlameNodeType<T> {
id: string;
name: string;
duration: number;
start: number;
nodeStyle?: React.CSSProperties;
textStyle?: React.CSSProperties;
x?: number;
y?: number;
width?: number;
height?: number;
detail: T;
children: FlameNodeType<T>[];
}

export type FlameNodeClickHandler<T> = (node: FlameNodeType<T | unknown>) => void;

export interface FlameNodeProps<T> {
node: FlameNodeType<T>;
svgRef?: React.RefObject<SVGSVGElement>;
onClickNode?: (e: React.MouseEvent, node: FlameNodeType<T>) => void;
onClickNode?: FlameNodeClickHandler<T>;
}

export const FlameNode = <T,>({ node, svgRef, onClickNode }: FlameNodeProps<T>) => {
export const FlameNode = React.memo(<T,>({ node, svgRef, onClickNode }: FlameNodeProps<T>) => {
const { x = 0, y = 0, width = 1, height = 1, name, nodeStyle, textStyle } = node;
const colorMap = React.useRef<{ [key: string]: { color: string; hoverColor: string } }>({});
const color = colorMap.current[name]?.color || getRandomColor();
const hoverColor = colorMap.current[name]?.hoverColor || getDarkenHexColor(color);
const ellipsizedText = getEllipsizedText(name, width, svgRef);
const ellipsizedText = React.useMemo(
() => getEllipsizedText(name, width, svgRef),
[name, width, svgRef],
);
const [isHover, setHover] = React.useState(false);

if (!colorMap.current[name]) colorMap.current[name] = { color, hoverColor };
Expand All @@ -24,9 +43,8 @@ export const FlameNode = <T,>({ node, svgRef, onClickNode }: FlameNodeProps<T>)
className="cursor-pointer"
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onClick={(e) => {
console.log(e);
onClickNode?.(e, node);
onClick={() => {
onClickNode?.(node);
}}
>
<rect
Expand All @@ -51,12 +69,17 @@ export const FlameNode = <T,>({ node, svgRef, onClickNode }: FlameNodeProps<T>)
</text>
</g>
{node.children &&
node.children.map((node, i) => (
<FlameNode key={i} node={node} svgRef={svgRef} onClickNode={onClickNode} />
node.children.map((childNode, i) => (
<FlameNode
key={i}
node={childNode as FlameNodeType<T>}
svgRef={svgRef}
onClickNode={onClickNode}
/>
))}
</>
);
};
});

const getEllipsizedText = (text: string, maxWidth = 1, svgRef?: React.RefObject<SVGSVGElement>) => {
if (!svgRef?.current) return text;
Expand All @@ -69,12 +92,27 @@ const getEllipsizedText = (text: string, maxWidth = 1, svgRef?: React.RefObject<
svg.appendChild(textElement);

let ellipsizedText = text;
while (textElement.getComputedTextLength() > maxWidth && ellipsizedText.length > 0) {
ellipsizedText = ellipsizedText.slice(0, -1);
textElement.textContent = `${ellipsizedText}...`;

if (maxWidth < textElement.getComputedTextLength()) {
let low = 0;
let high = text.length;

while (low < high) {
const mid = Math.floor((low + high) / 2);
const candidateText = text.slice(0, mid);
textElement.textContent = candidateText;

if (textElement.getComputedTextLength() <= maxWidth) {
low = mid + 1;
} else {
high = mid;
}
}

ellipsizedText = text.slice(0, low - 1) + '...';
}

svg.removeChild(textElement);
return textElement.textContent;
return ellipsizedText;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { FlameAxis } from './FlameAxis';
import { FlameGraphConfigContext } from './FlameGraphConfigContext';

export interface FlameTimelineProps {
width: number;
start: number;
end: number;
}

export const FlameTimeline = ({ width, start, end }: FlameTimelineProps) => {
const { config } = React.useContext(FlameGraphConfigContext);
const { padding, yAxisCount } = config;
const actualWidth = width - padding.left - padding.right;
const timeGap = end - start;

return (
<>
{Array.from(Array(yAxisCount)).map((_, i) => {
return (
<text
key={i}
x={(actualWidth / yAxisCount) * i + 14}
y={12}
fontSize={'0.625rem'}
letterSpacing={-0.5}
fill={config.color.time}
>
+{((timeGap / yAxisCount) * i).toFixed(1)}ms
</text>
);
})}
<FlameAxis width={width} />;
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FlameGraph';
Loading

0 comments on commit cf789d6

Please sign in to comment.