Home Reference Source

lib/RadialGauge.js

  1. /*!
  2. * @license
  3. * Minimalistic HTML5 Canvas Gauge implementation
  4. *
  5. * This code is subject to MIT license.
  6. *
  7. * Copyright (c) 2012 Mykhailo Stadnyk <mikhus@gmail.com>
  8. *
  9. * Permission is hereby granted, free of charge, to any person obtaining a copy
  10. * of this software and associated documentation files (the "Software"), to deal
  11. * in the Software without restriction, including without limitation the rights
  12. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  13. * copies of the Software, and to permit persons to whom the Software is
  14. * furnished to do so, subject to the following conditions:
  15. *
  16. * The above copyright notice and this permission notice shall be included in
  17. * all copies or substantial portions of the Software.
  18. *
  19. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  20. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  21. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  22. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  23. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  24. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  25. * SOFTWARE.
  26. */
  27. require('./polyfill');
  28.  
  29. const GenericOptions = require('./GenericOptions');
  30. const BaseGauge = require('./BaseGauge');
  31. const SmartCanvas = require('./SmartCanvas');
  32. const drawings = require('./drawings');
  33.  
  34. const PI = Math.PI;
  35. const HPI = PI / 2;
  36.  
  37. /**
  38. * Gauge configuration options
  39. *
  40. * @typedef {GenericOptions|{ticksAngle: number, startAngle: number, colorNeedleCircleOuter: string, colorNeedleCircleOuterEnd: string, colorNeedleCircleInner: string, colorNeedleCircleInnerEnd: string, needleCircleSize: number, needleCircleInner: boolean, needleCircleOuter: boolean}} RadialGaugeOptions
  41. */
  42.  
  43. /**
  44. * Default gauge configuration options
  45. *
  46. * @access private
  47. * @type {RadialGaugeOptions}
  48. */
  49. const defaultRadialGaugeOptions = Object.assign({}, GenericOptions, {
  50. // basic options
  51. ticksAngle: 270,
  52. startAngle: 45,
  53.  
  54. // colors
  55. colorNeedleCircleOuter: '#f0f0f0',
  56. colorNeedleCircleOuterEnd: '#ccc',
  57. colorNeedleCircleInner: '#e8e8e8',
  58. colorNeedleCircleInnerEnd: '#f5f5f5',
  59.  
  60. // needle
  61. needleCircleSize: 10,
  62. needleCircleInner: true,
  63. needleCircleOuter: true
  64. });
  65.  
  66. /* istanbul ignore next: private, not testable */
  67. /**
  68. * Draws gradient-filled circle on a canvas
  69. *
  70. * @access private
  71. * @param {number} radius
  72. * @param {Canvas2DContext} context
  73. * @param {string} start gradient start color
  74. * @param {string} end gradient end color
  75. */
  76. function drawRadialCircle(radius, context, start, end) {
  77. context.beginPath();
  78. context.arc(0, 0, radius, 0, PI * 2, true);
  79. context.fillStyle = drawings.linearGradient(context, start, end, radius);
  80. context.fill();
  81. context.closePath();
  82. }
  83.  
  84. /* istanbul ignore next: private, not testable */
  85. /**
  86. * Returns max radius without borders for the gauge
  87. *
  88. * @param {Canvas2DContext} context
  89. * @param {RadialGaugeOptions} options
  90. * @return {number}
  91. */
  92. function maxRadialRadius(context, options) {
  93. if (!context.maxRadius) {
  94. context.maxRadius = context.max
  95. - options.borderShadowWidth
  96. - options.borderOuterWidth
  97. - options.borderMiddleWidth
  98. - options.borderInnerWidth;
  99. }
  100.  
  101. return context.maxRadius;
  102. }
  103.  
  104. /* istanbul ignore next: private, not testable */
  105. /**
  106. * Draws gauge plate on the canvas
  107. *
  108. * @access private
  109. * @param {Canvas2DContext} context
  110. * @param {RadialGaugeOptions} options
  111. */
  112. function drawRadialPlate(context, options) {
  113. let d0 = options.borderShadowWidth;
  114. let r0 = context.max - options.borderShadowWidth;
  115. let r1 = r0 - options.borderOuterWidth;
  116. let r2 = r1 - options.borderMiddleWidth;
  117. let r3 = maxRadialRadius(context, options);
  118.  
  119. context.save();
  120.  
  121. if (options.borderOuterWidth) {
  122. drawRadialCircle(r0, context,
  123. options.colorBorderOuter,
  124. options.colorBorderOuterEnd);
  125. }
  126.  
  127. if (options.borderMiddleWidth) {
  128. drawRadialCircle(r1, context,
  129. options.colorBorderMiddle,
  130. options.colorBorderMiddleEnd);
  131. }
  132.  
  133. if (options.borderInnerWidth) {
  134. drawRadialCircle(r2, context,
  135. options.colorBorderInner,
  136. options.colorBorderInnerEnd);
  137. }
  138.  
  139. if (d0) {
  140. context.shadowBlur = d0;
  141. context.shadowColor = options.colorBorderShadow;
  142. }
  143.  
  144. context.beginPath();
  145. context.arc(0, 0, r3, 0, PI * 2, true);
  146. context.fillStyle = options.colorPlate;
  147. context.fill();
  148. context.closePath();
  149.  
  150. context.restore();
  151. }
  152.  
  153. /* istanbul ignore next: private, not testable */
  154. /**
  155. * Draws gauge highlight areas on a canvas
  156. *
  157. * @access private
  158. * @param {Canvas2DContext} context
  159. * @param {RadialGaugeOptions} options
  160. */
  161. function drawRadialHighlights(context, options) {
  162. context.save();
  163.  
  164. let r1 = radialTicksRadius(context, options);
  165. let r2 = r1 - context.max * .15;
  166. let i = 0, s = options.highlights.length;
  167.  
  168. for (; i < s; i++) {
  169. let hlt = options.highlights[i];
  170. let vd = (options.maxValue - options.minValue) / options.ticksAngle;
  171. let sa = drawings.radians(options.startAngle +
  172. (hlt.from - options.minValue) / vd);
  173. let ea = drawings.radians(options.startAngle +
  174. (hlt.to - options.minValue) / vd);
  175. let ps = drawings.radialPoint(r2, sa);
  176. let pe = drawings.radialPoint(r1, sa);
  177. let ps1 = drawings.radialPoint(r1, ea);
  178. let pe1 = drawings.radialPoint(r2, ea);
  179.  
  180. context.beginPath();
  181. context.rotate(HPI);
  182. context.arc(0, 0, r1, sa, ea, false);
  183. context.restore();
  184. context.save();
  185. context.moveTo(ps.x, ps.y);
  186. context.lineTo(pe.x, pe.y);
  187. context.lineTo(ps1.x, ps1.y);
  188. context.lineTo(pe1.x, pe1.y);
  189. context.lineTo(ps.x, ps.y);
  190. context.closePath();
  191.  
  192. context.fillStyle = hlt.color;
  193. context.fill();
  194.  
  195. context.beginPath();
  196. context.rotate(HPI);
  197. context.arc(0, 0, r2, sa - 0.2, ea + 0.2, false);
  198. context.restore();
  199. context.closePath();
  200.  
  201. context.fillStyle = options.colorPlate;
  202. context.fill();
  203. context.save();
  204. }
  205. }
  206.  
  207. /* istanbul ignore next: private, not testable */
  208. /**
  209. * Draws minor ticks bar on a canvas
  210. *
  211. * @access private
  212. * @param {Canvas2DContext} context
  213. * @param {RadialGaugeOptions} options
  214. */
  215. function drawRadialMinorTicks(context, options) {
  216. let radius = radialTicksRadius(context, options);
  217.  
  218. context.lineWidth = SmartCanvas.pixelRatio;
  219. context.strokeStyle = options.colorMinorTicks;
  220.  
  221. context.save();
  222.  
  223. let s = options.minorTicks * (options.majorTicks.length - 1);
  224. let i = 0;
  225.  
  226. for (; i < s; ++i) {
  227. let angle = options.startAngle + i * (options.ticksAngle / s);
  228.  
  229. context.rotate(drawings.radians(angle));
  230.  
  231. context.beginPath();
  232. context.moveTo(0, radius);
  233. context.lineTo(0, radius - context.max * .075);
  234. closeStrokedPath(context);
  235. }
  236. }
  237.  
  238. /* istanbul ignore next: private, not testable */
  239. /**
  240. * Returns ticks radius
  241. *
  242. * @access private
  243. * @param context
  244. * @param options
  245. * @return {number}
  246. */
  247. function radialTicksRadius(context, options) {
  248. return maxRadialRadius(context, options) - context.max * .05;
  249. }
  250.  
  251. /* istanbul ignore next: private, not testable */
  252. /**
  253. * Draws gauge major ticks bar on a canvas
  254. *
  255. * @param {Canvas2DContext} context
  256. * @param {RadialGaugeOptions} options
  257. */
  258. function drawRadialMajorTicks(context, options) {
  259. let r = radialTicksRadius(context, options);
  260. let i;
  261. let s = options.majorTicks.length;
  262. let pixelRatio = SmartCanvas.pixelRatio;
  263.  
  264. context.lineWidth = 2 * pixelRatio;
  265. context.strokeStyle = options.colorMajorTicks;
  266. context.save();
  267.  
  268. if (s === 0) {
  269. options.majorTicks.push(drawings.formatMajorTickNumber(
  270. options.minValue, options));
  271. options.majorTicks.push(drawings.formatMajorTickNumber(
  272. options.maxValue, options));
  273. s = 2;
  274. }
  275.  
  276. i = 0;
  277. for (; i < s; ++i) {
  278. context.rotate(drawings.radians(radialNextAngle(options, i, s)));
  279.  
  280. context.beginPath();
  281. context.moveTo(0, r);
  282. context.lineTo(0, r - context.max * .15);
  283. closeStrokedPath(context);
  284. }
  285.  
  286. if (options.strokeTicks) {
  287. context.rotate(HPI);
  288.  
  289. context.beginPath();
  290. context.arc(0, 0, r,
  291. drawings.radians(options.startAngle),
  292. drawings.radians(options.startAngle + options.ticksAngle),
  293. false
  294. );
  295. closeStrokedPath(context);
  296. }
  297. }
  298.  
  299. /* istanbul ignore next: private, not testable */
  300. function radialNextAngle(options, i, s) {
  301. return options.startAngle + i * (options.ticksAngle / (s - 1));
  302. }
  303.  
  304. /* istanbul ignore next: private, not testable */
  305. /**
  306. * Strokes, closes path and restores previous context state
  307. *
  308. * @param {Canvas2DContext} context
  309. */
  310. function closeStrokedPath(context) {
  311. context.stroke();
  312. context.restore();
  313. context.closePath();
  314. context.save();
  315. }
  316.  
  317. /* istanbul ignore next: private, not testable */
  318. /**
  319. * Draws gauge bar numbers
  320. *
  321. * @access private
  322. * @param {Canvas2DContext} context
  323. * @param {RadialGaugeOptions} options
  324. */
  325. function drawRadialNumbers(context, options) {
  326. let radius = maxRadialRadius(context, options) - context.max * .35;
  327. let points = {};
  328. let i = 0;
  329. let s = options.majorTicks.length;
  330.  
  331. for (; i < s; ++i) {
  332. let angle = radialNextAngle(options, i, s);
  333. let point = drawings.radialPoint(radius, drawings.radians(angle));
  334.  
  335. if (angle === 360) angle = 0;
  336.  
  337. if (points[angle]) {
  338. continue; //already drawn at this place, skipping
  339. }
  340.  
  341. points[angle] = true;
  342.  
  343. context.font = 20 * (context.max / 200) + 'px ' + options.fontNumbers;
  344. context.fillStyle = options.colorNumbers;
  345. context.lineWidth = 0;
  346. context.textAlign = 'center';
  347. context.fillText(options.majorTicks[i], point.x, point.y + 3);
  348. }
  349. }
  350.  
  351. /* istanbul ignore next: private, not testable */
  352. /**
  353. * Draws gauge title
  354. *
  355. * @access private
  356. * @param {Canvas2DContext} context
  357. * @param {RadialGaugeOptions} options
  358. */
  359. function drawRadialTitle(context, options) {
  360. if (!options.title) return;
  361.  
  362. context.save();
  363. context.font = 24 * (context.max / 200) + 'px ' + options.fontTitle;
  364. context.fillStyle = options.colorTitle;
  365. context.textAlign = 'center';
  366. context.fillText(options.title, 0, -context.max / 4.25, context.max * .8);
  367. context.restore();
  368. }
  369.  
  370. /* istanbul ignore next: private, not testable */
  371. /**
  372. * Draws units name on the gauge
  373. *
  374. * @access private
  375. * @param {Canvas2DContext} context
  376. * @param {RadialGaugeOptions} options
  377. */
  378. function drawRadialUnits(context, options) {
  379. if (!options.units) return;
  380.  
  381. context.save();
  382. context.font = 22 * (context.max / 200) + 'px ' + options.fontUnits;
  383. context.fillStyle = options.colorUnits;
  384. context.textAlign = 'center';
  385. context.fillText(options.units, 0, context.max / 3.25, context.max * .8);
  386. context.restore();
  387. }
  388.  
  389. /* istanbul ignore next: private, not testable */
  390. /**
  391. * Draws gauge needle
  392. *
  393. * @access private
  394. * @param {Canvas2DContext} context
  395. * @param {RadialGaugeOptions} options
  396. */
  397. function drawRadialNeedle(context, options) {
  398. if (!options.needle) return;
  399.  
  400. let value = options.value;
  401. let max = maxRadialRadius(context, options);
  402. let r1 = max / 100 * options.needleCircleSize;
  403. let r2 = max / 100 * options.needleCircleSize * 0.75;
  404. let rIn = max / 100 * options.needleEnd;
  405. let rStart = options.needleStart ?
  406. max / 100 * options.needleStart : 0,
  407. rOut = max * .2;
  408. let pad1 = max / 100 * options.needleWidth;
  409. let pad2 = max / 100 * options.needleWidth / 2;
  410. let pixelRatio = SmartCanvas.pixelRatio;
  411.  
  412. context.save();
  413.  
  414. drawings.drawNeedleShadow(context, options);
  415.  
  416. context.rotate(drawings.radians(
  417. options.startAngle + (value - options.minValue) /
  418. (options.maxValue - options.minValue) * options.ticksAngle));
  419.  
  420. context.fillStyle = drawings.linearGradient(
  421. context,
  422. options.colorNeedle,
  423. options.colorNeedleEnd,
  424. rIn - rOut);
  425.  
  426. if (options.needleType === 'arrow') {
  427. context.beginPath();
  428. context.moveTo(-pad2, -rOut);
  429. context.lineTo(-pad1, 0);
  430. context.lineTo(-1 * pixelRatio, rIn);
  431. context.lineTo(pixelRatio, rIn);
  432. context.lineTo(pad1, 0);
  433. context.lineTo(pad2, -rOut);
  434. context.closePath();
  435. context.fill();
  436.  
  437. context.beginPath();
  438. context.lineTo(-0.5 * pixelRatio, rIn);
  439. context.lineTo(-1 * pixelRatio, rIn);
  440. context.lineTo(-pad1, 0);
  441. context.lineTo(-pad2, -rOut);
  442. context.lineTo(pad2 / 2 * pixelRatio - 2 * pixelRatio, -rOut);
  443. context.closePath();
  444. context.fillStyle = options.colorNeedleShadowUp;
  445. context.fill();
  446. }
  447.  
  448. else { // simple line needle
  449. context.beginPath();
  450. context.moveTo(-pad2, rIn);
  451. context.lineTo(-pad2, rStart);
  452. context.lineTo(pad2, rStart);
  453. context.lineTo(pad2, rIn);
  454. context.closePath();
  455. context.fill();
  456. }
  457.  
  458. if (options.needleCircleSize) {
  459. drawings.drawNeedleShadow(context, options);
  460.  
  461. if (options.needleCircleOuter) {
  462. context.beginPath();
  463. context.arc(0, 0, r1, 0, PI * 2, true);
  464. context.fillStyle = drawings.linearGradient(
  465. context,
  466. options.colorNeedleCircleOuter,
  467. options.colorNeedleCircleOuterEnd,
  468. r1
  469. );
  470. context.fill();
  471. context.closePath();
  472. }
  473.  
  474. if (options.needleCircleInner) {
  475. context.beginPath();
  476. context.arc(0, 0, r2, 0, PI * 2, true);
  477. context.fillStyle = drawings.linearGradient(
  478. context,
  479. options.colorNeedleCircleInner,
  480. options.colorNeedleCircleInnerEnd,
  481. r2
  482. );
  483. context.fill();
  484. context.closePath();
  485. }
  486.  
  487. context.restore();
  488. }
  489. }
  490.  
  491. /* istanbul ignore next: private, not testable */
  492. /**
  493. * Draws gauge value box
  494. *
  495. * @param {Canvas2DContext} context
  496. * @param {RadialGaugeOptions} options
  497. * @param {number} value
  498. */
  499. function drawRadialValueBox(context, options, value) {
  500. drawings.drawValueBox(context, options, value, 0,
  501. context.max - context.max * .33, context.max);
  502. }
  503.  
  504. /**
  505. * Minimalistic HTML5 Canvas Gauge
  506. * @example
  507. * var gauge = new RadialGauge({
  508. * renderTo: 'gauge-id', // identifier of HTML canvas element or element itself
  509. * width: 400,
  510. * height: 400,
  511. * units: 'Km/h',
  512. * title: false,
  513. * value: 0,
  514. * minValue: 0,
  515. * maxValue: 220,
  516. * majorTicks: [
  517. * '0','20','40','60','80','100','120','140','160','180','200','220'
  518. * ],
  519. * minorTicks: 2,
  520. * strokeTicks: false,
  521. * highlights: [
  522. * { from: 0, to: 50, color: 'rgba(0,255,0,.15)' },
  523. * { from: 50, to: 100, color: 'rgba(255,255,0,.15)' },
  524. * { from: 100, to: 150, color: 'rgba(255,30,0,.25)' },
  525. * { from: 150, to: 200, color: 'rgba(255,0,225,.25)' },
  526. * { from: 200, to: 220, color: 'rgba(0,0,255,.25)' }
  527. * ],
  528. * colorPlate: '#222',
  529. * colorMajorTicks: '#f5f5f5',
  530. * colorMinorTicks: '#ddd',
  531. * colorTitle: '#fff',
  532. * colorUnits: '#ccc',
  533. * colorNumbers: '#eee',
  534. * colorNeedleStart: 'rgba(240, 128, 128, 1)',
  535. * colorNeedleEnd: 'rgba(255, 160, 122, .9)',
  536. * valueBox: true,
  537. * animationRule: 'bounce'
  538. * });
  539. * // draw initially
  540. * gauge.draw();
  541. * // animate
  542. * setInterval(() => {
  543. * gauge.value = Math.random() * -220 + 220;
  544. * }, 1000);
  545. */
  546. export default class RadialGauge extends BaseGauge {
  547.  
  548. /**
  549. * @constructor
  550. * @param {RadialGaugeOptions} options
  551. */
  552. constructor(options) {
  553. options = Object.assign({}, defaultRadialGaugeOptions, options || {});
  554.  
  555. /* istanbul ignore if */
  556. if (isNaN(options.startAngle)) options.startAngle = 45;
  557. /* istanbul ignore if */
  558. if (isNaN(options.ticksAngle)) options.ticksAngle = 270;
  559.  
  560. /* istanbul ignore if */
  561. if (options.ticksAngle > 360) options.ticksAngle = 360;
  562. /* istanbul ignore if */
  563. if (options.ticksAngle < 0) options.ticksAngle = 0;
  564.  
  565. /* istanbul ignore if */
  566. if (options.startAngle < 0) options.startAngle = 0;
  567. /* istanbul ignore if */
  568. if (options.startAngle > 360) options.startAngle = 360;
  569.  
  570. super(options);
  571. }
  572.  
  573. /* */
  574. /**
  575. * Triggering gauge render on a canvas.
  576. *
  577. * @returns {RadialGauge}
  578. */
  579. draw() {
  580. let canvas = this.canvas;
  581. let [x, y, w, h] = [
  582. -canvas.drawX,
  583. -canvas.drawY,
  584. canvas.drawWidth,
  585. canvas.drawHeight
  586. ];
  587. let options = this.options;
  588.  
  589. if (!canvas.elementClone.initialized) {
  590. let context = canvas.contextClone;
  591.  
  592. // clear the cache
  593. context.clearRect(x, y, w, h);
  594. context.save();
  595.  
  596. drawRadialPlate(context, options);
  597. drawRadialHighlights(context, options);
  598. drawRadialMinorTicks(context, options);
  599. drawRadialMajorTicks(context, options);
  600. drawRadialNumbers(context, options);
  601. drawRadialTitle(context, options);
  602. drawRadialUnits(context, options);
  603.  
  604. canvas.elementClone.initialized = true;
  605. }
  606.  
  607. this.canvas.commit();
  608.  
  609. // clear the canvas
  610. canvas.context.clearRect(x, y, w, h);
  611. canvas.context.save();
  612.  
  613. canvas.context.drawImage(canvas.elementClone, x, y, w, h);
  614. canvas.context.save();
  615.  
  616. drawRadialValueBox(canvas.context, options, options.animatedValue ?
  617. this.options.value : this.value);
  618. drawRadialNeedle(canvas.context, options);
  619.  
  620. return this;
  621. }
  622. }
  623.  
  624.  
  625. /**
  626. * @typedef {object} ns
  627. */
  628. /* istanbul ignore if */
  629. if (typeof ns !== 'undefined') {
  630. ns['RadialGauge'] = RadialGauge;
  631. }
  632.  
  633. BaseGauge.initialize('RadialGauge', defaultRadialGaugeOptions);
  634.  
  635. module.exports = RadialGauge;