diff --git a/frontend/src/metabase/components/ColorPicker.jsx b/frontend/src/metabase/components/ColorPicker.jsx index d663d206d494524a41ab60e740d7a38ce3e0aee5..5b9d89845229ad8859099bfdbd34597c1acf27b6 100644 --- a/frontend/src/metabase/components/ColorPicker.jsx +++ b/frontend/src/metabase/components/ColorPicker.jsx @@ -35,7 +35,7 @@ class ColorPicker extends Component { render() { const { onChange, padding, size, triggerSize, value } = this.props; - const colors = this.props.colors || [...Object.values(normal)]; + const colors = this.props.colors || Object.values(normal).slice(0, 9); return ( <div className="inline-block"> <PopoverWithTrigger diff --git a/frontend/src/metabase/components/ExplicitSize.jsx b/frontend/src/metabase/components/ExplicitSize.jsx index fbf065ad95be80e5ee768f85583109dd4debf686..912c516a1b24214a314fa6503d9795f525aed2c9 100644 --- a/frontend/src/metabase/components/ExplicitSize.jsx +++ b/frontend/src/metabase/components/ExplicitSize.jsx @@ -3,7 +3,7 @@ import ReactDOM from "react-dom"; import ResizeObserver from "resize-observer-polyfill"; -export default ComposedComponent => +export default measureClass => ComposedComponent => class extends Component { static displayName = "ExplicitSize[" + (ComposedComponent.displayName || ComposedComponent.name) + @@ -17,6 +17,17 @@ export default ComposedComponent => }; } + _getElement() { + const element = ReactDOM.findDOMNode(this); + if (measureClass) { + const elements = element.getElementsByClassName(measureClass); + if (elements.length > 0) { + return elements[0]; + } + } + return element; + } + componentDidMount() { // media query listener, ensure re-layout when printing if (window.matchMedia) { @@ -24,11 +35,11 @@ export default ComposedComponent => this._mql.addListener(this._updateSize); } - const element = ReactDOM.findDOMNode(this); + const element = this._getElement(); if (element) { // resize observer, ensure re-layout when container element changes size this._ro = new ResizeObserver((entries, observer) => { - const element = ReactDOM.findDOMNode(this); + const element = this._getElement(); for (const entry of entries) { if (entry.target === element) { this._updateSize(); @@ -56,7 +67,7 @@ export default ComposedComponent => } _updateSize = () => { - const element = ReactDOM.findDOMNode(this); + const element = this._getElement(); if (element) { const { width, height } = element.getBoundingClientRect(); if (this.state.width !== width || this.state.height !== height) { diff --git a/frontend/src/metabase/components/ShrinkableList.jsx b/frontend/src/metabase/components/ShrinkableList.jsx index 0b16343ea45f3d3a51be8b67eae2f703f48631e4..7c70844fad7a4d1234472016a45a9baec6b66cd3 100644 --- a/frontend/src/metabase/components/ShrinkableList.jsx +++ b/frontend/src/metabase/components/ShrinkableList.jsx @@ -16,7 +16,7 @@ type State = { isShrunk: ?boolean, }; -@ExplicitSize +@ExplicitSize() export default class ShrinkableList extends Component { props: Props; state: State = { diff --git a/frontend/src/metabase/components/__mocks__/ExplicitSize.jsx b/frontend/src/metabase/components/__mocks__/ExplicitSize.jsx index 91e740969979b01f4c19ef9b0fc2f439cb76c1c3..9d289eeffaef006916f18177b972ba7622eb9b64 100644 --- a/frontend/src/metabase/components/__mocks__/ExplicitSize.jsx +++ b/frontend/src/metabase/components/__mocks__/ExplicitSize.jsx @@ -1,7 +1,7 @@ /* eslint-disable react/display-name */ import React from "react"; -const ExplicitSize = ComposedComponent => props => ( +const ExplicitSize = measureClass => ComposedComponent => props => ( <ComposedComponent width={1000} height={1000} {...props} /> ); diff --git a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx index 38e45d66c3637fe09ac99fc913e32a8abd49217b..972a0ce8729f6befcc8a3a3afe545e2322b8c6ec 100644 --- a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx @@ -25,7 +25,7 @@ import cx from "classnames"; const MOBILE_ASPECT_RATIO = 3 / 2; const MOBILE_TEXT_CARD_ROW_HEIGHT = 40; -@ExplicitSize +@ExplicitSize() export default class DashboardGrid extends Component { constructor(props, context) { super(props, context); diff --git a/frontend/src/metabase/hoc/AutoExpanding.jsx b/frontend/src/metabase/hoc/AutoExpanding.jsx index 7d809610d4bfd4fb6629dd3c01f891c9e6ea3eb0..7665564fc0411f3fc5b67d80cdf90535d6bbae58 100644 --- a/frontend/src/metabase/hoc/AutoExpanding.jsx +++ b/frontend/src/metabase/hoc/AutoExpanding.jsx @@ -8,7 +8,7 @@ import ExplicitSize from "metabase/components/ExplicitSize"; // beyond their initial size we want to fix their size to be larger so it doesn't // jump around, etc export default ComposedComponent => - @ExplicitSize + @ExplicitSize() class AutoExpanding extends React.Component { state = { expand: false, diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 7872e844da13793501259baae7c9a2e93a38d853..7b71cdcf03edc6f4eb9ee1c313a9c6906ac8284c 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -170,6 +170,8 @@ export const ICON_PATHS = { }, folder: "M0 5l.01 21.658a2 2 0 0 0 2 1.999H30a2 2 0 0 0 2-2V7.705a2 2 0 0 0-2-2H17.51a1 1 0 0 1-.924-.615l-.614-1.474A1 1 0 0 0 15.049 3H1.999a2 2 0 0 0-2 2z", + gauge: + "M5.197 29.803A15.958 15.958 0 0 1 0 18C0 9.163 7.163 2 16 2s16 7.163 16 16a15.96 15.96 0 0 1-5.344 11.936L22.983 26.5A10.978 10.978 0 0 0 27 18c0-6.075-4.925-11-11-11S5 11.925 5 18c0 3.292 1.446 6.246 3.738 8.262l-3.54 3.54zM13 21.25a3.774 3.774 0 0 1 1.122-5.975L23 11l-4.34 9.347a3.455 3.455 0 0 1-5.66.903z", gear: "M14 0 H18 L19 6 L20.707 6.707 L26 3.293 L28.707 6 L25.293 11.293 L26 13 L32 14 V18 L26 19 L25.293 20.707 L28.707 26 L26 28.707 L20.707 25.293 L19 26 L18 32 L14 32 L13 26 L11.293 25.293 L6 28.707 L3.293 26 L6.707 20.707 L6 19 L0 18 L0 14 L6 13 L6.707 11.293 L3.293 6 L6 3.293 L11.293 6.707 L13 6 L14 0 z M16 10 A6 6 0 0 0 16 22 A6 6 0 0 0 16 10", grabber: diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js index 610b49564fb7928d264c7e39b9da9002eed336b0..d01c82bfdfc96ae101fde2996eba554991688dba 100644 --- a/frontend/src/metabase/meta/types/Visualization.js +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -81,9 +81,13 @@ export type VisualizationProps = { height: number, }, + width: number, + height: number, + showTitle: boolean, isDashboard: boolean, isEditing: boolean, + isSettings: boolean, actionButtons: Node, onRender: ({ diff --git a/frontend/src/metabase/public/containers/PublicQuestion.jsx b/frontend/src/metabase/public/containers/PublicQuestion.jsx index ee0cdd276118d62ef59138164d29e320f2f41d76..3d56a2c9c780aca462b7820198eff6e6da406c2f 100644 --- a/frontend/src/metabase/public/containers/PublicQuestion.jsx +++ b/frontend/src/metabase/public/containers/PublicQuestion.jsx @@ -55,7 +55,7 @@ const mapDispatchToProps = { }; @connect(null, mapDispatchToProps) -@ExplicitSize +@ExplicitSize() export default class PublicQuestion extends Component { props: Props; state: State; diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index ff16d48431c61c1ac4b0b134885f83341866b9e9..17ec6731656d38e760f826e7ad16928e64f6498c 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -1252,7 +1252,9 @@ export const getDisplayTypeForCard = (card, queryResults) => { // try a little logic to pick a smart display for the data // TODO: less hard-coded rules for picking chart type const isScalarVisualization = - card.display === "scalar" || card.display === "progress"; + card.display === "scalar" || + card.display === "progress" || + card.display === "gauge"; if ( !isScalarVisualization && queryResult.data.rows && diff --git a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx index b22ba0f2dcb908f4d91b7c1de2d98874c6974492..0ec788905f07b75aa02dc0e8c1017ae9389ca673 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx @@ -61,7 +61,7 @@ export default class VisualizationSettings extends React.Component { triggerClasses="flex align-center" sizeToFit > - <ul className="pt1 pb1"> + <ul className="pt1 pb1 scroll-y"> {Array.from(visualizations).map(([vizType, viz], index) => ( <li key={index} diff --git a/frontend/src/metabase/visualizations/components/CardRenderer.jsx b/frontend/src/metabase/visualizations/components/CardRenderer.jsx index 0e8e90964bff980d361db26c46286342c16c35d7..0a7843552edb1e25dcf1082ea1e4b2faefd9a323 100644 --- a/frontend/src/metabase/visualizations/components/CardRenderer.jsx +++ b/frontend/src/metabase/visualizations/components/CardRenderer.jsx @@ -16,7 +16,7 @@ type Props = VisualizationProps & { renderer: (element: Element, props: VisualizationProps) => DeregisterFunction, }; -@ExplicitSize +@ExplicitSize() export default class CardRenderer extends Component { props: Props; diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx index 42cfb4a172f102dbbf60b243533d324ad9f7f577..5d7d19bcf4088037ce6cc6195820ef4c8accbbd0 100644 --- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx +++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx @@ -174,9 +174,10 @@ class ChartSettings extends Component { <Visualization className="spread" rawSeries={series} - isEditing showTitle + isEditing isDashboard + isSettings showWarnings onUpdateVisualizationSettings={this.handleChangeSettings} onUpdateWarnings={warnings => this.setState({ warnings })} diff --git a/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx b/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx index 1391ccb93e5a2957dc0519c2b5379c1cf94bc666..3a2082d6daf7630be30f2a3caae6815cfb7ccd3c 100644 --- a/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx +++ b/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx @@ -11,7 +11,7 @@ import cx from "classnames"; const GRID_ASPECT_RATIO = 4 / 3; const PADDING = 14; -@ExplicitSize +@ExplicitSize() export default class ChartWithLegend extends Component { static defaultProps = { aspectRatio: 1, diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index 2d2d77d5479c4074e2e10a51c835f667de5246ae..2cf38ab2f5eb35814eb637903b5dd9e83894252d 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -88,7 +88,7 @@ type GridComponent = Component<void, void, void> & { recomputeGridSize: () => void, }; -@ExplicitSize +@ExplicitSize() export default class TableInteractive extends Component { state: State; props: Props; diff --git a/frontend/src/metabase/visualizations/components/TableSimple.jsx b/frontend/src/metabase/visualizations/components/TableSimple.jsx index c36f0fec2d348b0ae6585d5d49e66856378edadb..1863401520363104c2a5c7fd6c70ea794f3ec9b4 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple.jsx +++ b/frontend/src/metabase/visualizations/components/TableSimple.jsx @@ -33,7 +33,7 @@ type State = { sortDescending: boolean, }; -@ExplicitSize +@ExplicitSize() export default class TableSimple extends Component { props: Props; state: State; diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index f19e85aa2e95d1f6d13e55a6de01fa13f9a5ab21..f603cdcd19b8236e24b4a445464dbefbb464649f 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -57,6 +57,7 @@ type Props = { showTitle: boolean, isDashboard: boolean, isEditing: boolean, + isSettings: boolean, actionButtons: Element<any>, @@ -109,7 +110,8 @@ type State = { yAxisSplit: ?(number[][]), }; -@ExplicitSize +// NOTE: pass `CardVisualization` so that we don't include header when providing size to child element +@ExplicitSize("CardVisualization") export default class Visualization extends Component { state: State; props: Props; @@ -135,6 +137,7 @@ export default class Visualization extends Component { showTitle: false, isDashboard: false, isEditing: false, + isSettings: false, onUpdateVisualizationSettings: (...args) => console.warn("onUpdateVisualizationSettings", args), }; @@ -497,7 +500,8 @@ export default class Visualization extends Component { // $FlowFixMe <CardVisualization {...this.props} - className="flex-full" + // NOTE: CardVisualization class used to target ExplicitSize HOC + className="CardVisualization flex-full" series={series} settings={settings} // $FlowFixMe diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingGaugeSegments.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingGaugeSegments.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3def4c35297297897731bb83ba38587e20bb6494 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingGaugeSegments.jsx @@ -0,0 +1,128 @@ +import React from "react"; + +import { t } from "c-3po"; +import _ from "underscore"; + +import colors, { normal } from "metabase/lib/colors"; + +import ColorPicker from "metabase/components/ColorPicker"; +import Button from "metabase/components/Button"; +import Icon from "metabase/components/Icon"; +import NumericInput from "metabase/components/NumericInput"; + +const ChartSettingGaugeSegments = ({ value: segments, onChange }) => { + const onChangeProperty = (index, property, value) => + onChange([ + ...segments.slice(0, index), + { ...segments[index], [property]: value }, + ...segments.slice(index + 1), + ]); + return ( + <div> + <table> + <thead> + <tr> + <th>Color</th> + <th>Min</th> + <th>Max</th> + </tr> + </thead> + <tbody> + {segments.map((segment, index) => [ + <tr> + <td> + <ColorPicker + value={segment.color} + onChange={color => onChangeProperty(index, "color", color)} + triggerSize={28} + padding={2} + colors={getColorPalette()} + /> + </td> + <td> + <NumericInput + type="number" + className="input full" + value={segment.min} + onChange={value => onChangeProperty(index, "min", value)} + placeholder={t`Min`} + /> + </td> + <td> + <NumericInput + type="number" + className="input full" + value={segment.max} + onChange={value => onChangeProperty(index, "max", value)} + placeholder={t`Max`} + /> + </td> + <td> + {segments.length > 1 && ( + <Icon + name="close" + className="cursor-pointer text-grey-2 text-grey-4-hover ml2" + onClick={() => + onChange(segments.filter((v, i) => i !== index)) + } + /> + )} + </td> + </tr>, + <tr> + <td colSpan={3} className="pb2"> + <input + type="text" + className="input full" + value={segment.label} + onChange={e => + onChangeProperty(index, "label", e.target.value) + } + placeholder={t`Label for this range (optional)`} + /> + </td> + </tr>, + ])} + </tbody> + </table> + <Button + borderless + icon="add" + onClick={() => onChange(segments.concat(newSegment(segments)))} + > + {t`Add a range`} + </Button> + </div> + ); +}; + +function getColorPalette() { + return [ + colors["error"], + colors["warning"], + colors["success"], + ...Object.values(normal).slice(0, 9), + colors["bg-medium"], + ]; +} + +function newSegment(segments) { + const palette = getColorPalette(); + const lastSegment = segments[segments.length - 1]; + const lastColorIndex = lastSegment + ? _.findIndex(palette, color => color === lastSegment.color) + : -1; + const nextColor = + lastColorIndex >= 0 + ? palette[lastColorIndex + 1 % palette.length] + : palette[0]; + + return { + min: lastSegment ? lastSegment.max : 0, + max: lastSegment ? lastSegment.max * 2 : 1, + color: nextColor, + label: "", + }; +} + +export default ChartSettingGaugeSegments; diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js index ca6701f4d368db4e88c924602184cedaa69bdf24..cac43f18fc7ce40ebd0ffeca8b9c533a5b196e5b 100644 --- a/frontend/src/metabase/visualizations/index.js +++ b/frontend/src/metabase/visualizations/index.js @@ -12,6 +12,7 @@ import AreaChart from "./visualizations/AreaChart.jsx"; import MapViz from "./visualizations/Map.jsx"; import ScatterPlot from "./visualizations/ScatterPlot.jsx"; import Funnel from "./visualizations/Funnel.jsx"; +import Gauge from "./visualizations/Gauge.jsx"; import ObjectDetail from "./visualizations/ObjectDetail.jsx"; import { t } from "c-3po"; import _ from "underscore"; @@ -124,6 +125,7 @@ const extractRemappedColumns = data => { registerVisualization(Scalar); registerVisualization(Progress); +registerVisualization(Gauge); registerVisualization(Table); registerVisualization(Text); registerVisualization(LineChart); diff --git a/frontend/src/metabase/visualizations/visualizations/Gauge.jsx b/frontend/src/metabase/visualizations/visualizations/Gauge.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f313e4f166e5638f3f3414b662ca05de330ed6c0 --- /dev/null +++ b/frontend/src/metabase/visualizations/visualizations/Gauge.jsx @@ -0,0 +1,405 @@ +/* @flow */ + +import React, { Component } from "react"; +import ReactDOM from "react-dom"; +import { t } from "c-3po"; +import d3 from "d3"; +import cx from "classnames"; + +import colors from "metabase/lib/colors"; +import { formatValue } from "metabase/lib/formatting"; +import { isNumeric } from "metabase/lib/schema_metadata"; + +import ChartSettingGaugeSegments from "metabase/visualizations/components/settings/ChartSettingGaugeSegments"; + +import type { VisualizationProps } from "metabase/meta/types/Visualization"; + +const MAX_WIDTH = 500; +const PADDING_BOTTOM = 10; + +const OUTER_RADIUS = 45; // within 100px SVG element +const INNER_RADIUS_RATIO = 3.7 / 5; +const INNER_RADIUS = OUTER_RADIUS * INNER_RADIUS_RATIO; + +// arrow shape, currently an equilateral triangle +const ARROW_HEIGHT = (OUTER_RADIUS - INNER_RADIUS) * 2.5 / 4; // 2/3 of segment thickness +const ARROW_BASE = ARROW_HEIGHT / Math.tan(64 / 180 * Math.PI); +const ARROW_STROKE_THICKNESS = 1.25; + +// colors +const BACKGROUND_ARC_COLOR = colors["bg-medium"]; +const SEGMENT_LABEL_COLOR = colors["text-dark"]; +const CENTER_LABEL_COLOR = colors["text-dark"]; +const ARROW_FILL_COLOR = colors["text-medium"]; +const ARROW_STROKE_COLOR = "white"; + +// in ems, but within the scaled 100px SVG element +const FONT_SIZE_SEGMENT_LABEL = 0.25; +const FONT_SIZE_CENTER_LABEL_MIN = 0.5; +const FONT_SIZE_CENTER_LABEL_MAX = 0.7; + +// hide labels if SVG width is smaller than this +const MIN_WIDTH_LABEL_THRESHOLD = 250; + +const LABEL_OFFSET_PERCENT = 1.025; + +// total degrees of the arc (180 = semicircle, etc) +const ARC_DEGREES = 180 + 45 * 2; // semicircle plus a bit + +const radians = degrees => degrees * Math.PI / 180; +const degrees = radians => radians * 180 / Math.PI; + +const segmentIsValid = s => !isNaN(s.min) && !isNaN(s.max); + +export default class Gauge extends Component { + props: VisualizationProps; + + static uiName = t`Gauge`; + static identifier = "gauge"; + static iconName = "gauge"; + + static minSize = { width: 4, height: 4 }; + + static isSensible(cols, rows) { + return rows.length === 1 && cols.length === 1; + } + + static checkRenderable([{ data: { cols, rows } }]) { + if (!isNumeric(cols[0])) { + throw new Error(t`Gauge visualization requires a number.`); + } + } + + state = { + mounted: false, + }; + + _label: ?HTMLElement; + + static settings = { + "gauge.range": { + // currently not exposed in settings, just computed from gauge.segments + getDefault(series, vizSettings) { + const segments = vizSettings["gauge.segments"].filter(segmentIsValid); + const values = [ + ...segments.map(s => s.max), + ...segments.map(s => s.min), + ]; + return values.length > 0 + ? [Math.min(...values), Math.max(...values)] + : [0, 1]; + }, + readDependencies: ["gauge.segments"], + }, + "gauge.segments": { + section: "Display", + title: t`Gauge ranges`, + getDefault(series) { + let value = 100; + try { + value = series[0].data.rows[0][0]; + } catch (e) {} + return [ + { min: 0, max: value / 2, color: colors["error"], label: "" }, + { min: value / 2, max: value, color: colors["warning"], label: "" }, + { min: value, max: value * 2, color: colors["success"], label: "" }, + ]; + }, + widget: ChartSettingGaugeSegments, + persistDefault: true, + }, + }; + + componentDidMount() { + this.setState({ mounted: true }); + this._updateLabelSize(); + } + componentDidUpdate() { + this._updateLabelSize(); + } + + _updateLabelSize() { + // TODO: extract this into a component that resizes SVG <text> element to fit bounds + const label = this._label && ReactDOM.findDOMNode(this._label); + if (label) { + const { width: currentWidth } = label.getBBox(); + // maxWidth currently 95% of inner diameter, could be more intelligent based on text aspect ratio + const maxWidth = INNER_RADIUS * 2 * 0.95; + const currentFontSize = parseFloat( + label.style.fontSize.replace("em", ""), + ); + // scale the font based on currentWidth/maxWidth, within min and max + // TODO: if text is too big wrap or ellipsis? + const desiredFontSize = Math.max( + FONT_SIZE_CENTER_LABEL_MIN, + Math.min( + FONT_SIZE_CENTER_LABEL_MAX, + currentFontSize * (maxWidth / currentWidth), + ), + ); + // don't resize if within 5% to avoid potential thrashing + if (Math.abs(1 - currentFontSize / desiredFontSize) > 0.05) { + label.style.fontSize = desiredFontSize + "em"; + } + } + } + + render() { + const { + series: [{ data: { rows, cols } }], + settings, + className, + isSettings, + } = this.props; + + const width = this.props.width; + const height = this.props.height - PADDING_BOTTOM; + + const viewBoxHeight = + (ARC_DEGREES > 180 ? 50 : 0) + Math.sin(radians(ARC_DEGREES / 2)) * 50; + const viewBoxWidth = 100; + + const svgAspectRatio = viewBoxHeight / viewBoxWidth; + const containerAspectRadio = height / width; + + let svgWidth, svgHeight; + if (containerAspectRadio < svgAspectRatio) { + svgWidth = Math.min(MAX_WIDTH, height / svgAspectRatio); + } else { + svgWidth = Math.min(MAX_WIDTH, width); + } + svgHeight = svgWidth * svgAspectRatio; + + const showLabels = svgWidth > MIN_WIDTH_LABEL_THRESHOLD; + + const range = settings["gauge.range"]; + const segments = settings["gauge.segments"].filter(segmentIsValid); + + // value to angle in radians, clamped + const angle = d3.scale + .linear() + .domain(range) // NOTE: confusing, but the "range" is the domain for the arc scale + .range([ + ARC_DEGREES / 180 * -Math.PI / 2, + ARC_DEGREES / 180 * Math.PI / 2, + ]) + .clamp(true); + + const value = rows[0][0]; + const column = cols[0]; + + const valuePosition = (value, distance) => { + return [ + Math.cos(angle(value) - Math.PI / 2) * distance, + Math.sin(angle(value) - Math.PI / 2) * distance, + ]; + }; + + // get unique min/max plus range endpoints + const numberLabels = Array.from( + new Set( + range.concat(...segments.map(segment => [segment.min, segment.max])), + ), + ); + + const textLabels = segments + .filter(segment => segment.label) + .map(segment => ({ + label: segment.label, + value: segment.min + (segment.max - segment.min) / 2, + })); + + // expand the width to fill available space so that labels don't overflow as often + const expandWidthFactor = width / svgWidth; + + return ( + <div className={cx(className, "relative")}> + <div + className="absolute overflow-hidden" + style={{ + width: svgWidth * expandWidthFactor, + height: svgHeight, + top: (height - svgHeight) / 2, + left: + (width - svgWidth) / 2 - + // shift to the left the + (svgWidth * expandWidthFactor - svgWidth) / 2, + }} + > + <svg + viewBox={`0 0 ${viewBoxWidth * expandWidthFactor} ${viewBoxHeight}`} + > + <g + transform={`translate(${viewBoxWidth * + expandWidthFactor / + 2},50)`} + > + {/* BACKGROUND ARC */} + <GaugeArc + start={angle(range[0])} + end={angle(range[1])} + fill={BACKGROUND_ARC_COLOR} + /> + {/* SEGMENT ARCS */} + {segments.map((segment, index) => ( + <GaugeArc + key={index} + start={angle(segment.min)} + end={angle(segment.max)} + fill={segment.color} + segment={segment} + onHoverChange={this.props.onHoverChange} + /> + ))} + {/* NEEDLE */} + <GaugeNeedle + angle={angle(this.state.mounted ? value : 0)} + isAnimated={!isSettings} + /> + {/* NUMBER LABELS */} + {showLabels && + numberLabels.map((value, index) => ( + <GaugeSegmentLabel + position={valuePosition( + value, + OUTER_RADIUS * LABEL_OFFSET_PERCENT, + )} + > + {formatValue(value, { column })} + </GaugeSegmentLabel> + ))} + {/* TEXT LABELS */} + {showLabels && + textLabels.map(({ label, value }, index) => ( + <HideIfOverlowingSVG> + <GaugeSegmentLabel + position={valuePosition( + value, + OUTER_RADIUS * LABEL_OFFSET_PERCENT, + )} + style={{ + fill: SEGMENT_LABEL_COLOR, + }} + > + {label} + </GaugeSegmentLabel> + </HideIfOverlowingSVG> + ))} + {/* CENTER LABEL */} + {/* NOTE: can't be a component because ref doesn't work? */} + <text + ref={label => (this._label = label)} + x={0} + y={0} + style={{ + fill: CENTER_LABEL_COLOR, + fontSize: "1em", + fontWeight: "bold", + textAnchor: "middle", + transform: "translate(0,0.2em)", + }} + > + {formatValue(value, { column })} + </text> + </g> + </svg> + </div> + </div> + ); + } +} + +const GaugeArc = ({ start, end, fill, segment, onHoverChange }) => { + const arc = d3.svg + .arc() + .outerRadius(OUTER_RADIUS) + .innerRadius(OUTER_RADIUS * INNER_RADIUS_RATIO); + return ( + <path + d={arc({ + startAngle: start, + endAngle: end, + })} + fill={fill} + onMouseMove={ + onHoverChange && segment.label + ? e => + onHoverChange({ + data: [ + { + key: segment.label, + value: `${segment.min} - ${segment.max}`, + }, + ], + event: e.nativeEvent, + }) + : null + } + onMouseLeave={onHoverChange ? () => onHoverChange(null) : null} + /> + ); +}; + +const GaugeNeedle = ({ angle, isAnimated = true }) => ( + <path + d={`M-${ARROW_BASE} 0 L0 -${ARROW_HEIGHT} L${ARROW_BASE} 0 Z`} + transform={`translate(0,-${INNER_RADIUS}) rotate(${degrees( + angle, + )}, 0, ${INNER_RADIUS})`} + style={isAnimated ? { transition: "transform 1.5s ease-in-out" } : null} + stroke={ARROW_STROKE_COLOR} + strokeWidth={ARROW_STROKE_THICKNESS} + fill={ARROW_FILL_COLOR} + /> +); + +const GaugeSegmentLabel = ({ position: [x, y], style = {}, children }) => ( + <text + x={x} + y={y} + style={{ + fill: colors["text-medium"], + fontSize: `${FONT_SIZE_SEGMENT_LABEL}em`, + textAnchor: Math.abs(x) < 5 ? "middle" : x > 0 ? "start" : "end", + // shift text in the lower half down a bit + transform: + y > 0 ? `translate(0,${FONT_SIZE_SEGMENT_LABEL}em)` : undefined, + ...style, + }} + > + {children} + </text> +); + +class HideIfOverlowingSVG extends React.Component { + componentDidMount() { + this._hideIfClipped(); + } + componentDidUpdate() { + this._hideIfClipped(); + } + _hideIfClipped() { + const element = ReactDOM.findDOMNode(this); + if (element) { + let svg = element; + while (svg.nodeName.toLowerCase() !== "svg") { + svg = svg.parentNode; + } + const svgRect = svg.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + if ( + elementRect.left >= svgRect.left && + elementRect.right <= svgRect.right && + elementRect.top >= svgRect.top && + elementRect.bottom <= svgRect.bottom + ) { + element.classList.remove("hidden"); + } else { + element.classList.add("hidden"); + } + } + } + render() { + return this.props.children; + } +}