--- /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.
+ */
+/* 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')};
+`;
--- /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 { 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} />);
+}
--- /dev/null
+// 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>
+`;
export * from './FormField';
export * from './GenericAvatar';
export * from './HighlightedSection';
+export { Histogram } from './Histogram';
export { HotspotRating } from './HotspotRating';
export * from './HtmlFormatter';
export * from './InputField';