You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

722 lines
17 KiB

  1. import { getBarHeightAndYAttr, truncateString, shortenLargeNumber, getSplineCurvePointsStr } from './draw-utils';
  2. import { getStringWidth } from './helpers';
  3. import { DOT_OVERLAY_SIZE_INCR, PERCENTAGE_BAR_DEFAULT_DEPTH } from './constants';
  4. import { lightenDarkenColor } from './colors';
  5. export const AXIS_TICK_LENGTH = 6;
  6. const LABEL_MARGIN = 4;
  7. const LABEL_MAX_CHARS = 15;
  8. export const FONT_SIZE = 10;
  9. const BASE_LINE_COLOR = '#dadada';
  10. const FONT_FILL = '#555b51';
  11. function $(expr, con) {
  12. return typeof expr === "string"? (con || document).querySelector(expr) : expr || null;
  13. }
  14. export function createSVG(tag, o) {
  15. var element = document.createElementNS("http://www.w3.org/2000/svg", tag);
  16. for (var i in o) {
  17. var val = o[i];
  18. if (i === "inside") {
  19. $(val).appendChild(element);
  20. }
  21. else if (i === "around") {
  22. var ref = $(val);
  23. ref.parentNode.insertBefore(element, ref);
  24. element.appendChild(ref);
  25. } else if (i === "styles") {
  26. if(typeof val === "object") {
  27. Object.keys(val).map(prop => {
  28. element.style[prop] = val[prop];
  29. });
  30. }
  31. } else {
  32. if(i === "className") { i = "class"; }
  33. if(i === "innerHTML") {
  34. element['textContent'] = val;
  35. } else {
  36. element.setAttribute(i, val);
  37. }
  38. }
  39. }
  40. return element;
  41. }
  42. function renderVerticalGradient(svgDefElem, gradientId) {
  43. return createSVG('linearGradient', {
  44. inside: svgDefElem,
  45. id: gradientId,
  46. x1: 0,
  47. x2: 0,
  48. y1: 0,
  49. y2: 1
  50. });
  51. }
  52. function setGradientStop(gradElem, offset, color, opacity) {
  53. return createSVG('stop', {
  54. 'inside': gradElem,
  55. 'style': `stop-color: ${color}`,
  56. 'offset': offset,
  57. 'stop-opacity': opacity
  58. });
  59. }
  60. export function makeSVGContainer(parent, className, width, height) {
  61. return createSVG('svg', {
  62. className: className,
  63. inside: parent,
  64. width: width,
  65. height: height
  66. });
  67. }
  68. export function makeSVGDefs(svgContainer) {
  69. return createSVG('defs', {
  70. inside: svgContainer,
  71. });
  72. }
  73. export function makeSVGGroup(className, transform='', parent=undefined) {
  74. let args = {
  75. className: className,
  76. transform: transform
  77. };
  78. if(parent) args.inside = parent;
  79. return createSVG('g', args);
  80. }
  81. export function wrapInSVGGroup(elements, className='') {
  82. let g = createSVG('g', {
  83. className: className
  84. });
  85. elements.forEach(e => g.appendChild(e));
  86. return g;
  87. }
  88. export function makePath(pathStr, className='', stroke='none', fill='none', strokeWidth=2) {
  89. return createSVG('path', {
  90. className: className,
  91. d: pathStr,
  92. styles: {
  93. stroke: stroke,
  94. fill: fill,
  95. 'stroke-width': strokeWidth
  96. }
  97. });
  98. }
  99. export function makeArcPathStr(startPosition, endPosition, center, radius, clockWise=1, largeArc=0){
  100. let [arcStartX, arcStartY] = [center.x + startPosition.x, center.y + startPosition.y];
  101. let [arcEndX, arcEndY] = [center.x + endPosition.x, center.y + endPosition.y];
  102. return `M${center.x} ${center.y}
  103. L${arcStartX} ${arcStartY}
  104. A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
  105. ${arcEndX} ${arcEndY} z`;
  106. }
  107. export function makeCircleStr(startPosition, endPosition, center, radius, clockWise=1, largeArc=0){
  108. let [arcStartX, arcStartY] = [center.x + startPosition.x, center.y + startPosition.y];
  109. let [arcEndX, midArc, arcEndY] = [center.x + endPosition.x, center.y * 2, center.y + endPosition.y];
  110. return `M${center.x} ${center.y}
  111. L${arcStartX} ${arcStartY}
  112. A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
  113. ${arcEndX} ${midArc} z
  114. L${arcStartX} ${midArc}
  115. A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
  116. ${arcEndX} ${arcEndY} z`;
  117. }
  118. export function makeArcStrokePathStr(startPosition, endPosition, center, radius, clockWise=1, largeArc=0){
  119. let [arcStartX, arcStartY] = [center.x + startPosition.x, center.y + startPosition.y];
  120. let [arcEndX, arcEndY] = [center.x + endPosition.x, center.y + endPosition.y];
  121. return `M${arcStartX} ${arcStartY}
  122. A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
  123. ${arcEndX} ${arcEndY}`;
  124. }
  125. export function makeStrokeCircleStr(startPosition, endPosition, center, radius, clockWise=1, largeArc=0){
  126. let [arcStartX, arcStartY] = [center.x + startPosition.x, center.y + startPosition.y];
  127. let [arcEndX, midArc, arcEndY] = [center.x + endPosition.x, radius * 2 + arcStartY, center.y + startPosition.y];
  128. return `M${arcStartX} ${arcStartY}
  129. A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
  130. ${arcEndX} ${midArc}
  131. M${arcStartX} ${midArc}
  132. A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0}
  133. ${arcEndX} ${arcEndY}`;
  134. }
  135. export function makeGradient(svgDefElem, color, lighter = false) {
  136. let gradientId ='path-fill-gradient' + '-' + color + '-' +(lighter ? 'lighter' : 'default');
  137. let gradientDef = renderVerticalGradient(svgDefElem, gradientId);
  138. let opacities = [1, 0.6, 0.2];
  139. if(lighter) {
  140. opacities = [0.4, 0.2, 0];
  141. }
  142. setGradientStop(gradientDef, "0%", color, opacities[0]);
  143. setGradientStop(gradientDef, "50%", color, opacities[1]);
  144. setGradientStop(gradientDef, "100%", color, opacities[2]);
  145. return gradientId;
  146. }
  147. export function percentageBar(x, y, width, height,
  148. depth=PERCENTAGE_BAR_DEFAULT_DEPTH, fill='none') {
  149. let args = {
  150. className: 'percentage-bar',
  151. x: x,
  152. y: y,
  153. width: width,
  154. height: height,
  155. fill: fill,
  156. styles: {
  157. 'stroke': lightenDarkenColor(fill, -25),
  158. // Diabolically good: https://stackoverflow.com/a/9000859
  159. // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray
  160. 'stroke-dasharray': `0, ${height + width}, ${width}, ${height}`,
  161. 'stroke-width': depth
  162. },
  163. };
  164. return createSVG("rect", args);
  165. }
  166. export function heatSquare(className, x, y, size, radius, fill='none', data={}) {
  167. let args = {
  168. className: className,
  169. x: x,
  170. y: y,
  171. width: size,
  172. height: size,
  173. rx: radius,
  174. fill: fill
  175. };
  176. Object.keys(data).map(key => {
  177. args[key] = data[key];
  178. });
  179. return createSVG("rect", args);
  180. }
  181. export function legendBar(x, y, size, fill='none', label, truncate=false) {
  182. label = truncate ? truncateString(label, LABEL_MAX_CHARS) : label;
  183. let args = {
  184. className: 'legend-bar',
  185. x: 0,
  186. y: 0,
  187. width: size,
  188. height: '2px',
  189. fill: fill
  190. };
  191. let text = createSVG('text', {
  192. className: 'legend-dataset-text',
  193. x: 0,
  194. y: 0,
  195. dy: (FONT_SIZE * 2) + 'px',
  196. 'font-size': (FONT_SIZE * 1.2) + 'px',
  197. 'text-anchor': 'start',
  198. fill: FONT_FILL,
  199. innerHTML: label
  200. });
  201. let group = createSVG('g', {
  202. transform: `translate(${x}, ${y})`
  203. });
  204. group.appendChild(createSVG("rect", args));
  205. group.appendChild(text);
  206. return group;
  207. }
  208. export function legendDot(x, y, size, fill='none', label, truncate=false) {
  209. label = truncate ? truncateString(label, LABEL_MAX_CHARS) : label;
  210. let args = {
  211. className: 'legend-dot',
  212. cx: 0,
  213. cy: 0,
  214. r: size,
  215. fill: fill
  216. };
  217. let text = createSVG('text', {
  218. className: 'legend-dataset-text',
  219. x: 0,
  220. y: 0,
  221. dx: (FONT_SIZE) + 'px',
  222. dy: (FONT_SIZE/3) + 'px',
  223. 'font-size': (FONT_SIZE * 1.2) + 'px',
  224. 'text-anchor': 'start',
  225. fill: FONT_FILL,
  226. innerHTML: label
  227. });
  228. let group = createSVG('g', {
  229. transform: `translate(${x}, ${y})`
  230. });
  231. group.appendChild(createSVG("circle", args));
  232. group.appendChild(text);
  233. return group;
  234. }
  235. export function makeText(className, x, y, content, options = {}) {
  236. let fontSize = options.fontSize || FONT_SIZE;
  237. let dy = options.dy !== undefined ? options.dy : (fontSize / 2);
  238. let fill = options.fill || FONT_FILL;
  239. let textAnchor = options.textAnchor || 'start';
  240. return createSVG('text', {
  241. className: className,
  242. x: x,
  243. y: y,
  244. dy: dy + 'px',
  245. 'font-size': fontSize + 'px',
  246. fill: fill,
  247. 'text-anchor': textAnchor,
  248. innerHTML: content
  249. });
  250. }
  251. function makeVertLine(x, label, y1, y2, options={}) {
  252. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  253. let l = createSVG('line', {
  254. className: 'line-vertical ' + options.className,
  255. x1: 0,
  256. x2: 0,
  257. y1: y1,
  258. y2: y2,
  259. styles: {
  260. stroke: options.stroke
  261. }
  262. });
  263. let text = createSVG('text', {
  264. x: 0,
  265. y: y1 > y2 ? y1 + LABEL_MARGIN : y1 - LABEL_MARGIN - FONT_SIZE,
  266. dy: FONT_SIZE + 'px',
  267. 'font-size': FONT_SIZE + 'px',
  268. 'text-anchor': 'middle',
  269. innerHTML: label + ""
  270. });
  271. let line = createSVG('g', {
  272. transform: `translate(${ x }, 0)`
  273. });
  274. line.appendChild(l);
  275. line.appendChild(text);
  276. return line;
  277. }
  278. function makeHoriLine(y, label, x1, x2, options={}) {
  279. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  280. if(!options.lineType) options.lineType = '';
  281. if (options.shortenNumbers) label = shortenLargeNumber(label);
  282. let className = 'line-horizontal ' + options.className +
  283. (options.lineType === "dashed" ? "dashed": "");
  284. let l = createSVG('line', {
  285. className: className,
  286. x1: x1,
  287. x2: x2,
  288. y1: 0,
  289. y2: 0,
  290. styles: {
  291. stroke: options.stroke
  292. }
  293. });
  294. let text = createSVG('text', {
  295. x: x1 < x2 ? x1 - LABEL_MARGIN : x1 + LABEL_MARGIN,
  296. y: 0,
  297. dy: (FONT_SIZE / 2 - 2) + 'px',
  298. 'font-size': FONT_SIZE + 'px',
  299. 'text-anchor': x1 < x2 ? 'end' : 'start',
  300. innerHTML: label+""
  301. });
  302. let line = createSVG('g', {
  303. transform: `translate(0, ${y})`,
  304. 'stroke-opacity': 1
  305. });
  306. if(text === 0 || text === '0') {
  307. line.style.stroke = "rgba(27, 31, 35, 0.6)";
  308. }
  309. line.appendChild(l);
  310. line.appendChild(text);
  311. return line;
  312. }
  313. export function yLine(y, label, width, options={}) {
  314. if(!options.pos) options.pos = 'left';
  315. if(!options.offset) options.offset = 0;
  316. if(!options.mode) options.mode = 'span';
  317. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  318. if(!options.className) options.className = '';
  319. let x1 = -1 * AXIS_TICK_LENGTH;
  320. let x2 = options.mode === 'span' ? width + AXIS_TICK_LENGTH : 0;
  321. if(options.mode === 'tick' && options.pos === 'right') {
  322. x1 = width + AXIS_TICK_LENGTH;
  323. x2 = width;
  324. }
  325. // let offset = options.pos === 'left' ? -1 * options.offset : options.offset;
  326. x1 += options.offset;
  327. x2 += options.offset;
  328. return makeHoriLine(y, label, x1, x2, {
  329. stroke: options.stroke,
  330. className: options.className,
  331. lineType: options.lineType,
  332. shortenNumbers: options.shortenNumbers
  333. });
  334. }
  335. export function xLine(x, label, height, options={}) {
  336. if(!options.pos) options.pos = 'bottom';
  337. if(!options.offset) options.offset = 0;
  338. if(!options.mode) options.mode = 'span';
  339. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  340. if(!options.className) options.className = '';
  341. // Draw X axis line in span/tick mode with optional label
  342. // y2(span)
  343. // |
  344. // |
  345. // x line |
  346. // |
  347. // |
  348. // ---------------------+-- y2(tick)
  349. // |
  350. // y1
  351. let y1 = height + AXIS_TICK_LENGTH;
  352. let y2 = options.mode === 'span' ? -1 * AXIS_TICK_LENGTH : height;
  353. if(options.mode === 'tick' && options.pos === 'top') {
  354. // top axis ticks
  355. y1 = -1 * AXIS_TICK_LENGTH;
  356. y2 = 0;
  357. }
  358. return makeVertLine(x, label, y1, y2, {
  359. stroke: options.stroke,
  360. className: options.className,
  361. lineType: options.lineType
  362. });
  363. }
  364. export function yMarker(y, label, width, options={}) {
  365. if(!options.labelPos) options.labelPos = 'right';
  366. let x = options.labelPos === 'left' ? LABEL_MARGIN
  367. : width - getStringWidth(label, 5) - LABEL_MARGIN;
  368. let labelSvg = createSVG('text', {
  369. className: 'chart-label',
  370. x: x,
  371. y: 0,
  372. dy: (FONT_SIZE / -2) + 'px',
  373. 'font-size': FONT_SIZE + 'px',
  374. 'text-anchor': 'start',
  375. innerHTML: label+""
  376. });
  377. let line = makeHoriLine(y, '', 0, width, {
  378. stroke: options.stroke || BASE_LINE_COLOR,
  379. className: options.className || '',
  380. lineType: options.lineType
  381. });
  382. line.appendChild(labelSvg);
  383. return line;
  384. }
  385. export function yRegion(y1, y2, width, label, options={}) {
  386. // return a group
  387. let height = y1 - y2;
  388. let rect = createSVG('rect', {
  389. className: `bar mini`, // remove class
  390. styles: {
  391. fill: `rgba(228, 234, 239, 0.49)`,
  392. stroke: BASE_LINE_COLOR,
  393. 'stroke-dasharray': `${width}, ${height}`
  394. },
  395. // 'data-point-index': index,
  396. x: 0,
  397. y: 0,
  398. width: width,
  399. height: height
  400. });
  401. if(!options.labelPos) options.labelPos = 'right';
  402. let x = options.labelPos === 'left' ? LABEL_MARGIN
  403. : width - getStringWidth(label+"", 4.5) - LABEL_MARGIN;
  404. let labelSvg = createSVG('text', {
  405. className: 'chart-label',
  406. x: x,
  407. y: 0,
  408. dy: (FONT_SIZE / -2) + 'px',
  409. 'font-size': FONT_SIZE + 'px',
  410. 'text-anchor': 'start',
  411. innerHTML: label+""
  412. });
  413. let region = createSVG('g', {
  414. transform: `translate(0, ${y2})`
  415. });
  416. region.appendChild(rect);
  417. region.appendChild(labelSvg);
  418. return region;
  419. }
  420. export function datasetBar(x, yTop, width, color, label='', index=0, offset=0, meta={}) {
  421. let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine);
  422. y -= offset;
  423. if(height === 0) {
  424. height = meta.minHeight;
  425. y -= meta.minHeight;
  426. }
  427. let rect = createSVG('rect', {
  428. className: `bar mini`,
  429. style: `fill: ${color}`,
  430. 'data-point-index': index,
  431. x: x,
  432. y: y,
  433. width: width,
  434. height: height
  435. });
  436. label += "";
  437. if(!label && !label.length) {
  438. return rect;
  439. } else {
  440. rect.setAttribute('y', 0);
  441. rect.setAttribute('x', 0);
  442. let text = createSVG('text', {
  443. className: 'data-point-value',
  444. x: width/2,
  445. y: 0,
  446. dy: (FONT_SIZE / 2 * -1) + 'px',
  447. 'font-size': FONT_SIZE + 'px',
  448. 'text-anchor': 'middle',
  449. innerHTML: label
  450. });
  451. let group = createSVG('g', {
  452. 'data-point-index': index,
  453. transform: `translate(${x}, ${y})`
  454. });
  455. group.appendChild(rect);
  456. group.appendChild(text);
  457. return group;
  458. }
  459. }
  460. export function datasetDot(x, y, radius, color, label='', index=0) {
  461. let dot = createSVG('circle', {
  462. style: `fill: ${color}`,
  463. 'data-point-index': index,
  464. cx: x,
  465. cy: y,
  466. r: radius
  467. });
  468. label += "";
  469. if(!label && !label.length) {
  470. return dot;
  471. } else {
  472. dot.setAttribute('cy', 0);
  473. dot.setAttribute('cx', 0);
  474. let text = createSVG('text', {
  475. className: 'data-point-value',
  476. x: 0,
  477. y: 0,
  478. dy: (FONT_SIZE / 2 * -1 - radius) + 'px',
  479. 'font-size': FONT_SIZE + 'px',
  480. 'text-anchor': 'middle',
  481. innerHTML: label
  482. });
  483. let group = createSVG('g', {
  484. 'data-point-index': index,
  485. transform: `translate(${x}, ${y})`
  486. });
  487. group.appendChild(dot);
  488. group.appendChild(text);
  489. return group;
  490. }
  491. }
  492. export function getPaths(xList, yList, color, options={}, meta={}) {
  493. let pointsList = yList.map((y, i) => (xList[i] + ',' + y));
  494. let pointsStr = pointsList.join("L");
  495. // Spline
  496. if (options.spline)
  497. pointsStr = getSplineCurvePointsStr(xList, yList);
  498. let path = makePath("M"+pointsStr, 'line-graph-path', color);
  499. // HeatLine
  500. if(options.heatline) {
  501. let gradient_id = makeGradient(meta.svgDefs, color);
  502. path.style.stroke = `url(#${gradient_id})`;
  503. }
  504. let paths = {
  505. path: path
  506. };
  507. // Region
  508. if(options.regionFill) {
  509. let gradient_id_region = makeGradient(meta.svgDefs, color, true);
  510. let pathStr = "M" + `${xList[0]},${meta.zeroLine}L` + pointsStr + `L${xList.slice(-1)[0]},${meta.zeroLine}`;
  511. paths.region = makePath(pathStr, `region-fill`, 'none', `url(#${gradient_id_region})`);
  512. }
  513. return paths;
  514. }
  515. export let makeOverlay = {
  516. 'bar': (unit) => {
  517. let transformValue;
  518. if(unit.nodeName !== 'rect') {
  519. transformValue = unit.getAttribute('transform');
  520. unit = unit.childNodes[0];
  521. }
  522. let overlay = unit.cloneNode();
  523. overlay.style.fill = '#000000';
  524. overlay.style.opacity = '0.4';
  525. if(transformValue) {
  526. overlay.setAttribute('transform', transformValue);
  527. }
  528. return overlay;
  529. },
  530. 'dot': (unit) => {
  531. let transformValue;
  532. if(unit.nodeName !== 'circle') {
  533. transformValue = unit.getAttribute('transform');
  534. unit = unit.childNodes[0];
  535. }
  536. let overlay = unit.cloneNode();
  537. let radius = unit.getAttribute('r');
  538. let fill = unit.getAttribute('fill');
  539. overlay.setAttribute('r', parseInt(radius) + DOT_OVERLAY_SIZE_INCR);
  540. overlay.setAttribute('fill', fill);
  541. overlay.style.opacity = '0.6';
  542. if(transformValue) {
  543. overlay.setAttribute('transform', transformValue);
  544. }
  545. return overlay;
  546. },
  547. 'heat_square': (unit) => {
  548. let transformValue;
  549. if(unit.nodeName !== 'circle') {
  550. transformValue = unit.getAttribute('transform');
  551. unit = unit.childNodes[0];
  552. }
  553. let overlay = unit.cloneNode();
  554. let radius = unit.getAttribute('r');
  555. let fill = unit.getAttribute('fill');
  556. overlay.setAttribute('r', parseInt(radius) + DOT_OVERLAY_SIZE_INCR);
  557. overlay.setAttribute('fill', fill);
  558. overlay.style.opacity = '0.6';
  559. if(transformValue) {
  560. overlay.setAttribute('transform', transformValue);
  561. }
  562. return overlay;
  563. }
  564. };
  565. export let updateOverlay = {
  566. 'bar': (unit, overlay) => {
  567. let transformValue;
  568. if(unit.nodeName !== 'rect') {
  569. transformValue = unit.getAttribute('transform');
  570. unit = unit.childNodes[0];
  571. }
  572. let attributes = ['x', 'y', 'width', 'height'];
  573. Object.values(unit.attributes)
  574. .filter(attr => attributes.includes(attr.name) && attr.specified)
  575. .map(attr => {
  576. overlay.setAttribute(attr.name, attr.nodeValue);
  577. });
  578. if(transformValue) {
  579. overlay.setAttribute('transform', transformValue);
  580. }
  581. },
  582. 'dot': (unit, overlay) => {
  583. let transformValue;
  584. if(unit.nodeName !== 'circle') {
  585. transformValue = unit.getAttribute('transform');
  586. unit = unit.childNodes[0];
  587. }
  588. let attributes = ['cx', 'cy'];
  589. Object.values(unit.attributes)
  590. .filter(attr => attributes.includes(attr.name) && attr.specified)
  591. .map(attr => {
  592. overlay.setAttribute(attr.name, attr.nodeValue);
  593. });
  594. if(transformValue) {
  595. overlay.setAttribute('transform', transformValue);
  596. }
  597. },
  598. 'heat_square': (unit, overlay) => {
  599. let transformValue;
  600. if(unit.nodeName !== 'circle') {
  601. transformValue = unit.getAttribute('transform');
  602. unit = unit.childNodes[0];
  603. }
  604. let attributes = ['cx', 'cy'];
  605. Object.values(unit.attributes)
  606. .filter(attr => attributes.includes(attr.name) && attr.specified)
  607. .map(attr => {
  608. overlay.setAttribute(attr.name, attr.nodeValue);
  609. });
  610. if(transformValue) {
  611. overlay.setAttribute('transform', transformValue);
  612. }
  613. },
  614. };