blob: 07902e149dc60d35d553274bcd5eb8428efb50de [file] [log] [blame]
/*
* Copyright (C) 2019 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
// GaugeChart creates a semi-circle gauge chart with colored segments.
//
// Initialize the chart with a semi-circle height, stroke width, and segments.
// The class names you provide for the segments will allow you to style them
// and the limit (0 - 100) is the upper percentage value where that segment
// ends. You can update the chart with new current needle value at any time.
//
// SVG:
//
// - There is a path for each segment. Note there is a small includes a
// buffer between segments, so they should be more than a few # apart.
// - There is a single polygon for the needle value.
//
// <div class="gauge-chart">
// <svg width="204" height="110" viewBox="0 0 204 110">
// <path class="segment segment-class-name-1" d="..."/>
// <path class="segment segment-class-name-2" d="..."/>
// ...
// <polygon class="needle" points="..."/>
// </svg>
// </div>
WI.GaugeChart = class GaugeChart extends WI.View
{
constructor({height, strokeWidth, segments})
{
super();
strokeWidth = strokeWidth || 10;
this._needleValue = null;
const needleOverhangSpace = 10; // Distance the needle goes past the outer circle edge.
const needleUnderhangSpace = 8; // Space allowed beneath the graph so a horizontal needle lines up.
this._center = height - needleUnderhangSpace;
this._radius = height - needleUnderhangSpace - needleOverhangSpace - 1;
this._innerRadius = Math.floor(this._radius - strokeWidth);
let width = (this._radius + needleOverhangSpace + 1) * 2;
this._size = new WI.Size(width, height);
console.assert(!this._segments, "Set segments only once");
console.assert(segments.length >= 1, "Need at least one segment");
console.assert(this._validateSegments(segments));
this._segments = segments;
this.element.classList.add("gauge-chart");
this._chartElement = this.element.appendChild(createSVGElement("svg"));
this._chartElement.setAttribute("width", width);
this._chartElement.setAttribute("height", height);
this._chartElement.setAttribute("viewBox", `0 0 ${width} ${height}`);
this._needleElement = null;
}
// Public
get size() { return this._size; }
get segments() { return this._segments; }
get value()
{
return this._needleValue;
}
set value(value)
{
console.assert(value >= 0 && value <= 100, "value should be between 0 and 100.", value);
this._needleValue = value;
}
clear()
{
this._needleValue = null;
}
// Protected
initialLayout()
{
super.initialLayout();
let startAngle = Math.PI;
const onePercentAngle = Math.PI / 100;
for (let {className, limit} of this._segments) {
let offset = limit === 100 ? 0 : 1;
let endAngle = Math.PI + (((limit - offset) / 100) * Math.PI);
let pathElement = this._chartElement.appendChild(createSVGElement("path"));
pathElement.classList.add("segment", className);
pathElement.setAttribute("d", this._createSegmentPathData(this._center, startAngle, endAngle, this._radius, this._innerRadius));
startAngle = endAngle + onePercentAngle;
}
const needlePointExtraDraw = 0.5; // Draw a fat tip to the needle.
const needleBaseExtraDraw = 4.5; // Draw a fat base to the needle.
const needleUnderhangDraw = 6; // Draw the needle underhanging the base of the graph.
let midX = this.size.width / 2;
let midY = this._center;
this._needleElement = this._chartElement.appendChild(createSVGElement("polygon"));
this._needleElement.classList.add("needle");
this._needleElement.setAttribute("points", `0,${midY + needlePointExtraDraw}, 0,${midY - needlePointExtraDraw} ${midX + needleUnderhangDraw},${midY - needleBaseExtraDraw} ${midX + needleUnderhangDraw},${midY + needleBaseExtraDraw}`);
this._needleElement.style.transformOrigin = `${midX}px ${midY}px`;
}
layout()
{
super.layout();
if (this.layoutReason === WI.View.LayoutReason.Resize)
return;
let empty = this._needleValue === null;
this.element.classList.toggle("empty", empty);
let value = empty ? 0 : this._needleValue;
let degrees = 180 * (value / 100); // 0-100% mapped to 0-180deg.
this._needleElement.style.transform = `rotate(${degrees}deg)`;
}
// Private
_validateSegments(segments)
{
let lastLimit = -1;
for (let {className, limit} of segments) {
console.assert(limit >= 1 && limit <= 100, "limit should be between 1 and 100", limit);
console.assert(limit >= (lastLimit + 1), "limits should always increase between segments");
lastLimit = limit;
}
return true;
}
_createSegmentPathData(c, a1, a2, r1, r2)
{
const startIndicatorUnderhang = 7;
let r3 = (r2 - startIndicatorUnderhang);
let onePercentArc = Math.PI / 100;
let largeArcFlag = ((a2 - a1) % (Math.PI * 2)) > Math.PI ? 1 : 0;
let x1 = c + Math.cos(a1) * r1,
y1 = c + Math.sin(a1) * r1,
x2 = c + Math.cos(a2) * r1,
y2 = c + Math.sin(a2) * r1,
x3 = c + Math.cos(a2) * r2,
y3 = c + Math.sin(a2) * r2,
x4 = c + Math.cos(a1 + onePercentArc) * r2,
y4 = c + Math.sin(a1 + onePercentArc) * r2,
x5 = c + Math.cos(a1 + onePercentArc) * r3,
y5 = c + Math.sin(a1 + onePercentArc) * r3,
x6 = c + Math.cos(a1) * r3,
y6 = c + Math.sin(a1) * r3;
return [
"M", x1, y1, // Starting position.
"A", r1, r1, 0, largeArcFlag, 1, x2, y2, // Draw outer arc.
"L", x3, y3, // Connect outer and inner arcs.
"A", r2, r2, 0, largeArcFlag, 0, x4, y4, // Draw inner arc.
"L", x5, y5, // Extend inner arc to center for start indicator.
"A", r3, r3, 0, largeArcFlag, 0, x6, y6, // Draw final inner arc for start indicator.
"Z" // Close path.
].join(" ");
}
};