"classnames": "2.3.2",
"clipboard": "2.0.11",
"d3-array": "3.2.3",
+ "d3-hierarchy": "3.1.2",
"d3-scale": "4.0.2",
"d3-selection": "3.0.0",
"d3-shape": "3.2.0",
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { hierarchy as d3Hierarchy, treemap as d3Treemap } from 'd3-hierarchy';
+import { sortBy } from 'lodash';
+import tw from 'twin.macro';
+import { PopupPlacement } from '../helpers/positioning';
+import { TreeMapRect } from './TreeMapRect';
+
+export interface TreeMapItem<T> {
+ color?: string;
+ gradient?: string;
+ icon?: React.ReactNode;
+ key: string;
+ label: string;
+ size: number;
+ sourceData?: T;
+ tooltip?: React.ReactNode;
+}
+
+interface HierarchicalTreemapItem<T> extends TreeMapItem<T> {
+ children?: Array<TreeMapItem<T>>;
+}
+
+export interface TreeMapProps<T> {
+ height: number;
+ items: Array<TreeMapItem<T>>;
+ onRectangleClick?: (item: TreeMapItem<T>) => void;
+ width: number;
+}
+
+export function TreeMap<T = unknown>(props: TreeMapProps<T>) {
+ function mostCommitPrefix(labels: string[]) {
+ const sortedLabels = sortBy(labels.slice(0));
+ const firstLabel = sortedLabels[0];
+ const firstLabelLength = firstLabel.length;
+ const lastLabel = sortedLabels[sortedLabels.length - 1];
+ let i = 0;
+ while (i < firstLabelLength && firstLabel.charAt(i) === lastLabel.charAt(i)) {
+ i++;
+ }
+ const prefix = firstLabel.substring(0, i);
+ const prefixTokens = prefix.split(/[\s\\/]/);
+ const lastPrefixPart = prefixTokens[prefixTokens.length - 1];
+ return prefix.substring(0, prefix.length - lastPrefixPart.length);
+ }
+
+ function handleClick(data: TreeMapItem<T>) {
+ if (props.onRectangleClick) {
+ props.onRectangleClick(data);
+ }
+ }
+
+ const { items, height, width } = props;
+ const hierarchy = d3Hierarchy({ children: items } as HierarchicalTreemapItem<T>)
+ .sum((d) => d.size)
+ .sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
+
+ const treemap = d3Treemap<TreeMapItem<T>>().round(true).size([width, height]);
+
+ const nodes = treemap(hierarchy).leaves();
+ const prefix = mostCommitPrefix(items.map((item) => item.label));
+ const halfWidth = width / 2;
+ return (
+ <StyledContainer style={{ width, height }}>
+ {nodes.map(({ data, y0, y1, x0, x1 }) => (
+ <TreeMapRect
+ fill={data.color}
+ gradient={data.gradient}
+ height={y1 - y0}
+ icon={data.icon}
+ itemKey={data.key}
+ key={data.key}
+ label={data.label}
+ onClick={() => {
+ handleClick(data);
+ }}
+ placement={x0 === 0 || x1 < halfWidth ? PopupPlacement.Right : PopupPlacement.Left}
+ prefix={prefix}
+ tooltip={data.tooltip}
+ width={x1 - x0}
+ x={x0}
+ y={y0}
+ />
+ ))}
+ </StyledContainer>
+ );
+}
+
+const StyledContainer = styled.ul`
+ ${tw`sw-relative`}
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { scaleLinear } from 'd3-scale';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeColor } from '../helpers';
+import { Key } from '../helpers/keyboard';
+import { BasePlacement, PopupPlacement } from '../helpers/positioning';
+import Tooltip from './Tooltip';
+
+const SIZE_SCALE = scaleLinear().domain([3, 15]).range([11, 18]).clamp(true);
+const TEXT_VISIBLE_AT_WIDTH = 40;
+const TEXT_VISIBLE_AT_HEIGHT = 45;
+const ICON_VISIBLE_AT_WIDTH = 24;
+const ICON_VISIBLE_AT_HEIGHT = 26;
+
+interface Props {
+ fill?: string;
+ gradient?: string;
+ height: number;
+ icon?: React.ReactNode;
+ itemKey: string;
+ label: string;
+ onClick: (item: string) => void;
+ placement?: BasePlacement;
+ prefix: string;
+ tooltip?: React.ReactNode;
+ width: number;
+ x: number;
+ y: number;
+}
+
+export function TreeMapRect(props: Props) {
+ function handleRectClick() {
+ props.onClick(props.itemKey);
+ }
+
+ function handleRectKeyDown(event: React.KeyboardEvent<HTMLAnchorElement>) {
+ if (event.key === Key.Enter) {
+ props.onClick(props.itemKey);
+ }
+ }
+
+ function renderCell() {
+ const cellStyles = {
+ left: props.x,
+ top: props.y,
+ width: props.width,
+ height: props.height,
+ backgroundColor: props.fill,
+ backgroundImage: props.gradient,
+ fontSize: SIZE_SCALE(props.width / props.label.length),
+ lineHeight: `${props.height}px`,
+ };
+ const isTextVisible =
+ props.width >= TEXT_VISIBLE_AT_WIDTH && props.height >= TEXT_VISIBLE_AT_HEIGHT;
+ const isIconVisible =
+ props.width >= ICON_VISIBLE_AT_WIDTH && props.height >= ICON_VISIBLE_AT_HEIGHT;
+
+ return (
+ <StyledCell style={cellStyles}>
+ <StyledCellLink
+ aria-label={props.prefix ? `${props.prefix} ${props.label}` : props.label}
+ onClick={handleRectClick}
+ onKeyDown={handleRectKeyDown}
+ role="link"
+ tabIndex={0}
+ >
+ <StyledCellLabel width={props.width}>
+ {isIconVisible && <span className="shrink-0">{props.icon}</span>}
+ {isTextVisible &&
+ (props.prefix ? (
+ <span className="treemap-text">
+ {props.prefix}
+ <br />
+ {props.label.substring(props.prefix.length)}
+ </span>
+ ) : (
+ <span className="treemap-text">{props.label}</span>
+ ))}
+ </StyledCellLabel>
+ </StyledCellLink>
+ </StyledCell>
+ );
+ }
+
+ const { placement, tooltip } = props;
+ return (
+ <Tooltip overlay={tooltip} placement={placement ?? PopupPlacement.Left}>
+ {renderCell()}
+ </Tooltip>
+ );
+}
+
+const StyledCell = styled.li`
+ ${tw`sw-absolute`};
+ ${tw`sw-box-border`};
+
+ border-right: 1px solid #fff;
+ border-bottom: 1px solid #fff;
+`;
+
+const StyledCellLink = styled.a`
+ ${tw`sw-w-full sw-h-full`};
+ ${tw`sw-border-0`};
+ ${tw`sw-flex sw-flex-col sw-items-center sw-justify-center`};
+
+ color: ${themeColor('pageContent')};
+
+ &:hover,
+ &:active,
+ &:focus {
+ ${tw`sw-border-0`};
+ outline: none;
+ }
+
+ &:focus .treemap-text,
+ &:hover .treemap-text {
+ ${tw`sw-underline`};
+ }
+`;
+
+const StyledCellLabel = styled.div<{ width: number }>`
+ ${tw`sw-flex sw-flex-wrap sw-justify-center sw-items-center sw-gap-2`};
+
+ line-height: 1.2;
+ max-width: ${({ width }) => width}px;
+
+ .treemap-text {
+ ${tw`sw-shrink sw-overflow-hidden sw-whitespace-nowrap sw-text-left sw-text-ellipsis`};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../helpers/testUtils';
+import { TreeMap, TreeMapProps } from '../TreeMap';
+
+it('should render correctly and forward click event', async () => {
+ const user = userEvent.setup();
+ const items = [
+ {
+ key: '1',
+ size: 10,
+ color: '#777',
+ label: 'SonarQube_Server',
+ },
+ {
+ key: '2',
+ size: 30,
+ color: '#777',
+ label: 'SonarQube_Web',
+ sourceData: 123,
+ },
+ {
+ key: '3',
+ size: 20,
+ gradient: '#777',
+ label: 'SonarQube_Search',
+ },
+ ];
+ const onRectangleClick = jest.fn();
+ const { container } = renderTreeMap({
+ items,
+ onRectangleClick,
+ });
+
+ expect(container).toMatchSnapshot();
+
+ await user.click(screen.getByText('SonarQube_Web'));
+ expect(onRectangleClick).toHaveBeenCalledTimes(1);
+ expect(onRectangleClick).toHaveBeenCalledWith(items[1]);
+});
+
+function renderTreeMap(props: Partial<TreeMapProps<unknown>>) {
+ return render(
+ <TreeMap height={100} items={[]} onRectangleClick={jest.fn()} width={100} {...props} />
+ );
+}
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly and forward click event 1`] = `
+.emotion-0 {
+ position: relative;
+}
+
+.emotion-2 {
+ position: absolute;
+ box-sizing: border-box;
+ border-right: 1px solid #fff;
+ border-bottom: 1px solid #fff;
+}
+
+.emotion-4 {
+ height: 100%;
+ width: 100%;
+ border-width: 0px;
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-direction: column;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -ms-flex-pack: center;
+ -webkit-justify-content: center;
+ justify-content: center;
+ color: rgb(62,67,87);
+}
+
+.emotion-4:hover,
+.emotion-4:active,
+.emotion-4:focus {
+ border-width: 0px;
+ outline: none;
+}
+
+.emotion-4:focus .treemap-text,
+.emotion-4:hover .treemap-text {
+ text-decoration-line: underline;
+}
+
+.emotion-6 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-flex-wrap: wrap;
+ -webkit-flex-wrap: wrap;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -ms-flex-pack: center;
+ -webkit-justify-content: center;
+ justify-content: center;
+ gap: 0.5rem;
+ line-height: 1.2;
+ max-width: 83px;
+}
+
+.emotion-6 .treemap-text {
+ -webkit-flex-shrink: 1;
+ -ms-flex-negative: 1;
+ flex-shrink: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: left;
+}
+
+.emotion-18 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-flex-wrap: wrap;
+ -webkit-flex-wrap: wrap;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -webkit-align-items: center;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -ms-flex-pack: center;
+ -webkit-justify-content: center;
+ justify-content: center;
+ gap: 0.5rem;
+ line-height: 1.2;
+ max-width: 17px;
+}
+
+.emotion-18 .treemap-text {
+ -webkit-flex-shrink: 1;
+ -ms-flex-negative: 1;
+ flex-shrink: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: left;
+}
+
+<div>
+ <ul
+ class="emotion-0 emotion-1"
+ style="width: 100px; height: 100px;"
+ >
+ <li
+ class="emotion-2 emotion-3"
+ style="left: 0px; top: 0px; width: 83px; height: 60px; background-color: rgb(119, 119, 119); font-size: 12.974358974358974px; line-height: 60px;"
+ >
+ <a
+ aria-label="SonarQube_Web"
+ class="emotion-4 emotion-5"
+ role="link"
+ tabindex="0"
+ >
+ <div
+ class="emotion-6 emotion-7"
+ width="83"
+ >
+ <span
+ class="shrink-0"
+ />
+ <span
+ class="treemap-text"
+ >
+ SonarQube_Web
+ </span>
+ </div>
+ </a>
+ </li>
+ <li
+ class="emotion-2 emotion-3"
+ style="left: 0px; top: 60px; width: 83px; height: 40px; font-size: 12.276041666666668px; line-height: 40px;"
+ >
+ <a
+ aria-label="SonarQube_Search"
+ class="emotion-4 emotion-5"
+ role="link"
+ tabindex="0"
+ >
+ <div
+ class="emotion-6 emotion-7"
+ width="83"
+ >
+ <span
+ class="shrink-0"
+ />
+ </div>
+ </a>
+ </li>
+ <li
+ class="emotion-2 emotion-3"
+ style="left: 83px; top: 0px; width: 17px; height: 100px; background-color: rgb(119, 119, 119); font-size: 11px; line-height: 100px;"
+ >
+ <a
+ aria-label="SonarQube_Server"
+ class="emotion-4 emotion-5"
+ role="link"
+ tabindex="0"
+ >
+ <div
+ class="emotion-18 emotion-7"
+ width="17"
+ />
+ </a>
+ </li>
+ </ul>
+</div>
+`;
export * from './Text';
export { ToggleButton } from './ToggleButton';
export { TopBar } from './TopBar';
+export * from './TreeMap';
+export * from './TreeMapRect';
export * from './buttons';
export { ClipboardIconButton } from './clipboard';
export * from './code-line/LineCoverage';