Skip to content
Snippets Groups Projects
Commit 4d4ae44e authored by Tom Robinson's avatar Tom Robinson
Browse files

Gauge: Various improvments and refactoring

parent 378cf72d
No related branches found
No related tags found
No related merge requests found
/* @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 Scalar from "./Scalar";
import colors from "metabase/lib/colors";
import { formatValue } from "metabase/lib/formatting";
......@@ -15,16 +14,36 @@ import ChartSettingRange from "metabase/visualizations/components/settings/Chart
import type { VisualizationProps } from "metabase/meta/types/Visualization";
const OUTER_RADIUS = 45; // within 100px canvas
const OUTER_RADIUS = 45; // within 100px SVG element
const INNER_RADIUS_RATIO = 4 / 5;
const INNER_RADIUS = OUTER_RADIUS * INNER_RADIUS_RATIO;
const ARROW_HEIGHT = (OUTER_RADIUS - INNER_RADIUS) * 2 / 3;
const ARROW_BASE = ARROW_HEIGHT / Math.tan(60 / 180 * Math.PI); // equilateral triangle
const ARROW_THICKNESS = 1.5;
// arrow shape, currently an equilateral triangle
const ARROW_HEIGHT = (OUTER_RADIUS - INNER_RADIUS) * 3 / 4; // 2/3 of segment thickness
const ARROW_BASE = ARROW_HEIGHT / Math.tan(60 / 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-dark"];
const ARROW_STROKE_COLOR = "white";
// in ems, but within the scaled 100px SVG element
const FONT_SIZE_SEGMENT_LABEL = 0.15;
const FONT_SIZE_CENTER_LABEL_MIN = 0.5;
const FONT_SIZE_CENTER_LABEL_MAX = 1.25;
// hide labels if SVG width is smaller than this
const MIN_WIDTH_LABEL_THRESHOLD = 400;
// 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;
export default class Gauge extends Component {
props: VisualizationProps;
......@@ -73,6 +92,36 @@ export default class Gauge extends Component {
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 = 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() {
......@@ -85,8 +134,7 @@ export default class Gauge extends Component {
} = this.props;
const viewBoxHeight =
(ARC_DEGREES > 180 ? 50 : 0) +
Math.sin(ARC_DEGREES / 2 / 180 * Math.PI) * 50;
(ARC_DEGREES > 180 ? 50 : 0) + Math.sin(radians(ARC_DEGREES / 2)) * 50;
const viewBoxWidth = 100;
const svgAspectRatio = viewBoxHeight / viewBoxWidth;
......@@ -101,14 +149,12 @@ export default class Gauge extends Component {
svgHeight = width / svgAspectRatio;
}
const showLabels = svgWidth > MIN_WIDTH_LABEL_THRESHOLD;
const range = settings["gauge.range"];
const segments = settings["gauge.segments"];
const arc = d3.svg
.arc()
.outerRadius(OUTER_RADIUS)
.innerRadius(OUTER_RADIUS * INNER_RADIUS_RATIO);
// 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
......@@ -128,8 +174,6 @@ export default class Gauge extends Component {
];
};
const radiusCenter = OUTER_RADIUS - (OUTER_RADIUS - INNER_RADIUS) / 2;
// get unique min/max plus range endpoints
const numberLabels = Array.from(
new Set(
......@@ -145,64 +189,71 @@ export default class Gauge extends Component {
}));
return (
<div className={cx(className, "flex layout-centered")}>
<div className={cx(className, "relative")}>
<div
className="relative"
style={{ width: svgWidth, height: svgHeight }}
className="absolute overflow-hidden"
style={{
width: svgWidth,
height: svgHeight,
top: (height - svgHeight) / 2,
left: (width - svgWidth) / 2,
}}
>
<Scalar {...this.props} className="spread" style={{ top: 0 }} />
<svg viewBox={`0 0 100 ${viewBoxHeight}`}>
<g transform={`translate(50,50)`}>
{/* BACKGROUND ARC */}
<path
d={arc({
startAngle: angle(range[0]),
endAngle: angle(range[1]),
})}
fill={colors["bg-medium"]}
<GaugeArc
start={angle(range[0])}
end={angle(range[1])}
fill={BACKGROUND_ARC_COLOR}
/>
{/* SEGMENT ARCS */}
{segments.map((segment, index) => (
<path
d={arc({
startAngle: angle(segments[index].min),
endAngle: angle(segments[index].max),
})}
<GaugeArc
key={index}
start={angle(segment.min)}
end={angle(segment.max)}
fill={segment.color}
/>
))}
{/* NEEDLE */}
<path
d={`M-${ARROW_BASE} 0 L0 -${ARROW_HEIGHT} L${ARROW_BASE} 0 Z`}
stroke="white"
strokeWidth={ARROW_THICKNESS}
fill="none"
transform={`translate(0,-${INNER_RADIUS}) rotate(${angle(
this.state.mounted ? value : 0,
) *
180 /
Math.PI}, 0, ${INNER_RADIUS})`}
style={{ transition: "transform 0.5s ease-in-out" }}
/>
<GaugeNeedle angle={angle(this.state.mounted ? value : 0)} />
{/* NUMBER LABELS */}
{numberLabels.map((value, index) => (
<GaugeLabel
position={valuePosition(value, OUTER_RADIUS * 1.01)}
>
{formatValue(value, { column })}
</GaugeLabel>
))}
{showLabels &&
numberLabels.map((value, index) => (
<GaugeSegmentLabel
position={valuePosition(value, OUTER_RADIUS * 1.01)}
>
{formatValue(value, { column })}
</GaugeSegmentLabel>
))}
{/* TEXT LABELS */}
{textLabels.map(({ label, value }, index) => (
<GaugeLabel
position={valuePosition(value, OUTER_RADIUS * 1.01)}
style={{
fill: colors["text-dark"],
}}
>
{label}
</GaugeLabel>
))}
{showLabels &&
textLabels.map(({ label, value }, index) => (
<GaugeSegmentLabel
position={valuePosition(value, OUTER_RADIUS * 1.01)}
style={{
fill: SEGMENT_LABEL_COLOR,
}}
>
{label}
</GaugeSegmentLabel>
))}
{/* 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",
textAnchor: "middle",
transform: "translate(0,0.2em)",
}}
>
{formatValue(value, { column })}
</text>
</g>
</svg>
</div>
......@@ -211,21 +262,49 @@ export default class Gauge extends Component {
}
}
const GaugeLabel = ({ position: [x, y], style = {}, children }) => {
const GaugeArc = ({ start, end, fill }) => {
const arc = d3.svg
.arc()
.outerRadius(OUTER_RADIUS)
.innerRadius(OUTER_RADIUS * INNER_RADIUS_RATIO);
return (
<text
x={x}
y={y}
style={{
fill: colors["text-medium"],
fontSize: "0.15em",
textAnchor: Math.abs(x) < 5 ? "middle" : x > 0 ? "start" : "end",
// shift text in the lower half down a bit
transform: y > 0 ? "translate(0,0.15em)" : undefined,
...style,
}}
>
{children}
</text>
<path
d={arc({
startAngle: start,
endAngle: end,
})}
fill={fill}
/>
);
};
const GaugeNeedle = ({ angle }) => (
<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={{ transition: "transform 0.5s ease-in-out" }}
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>
);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment