]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19387 Add new Histogram component
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Fri, 26 May 2023 06:37:44 +0000 (08:37 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 26 May 2023 20:03:09 +0000 (20:03 +0000)
server/sonar-web/design-system/src/components/Histogram.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Histogram-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/__snapshots__/Histogram-test.tsx.snap [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts

diff --git a/server/sonar-web/design-system/src/components/Histogram.tsx b/server/sonar-web/design-system/src/components/Histogram.tsx
new file mode 100644 (file)
index 0000000..7298c69
--- /dev/null
@@ -0,0 +1,151 @@
+/*
+ * 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.
+ */
+/* eslint-disable @typescript-eslint/prefer-optional-chain */
+import styled from '@emotion/styled';
+import { max } from 'd3-array';
+import { scaleBand, ScaleBand, scaleLinear, ScaleLinear } from 'd3-scale';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeColor, themeContrast } from '../helpers';
+import Tooltip, { TooltipWrapper } from './Tooltip';
+
+interface Props {
+  bars: number[];
+  height: number;
+  leftAlignTicks?: boolean;
+  padding?: [number, number, number, number];
+  width: number;
+  yTicks?: string[];
+  yTooltips?: string[];
+  yValues?: string[];
+}
+
+const BAR_HEIGHT = 10;
+const DEFAULT_PADDING = [10, 10, 10, 10];
+
+type XScale = ScaleLinear<number, number>;
+type YScale = ScaleBand<number>;
+
+export class Histogram extends React.PureComponent<Props> {
+  renderBar(d: number, index: number, xScale: XScale, yScale: YScale) {
+    const { leftAlignTicks, padding = DEFAULT_PADDING } = this.props;
+
+    const width = Math.round(xScale(d)) + /* minimum bar width */ 1;
+    const x = xScale.range()[0] + (leftAlignTicks ? padding[3] : 0);
+    const y = Math.round((yScale(index) ?? 0) + yScale.bandwidth() / 2);
+
+    return <HistogramBar height={BAR_HEIGHT} width={width} x={x} y={y} />;
+  }
+
+  renderValue(d: number, index: number, xScale: XScale, yScale: YScale) {
+    const { leftAlignTicks, padding = DEFAULT_PADDING, yValues } = this.props;
+
+    const value = yValues && yValues[index];
+
+    if (!value) {
+      return null;
+    }
+
+    const x = xScale(d) + (leftAlignTicks ? padding[3] : 0);
+    const y = Math.round((yScale(index) ?? 0) + yScale.bandwidth() / 2 + BAR_HEIGHT / 2);
+
+    return (
+      <Tooltip overlay={this.props.yTooltips && this.props.yTooltips[index]}>
+        <HistogramTick dx="1em" dy="0.3em" textAnchor="start" x={x} y={y}>
+          {value}
+        </HistogramTick>
+      </Tooltip>
+    );
+  }
+
+  renderTick(index: number, xScale: XScale, yScale: YScale) {
+    const { leftAlignTicks, yTicks } = this.props;
+
+    const tick = yTicks && yTicks[index];
+
+    if (!tick) {
+      return null;
+    }
+
+    const x = xScale.range()[0];
+    const y = Math.round((yScale(index) ?? 0) + yScale.bandwidth() / 2 + BAR_HEIGHT / 2);
+
+    return (
+      <HistogramTick
+        dx={leftAlignTicks ? 0 : '-1em'}
+        dy="0.3em"
+        textAnchor={leftAlignTicks ? 'start' : 'end'}
+        x={x}
+        y={y}
+      >
+        {tick}
+      </HistogramTick>
+    );
+  }
+
+  renderBars(xScale: XScale, yScale: YScale) {
+    return (
+      <g>
+        {this.props.bars.map((d, index) => (
+          <g key={index}>
+            {this.renderBar(d, index, xScale, yScale)}
+            {this.renderValue(d, index, xScale, yScale)}
+            {this.renderTick(index, xScale, yScale)}
+          </g>
+        ))}
+      </g>
+    );
+  }
+
+  render() {
+    const { bars, height, leftAlignTicks, padding = DEFAULT_PADDING, width } = this.props;
+
+    const availableWidth = width - padding[1] - padding[3];
+    const xScale: XScale = scaleLinear()
+      .domain([0, max(bars) ?? 0])
+      .range([0, availableWidth]);
+
+    const availableHeight = height - padding[0] - padding[2];
+    const yScale: YScale = scaleBand<number>()
+      .domain(bars.map((_, index) => index))
+      .rangeRound([0, availableHeight]);
+
+    return (
+      <svg height={this.props.height} width={this.props.width}>
+        <g transform={`translate(${leftAlignTicks ? 0 : padding[3]}, ${padding[0]})`}>
+          {this.renderBars(xScale, yScale)}
+        </g>
+      </svg>
+    );
+  }
+}
+
+const HistogramTick = styled.text`
+  ${tw`sw-body-sm`}
+  fill: ${themeColor('pageContentLight')};
+
+  ${TooltipWrapper} & {
+    fill: ${themeContrast('primary')};
+  }
+`;
+
+const HistogramBar = styled.rect`
+  fill: ${themeColor('primary')};
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/Histogram-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Histogram-test.tsx
new file mode 100644 (file)
index 0000000..71fdab4
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * 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 { render } from '../../helpers/testUtils';
+import { Histogram } from '../Histogram';
+
+it('renders correctly', () => {
+  const { container } = renderHistogram();
+  expect(container).toMatchSnapshot();
+});
+
+it('renders correctly with yValues', () => {
+  const { container } = renderHistogram({ yValues: ['100.0', '75.0', '150.0'] });
+  expect(container).toMatchSnapshot();
+});
+
+it('renders correctly with yValues and yTicks', () => {
+  const { container } = renderHistogram({
+    yValues: ['100.0', '75.0', '150.0'],
+    yTicks: ['a', 'b', 'c'],
+  });
+  expect(container).toMatchSnapshot();
+});
+
+it('renders correctly with yValues, yTicks, and yTooltips', () => {
+  const { container } = renderHistogram({
+    yValues: ['100.0', '75.0', '150.0'],
+    yTicks: ['a', 'b', 'c'],
+    yTooltips: ['a - 100', 'b - 75', 'c - 150'],
+  });
+  expect(container).toMatchSnapshot();
+});
+
+function renderHistogram(props: Partial<Histogram['props']> = {}) {
+  return render(<Histogram bars={[100, 75, 150]} height={75} width={100} {...props} />);
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Histogram-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Histogram-test.tsx.snap
new file mode 100644 (file)
index 0000000..166209b
--- /dev/null
@@ -0,0 +1,375 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+.emotion-0 {
+  fill: rgb(93,108,208);
+}
+
+<div>
+  <svg
+    height="75"
+    width="100"
+  >
+    <g
+      transform="translate(10, 10)"
+    >
+      <g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="54"
+            x="0"
+            y="10"
+          />
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="41"
+            x="0"
+            y="28"
+          />
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="81"
+            x="0"
+            y="46"
+          />
+        </g>
+      </g>
+    </g>
+  </svg>
+</div>
+`;
+
+exports[`renders correctly with yValues 1`] = `
+.emotion-0 {
+  fill: rgb(93,108,208);
+}
+
+.emotion-2 {
+  font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
+  font-size: 0.875rem;
+  line-height: 1.25rem;
+  font-weight: 400;
+  fill: rgb(106,117,144);
+}
+
+.e1vbniy52 .emotion-2 {
+  fill: rgb(255,255,255);
+}
+
+<div>
+  <svg
+    height="75"
+    width="100"
+  >
+    <g
+      transform="translate(10, 10)"
+    >
+      <g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="54"
+            x="0"
+            y="10"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="53.33333333333333"
+            y="15"
+          >
+            100.0
+          </text>
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="41"
+            x="0"
+            y="28"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="40"
+            y="33"
+          >
+            75.0
+          </text>
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="81"
+            x="0"
+            y="46"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="80"
+            y="51"
+          >
+            150.0
+          </text>
+        </g>
+      </g>
+    </g>
+  </svg>
+</div>
+`;
+
+exports[`renders correctly with yValues and yTicks 1`] = `
+.emotion-0 {
+  fill: rgb(93,108,208);
+}
+
+.emotion-2 {
+  font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
+  font-size: 0.875rem;
+  line-height: 1.25rem;
+  font-weight: 400;
+  fill: rgb(106,117,144);
+}
+
+.e1vbniy52 .emotion-2 {
+  fill: rgb(255,255,255);
+}
+
+<div>
+  <svg
+    height="75"
+    width="100"
+  >
+    <g
+      transform="translate(10, 10)"
+    >
+      <g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="54"
+            x="0"
+            y="10"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="53.33333333333333"
+            y="15"
+          >
+            100.0
+          </text>
+          <text
+            class="emotion-2 emotion-3"
+            dx="-1em"
+            dy="0.3em"
+            text-anchor="end"
+            x="0"
+            y="15"
+          >
+            a
+          </text>
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="41"
+            x="0"
+            y="28"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="40"
+            y="33"
+          >
+            75.0
+          </text>
+          <text
+            class="emotion-2 emotion-3"
+            dx="-1em"
+            dy="0.3em"
+            text-anchor="end"
+            x="0"
+            y="33"
+          >
+            b
+          </text>
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="81"
+            x="0"
+            y="46"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="80"
+            y="51"
+          >
+            150.0
+          </text>
+          <text
+            class="emotion-2 emotion-3"
+            dx="-1em"
+            dy="0.3em"
+            text-anchor="end"
+            x="0"
+            y="51"
+          >
+            c
+          </text>
+        </g>
+      </g>
+    </g>
+  </svg>
+</div>
+`;
+
+exports[`renders correctly with yValues, yTicks, and yTooltips 1`] = `
+.emotion-0 {
+  fill: rgb(93,108,208);
+}
+
+.emotion-2 {
+  font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
+  font-size: 0.875rem;
+  line-height: 1.25rem;
+  font-weight: 400;
+  fill: rgb(106,117,144);
+}
+
+.e1vbniy52 .emotion-2 {
+  fill: rgb(255,255,255);
+}
+
+<div>
+  <svg
+    height="75"
+    width="100"
+  >
+    <g
+      transform="translate(10, 10)"
+    >
+      <g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="54"
+            x="0"
+            y="10"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="53.33333333333333"
+            y="15"
+          >
+            100.0
+          </text>
+          <text
+            class="emotion-2 emotion-3"
+            dx="-1em"
+            dy="0.3em"
+            text-anchor="end"
+            x="0"
+            y="15"
+          >
+            a
+          </text>
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="41"
+            x="0"
+            y="28"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="40"
+            y="33"
+          >
+            75.0
+          </text>
+          <text
+            class="emotion-2 emotion-3"
+            dx="-1em"
+            dy="0.3em"
+            text-anchor="end"
+            x="0"
+            y="33"
+          >
+            b
+          </text>
+        </g>
+        <g>
+          <rect
+            class="emotion-0 emotion-1"
+            height="10"
+            width="81"
+            x="0"
+            y="46"
+          />
+          <text
+            class="emotion-2 emotion-3"
+            dx="1em"
+            dy="0.3em"
+            text-anchor="start"
+            x="80"
+            y="51"
+          >
+            150.0
+          </text>
+          <text
+            class="emotion-2 emotion-3"
+            dx="-1em"
+            dy="0.3em"
+            text-anchor="end"
+            x="0"
+            y="51"
+          >
+            c
+          </text>
+        </g>
+      </g>
+    </g>
+  </svg>
+</div>
+`;
index bf3c48ca0022dfd62028b8360dcf6d483cb9ae65..16fe359ece87abf7b218f1c9008358cd41c0e749 100644 (file)
@@ -44,6 +44,7 @@ export * from './FlowStep';
 export * from './FormField';
 export * from './GenericAvatar';
 export * from './HighlightedSection';
+export { Histogram } from './Histogram';
 export { HotspotRating } from './HotspotRating';
 export * from './HtmlFormatter';
 export * from './InputField';