]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19164 Add BarChart component
authorJeremy Davis <jeremy.davis@sonarsource.com>
Mon, 1 May 2023 13:22:48 +0000 (15:22 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 3 May 2023 20:02:57 +0000 (20:02 +0000)
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/components/BarChart.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/BarChart-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/yarn.lock

index 397174a46a8b51ab6c5df24aa7edd39eead3dfcb..8e280e6794eb7a9aa7127e0d5e8abc8b9bd0b0d0 100644 (file)
@@ -50,6 +50,8 @@
     "@primer/octicons-react": "18.3.0",
     "classnames": "2.3.2",
     "clipboard": "2.0.11",
+    "d3-array": "3.2.3",
+    "d3-scale": "4.0.2",
     "d3-shape": "3.2.0",
     "lodash": "4.17.21",
     "react": "17.0.2",
diff --git a/server/sonar-web/design-system/src/components/BarChart.tsx b/server/sonar-web/design-system/src/components/BarChart.tsx
new file mode 100644 (file)
index 0000000..9b46067
--- /dev/null
@@ -0,0 +1,163 @@
+/*
+ * 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 { max } from 'd3-array';
+import { ScaleBand, ScaleLinear, scaleBand, scaleLinear } from 'd3-scale';
+import { themeColor } from '../helpers';
+
+interface DataPoint {
+  description: string;
+  tooltip?: string;
+  x: number;
+  y: number;
+}
+
+interface Props<T> {
+  barsWidth: number;
+  data: Array<DataPoint & T>;
+  height: number;
+  onBarClick: (point: DataPoint & T) => void;
+  padding?: [number, number, number, number];
+  width: number;
+  xValues?: string[];
+}
+
+export function BarChart<T>(props: Props<T>) {
+  const { barsWidth, data, width, height, padding = [10, 10, 10, 10], xValues } = props;
+
+  const availableWidth = width - padding[1] - padding[3];
+  const availableHeight = height - padding[0] - padding[2];
+
+  const innerPadding = (availableWidth - barsWidth * data.length) / (data.length - 1);
+  const relativeInnerPadding = innerPadding / (innerPadding + barsWidth);
+
+  const maxY = max(data, (d) => d.y) as number;
+  const xScale = scaleBand<number>()
+    .domain(data.map((d) => d.x))
+    .range([0, availableWidth])
+    .paddingInner(relativeInnerPadding);
+  const yScale = scaleLinear().domain([0, maxY]).range([availableHeight, 0]);
+
+  return (
+    <svg className="bar-chart" height={height} width={width}>
+      <g transform={`translate(${padding[3]}, ${padding[0]})`}>
+        <Xvalues
+          data={data}
+          onBarClick={props.onBarClick}
+          xScale={xScale}
+          xValues={xValues}
+          yScale={yScale}
+        />
+        <Bars
+          barsWidth={barsWidth}
+          data={data}
+          onBarClick={props.onBarClick}
+          xScale={xScale}
+          yScale={yScale}
+        />
+      </g>
+    </svg>
+  );
+}
+
+function Xvalues<T>(
+  props: {
+    xScale: ScaleBand<number>;
+    yScale: ScaleLinear<number, number>;
+  } & Pick<Props<T>, 'data' | 'xValues' | 'onBarClick'>
+) {
+  const { data, xValues = [], xScale, yScale } = props;
+
+  if (!xValues.length) {
+    return null;
+  }
+
+  const ticks = xValues.map((value, index) => {
+    const point = data[index];
+    const x = Math.round((xScale(point.x) as number) + xScale.bandwidth() / 2);
+    const y = yScale(point.y);
+
+    return (
+      <BarChartTick
+        className="sw-body-sm sw-cursor-pointer"
+        dy="-0.5em"
+        // eslint-disable-next-line react/no-array-index-key
+        key={index}
+        onClick={() => {
+          props.onBarClick(point);
+        }}
+        x={x}
+        y={y}
+      >
+        {point.tooltip && <title>{point.tooltip}</title>}
+        {value}
+      </BarChartTick>
+    );
+  });
+  return <g>{ticks}</g>;
+}
+
+function Bars<T>(
+  props: {
+    xScale: ScaleBand<number>;
+    yScale: ScaleLinear<number, number>;
+  } & Pick<Props<T>, 'data' | 'barsWidth' | 'onBarClick'>
+) {
+  const { barsWidth, data, xScale, yScale } = props;
+
+  const bars = data.map((point, index) => {
+    const x = Math.round(xScale(point.x) as number);
+    const maxY = yScale.range()[0];
+    const y = Math.round(yScale(point.y)) - /* minimum bar height */ 1;
+    const height = maxY - y;
+    const rect = (
+      <BarChartBar
+        aria-label={point.description}
+        className="sw-cursor-pointer"
+        height={height}
+        // eslint-disable-next-line react/no-array-index-key
+        key={index}
+        onClick={() => {
+          props.onBarClick(point);
+        }}
+        width={barsWidth}
+        x={x}
+        y={y}
+      >
+        <title>{point.tooltip}</title>
+      </BarChartBar>
+    );
+    return rect;
+  });
+  return <g>{bars}</g>;
+}
+
+const BarChartTick = styled.text`
+  fill: ${themeColor('pageContentLight')};
+  text-anchor: middle;
+`;
+
+const BarChartBar = styled.rect`
+  fill: ${themeColor('primary')};
+
+  &:hover {
+    fill: ${themeColor('primaryDark')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/BarChart-test.tsx b/server/sonar-web/design-system/src/components/__tests__/BarChart-test.tsx
new file mode 100644 (file)
index 0000000..128eb11
--- /dev/null
@@ -0,0 +1,63 @@
+/*
+ * 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 { FCProps } from '../../types/misc';
+import { BarChart } from '../BarChart';
+
+it('renders chart correctly', async () => {
+  const user = userEvent.setup();
+  const onBarClick = jest.fn();
+  renderChart({ onBarClick });
+
+  const p1 = screen.getByLabelText('point 1');
+  expect(p1).toBeInTheDocument();
+  await user.click(p1);
+
+  expect(onBarClick).toHaveBeenCalledWith({ description: 'point 1', x: 1, y: 20 });
+});
+
+it('displays values', () => {
+  const xValues = ['hi', '43', 'testing'];
+  renderChart({ xValues });
+
+  expect(screen.getByText(xValues[0])).toBeInTheDocument();
+  expect(screen.getByText(xValues[1])).toBeInTheDocument();
+  expect(screen.getByText(xValues[2])).toBeInTheDocument();
+});
+
+function renderChart(overrides: Partial<FCProps<typeof BarChart>> = {}) {
+  return render(
+    <BarChart
+      barsWidth={20}
+      data={[
+        { x: 1, y: 20, description: 'point 1' },
+        { x: 2, y: 40, description: 'apex' },
+        { x: 3, y: 31, description: 'point 3' },
+      ]}
+      height={75}
+      onBarClick={jest.fn()}
+      width={200}
+      {...overrides}
+    />
+  );
+}
index c4c52b9436af5cb7b75f56a7ac20306b0f0666d2..e1e3b1572d4f56b8106e60893ce0bf9ac0c73440 100644 (file)
@@ -21,6 +21,7 @@
 export * from './Accordion';
 export * from './Avatar';
 export { Badge } from './Badge';
+export { BarChart } from './BarChart';
 export * from './Card';
 export * from './CoverageIndicator';
 export { DeferredSpinner } from './DeferredSpinner';
index 9991d747aed05aa31136dc4c701278ef29a966ba..9fa9de6be630af9850e0a6f2c6d067901860af09 100644 (file)
@@ -6150,6 +6150,8 @@ __metadata:
     "@primer/octicons-react": 18.3.0
     classnames: 2.3.2
     clipboard: 2.0.11
+    d3-array: 3.2.3
+    d3-scale: 4.0.2
     d3-shape: 3.2.0
     lodash: 4.17.21
     react: 17.0.2