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.
 
 
 

699 lines
16 KiB

  1. import { getBarHeightAndYAttr, truncateString } 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 makeGradient(svgDefElem, color, lighter = false) {
  126. let gradientId ='path-fill-gradient' + '-' + color + '-' +(lighter ? 'lighter' : 'default');
  127. let gradientDef = renderVerticalGradient(svgDefElem, gradientId);
  128. let opacities = [1, 0.6, 0.2];
  129. if(lighter) {
  130. opacities = [0.4, 0.2, 0];
  131. }
  132. setGradientStop(gradientDef, "0%", color, opacities[0]);
  133. setGradientStop(gradientDef, "50%", color, opacities[1]);
  134. setGradientStop(gradientDef, "100%", color, opacities[2]);
  135. return gradientId;
  136. }
  137. export function percentageBar(x, y, width, height,
  138. depth=PERCENTAGE_BAR_DEFAULT_DEPTH, fill='none') {
  139. let args = {
  140. className: 'percentage-bar',
  141. x: x,
  142. y: y,
  143. width: width,
  144. height: height,
  145. fill: fill,
  146. styles: {
  147. 'stroke': lightenDarkenColor(fill, -25),
  148. // Diabolically good: https://stackoverflow.com/a/9000859
  149. // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray
  150. 'stroke-dasharray': `0, ${height + width}, ${width}, ${height}`,
  151. 'stroke-width': depth
  152. },
  153. };
  154. return createSVG("rect", args);
  155. }
  156. export function heatSquare(className, x, y, size, fill='none', data={}) {
  157. let args = {
  158. className: className,
  159. x: x,
  160. y: y,
  161. width: size,
  162. height: size,
  163. fill: fill
  164. };
  165. Object.keys(data).map(key => {
  166. args[key] = data[key];
  167. });
  168. return createSVG("rect", args);
  169. }
  170. export function legendBar(x, y, size, fill='none', label, truncate=false) {
  171. label = truncate ? truncateString(label, LABEL_MAX_CHARS) : label;
  172. let args = {
  173. className: 'legend-bar',
  174. x: 0,
  175. y: 0,
  176. width: size,
  177. height: '2px',
  178. fill: fill
  179. };
  180. let text = createSVG('text', {
  181. className: 'legend-dataset-text',
  182. x: 0,
  183. y: 0,
  184. dy: (FONT_SIZE * 2) + 'px',
  185. 'font-size': (FONT_SIZE * 1.2) + 'px',
  186. 'text-anchor': 'start',
  187. fill: FONT_FILL,
  188. innerHTML: label
  189. });
  190. let group = createSVG('g', {
  191. transform: `translate(${x}, ${y})`
  192. });
  193. group.appendChild(createSVG("rect", args));
  194. group.appendChild(text);
  195. return group;
  196. }
  197. export function legendDot(x, y, size, fill='none', label) {
  198. let args = {
  199. className: 'legend-dot',
  200. cx: 0,
  201. cy: 0,
  202. r: size,
  203. fill: fill
  204. };
  205. let text = createSVG('text', {
  206. className: 'legend-dataset-text',
  207. x: 0,
  208. y: 0,
  209. dx: (FONT_SIZE) + 'px',
  210. dy: (FONT_SIZE/3) + 'px',
  211. 'font-size': (FONT_SIZE * 1.2) + 'px',
  212. 'text-anchor': 'start',
  213. fill: FONT_FILL,
  214. innerHTML: label
  215. });
  216. let group = createSVG('g', {
  217. transform: `translate(${x}, ${y})`
  218. });
  219. group.appendChild(createSVG("circle", args));
  220. group.appendChild(text);
  221. return group;
  222. }
  223. export function makeText(className, x, y, content, options = {}) {
  224. let fontSize = options.fontSize || FONT_SIZE;
  225. let dy = options.dy !== undefined ? options.dy : (fontSize / 2);
  226. let fill = options.fill || FONT_FILL;
  227. let textAnchor = options.textAnchor || 'start';
  228. return createSVG('text', {
  229. className: className,
  230. x: x,
  231. y: y,
  232. dy: dy + 'px',
  233. 'font-size': fontSize + 'px',
  234. fill: fill,
  235. 'text-anchor': textAnchor,
  236. innerHTML: content
  237. });
  238. }
  239. function makeVertLine(x, label, y1, y2, options={}) {
  240. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  241. let l = createSVG('line', {
  242. className: 'line-vertical ' + options.className,
  243. x1: 0,
  244. x2: 0,
  245. y1: y1,
  246. y2: y2,
  247. styles: {
  248. stroke: options.stroke
  249. }
  250. });
  251. let text = createSVG('text', {
  252. x: 0,
  253. y: y1 > y2 ? y1 + LABEL_MARGIN : y1 - LABEL_MARGIN - FONT_SIZE,
  254. dy: FONT_SIZE + 'px',
  255. 'font-size': FONT_SIZE + 'px',
  256. 'text-anchor': 'middle',
  257. innerHTML: label + ""
  258. });
  259. let line = createSVG('g', {
  260. transform: `translate(${ x }, 0)`
  261. });
  262. line.appendChild(l);
  263. line.appendChild(text);
  264. return line;
  265. }
  266. function makeHoriLine(y, label, x1, x2, options={}) {
  267. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  268. if(!options.lineType) options.lineType = '';
  269. let className = 'line-horizontal ' + options.className +
  270. (options.lineType === "dashed" ? "dashed": "");
  271. let l = createSVG('line', {
  272. className: className,
  273. x1: x1,
  274. x2: x2,
  275. y1: 0,
  276. y2: 0,
  277. styles: {
  278. stroke: options.stroke
  279. }
  280. });
  281. let text = createSVG('text', {
  282. x: x1 < x2 ? x1 - LABEL_MARGIN : x1 + LABEL_MARGIN,
  283. y: 0,
  284. dy: (FONT_SIZE / 2 - 2) + 'px',
  285. 'font-size': FONT_SIZE + 'px',
  286. 'text-anchor': x1 < x2 ? 'end' : 'start',
  287. innerHTML: label+""
  288. });
  289. let line = createSVG('g', {
  290. transform: `translate(0, ${y})`,
  291. 'stroke-opacity': 1
  292. });
  293. if(text === 0 || text === '0') {
  294. line.style.stroke = "rgba(27, 31, 35, 0.6)";
  295. }
  296. line.appendChild(l);
  297. line.appendChild(text);
  298. return line;
  299. }
  300. export function yLine(y, label, width, options={}) {
  301. if(!options.pos) options.pos = 'left';
  302. if(!options.offset) options.offset = 0;
  303. if(!options.mode) options.mode = 'span';
  304. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  305. if(!options.className) options.className = '';
  306. let x1 = -1 * AXIS_TICK_LENGTH;
  307. let x2 = options.mode === 'span' ? width + AXIS_TICK_LENGTH : 0;
  308. if(options.mode === 'tick' && options.pos === 'right') {
  309. x1 = width + AXIS_TICK_LENGTH;
  310. x2 = width;
  311. }
  312. // let offset = options.pos === 'left' ? -1 * options.offset : options.offset;
  313. x1 += options.offset;
  314. x2 += options.offset;
  315. return makeHoriLine(y, label, x1, x2, {
  316. stroke: options.stroke,
  317. className: options.className,
  318. lineType: options.lineType
  319. });
  320. }
  321. export function xLine(x, label, height, options={}) {
  322. if(!options.pos) options.pos = 'bottom';
  323. if(!options.offset) options.offset = 0;
  324. if(!options.mode) options.mode = 'span';
  325. if(!options.stroke) options.stroke = BASE_LINE_COLOR;
  326. if(!options.className) options.className = '';
  327. // Draw X axis line in span/tick mode with optional label
  328. // y2(span)
  329. // |
  330. // |
  331. // x line |
  332. // |
  333. // |
  334. // ---------------------+-- y2(tick)
  335. // |
  336. // y1
  337. let y1 = height + AXIS_TICK_LENGTH;
  338. let y2 = options.mode === 'span' ? -1 * AXIS_TICK_LENGTH : height;
  339. if(options.mode === 'tick' && options.pos === 'top') {
  340. // top axis ticks
  341. y1 = -1 * AXIS_TICK_LENGTH;
  342. y2 = 0;
  343. }
  344. return makeVertLine(x, label, y1, y2, {
  345. stroke: options.stroke,
  346. className: options.className,
  347. lineType: options.lineType
  348. });
  349. }
  350. export function yMarker(y, label, width, options={}) {
  351. if(!options.labelPos) options.labelPos = 'right';
  352. let x = options.labelPos === 'left' ? LABEL_MARGIN
  353. : width - getStringWidth(label, 5) - LABEL_MARGIN;
  354. let labelSvg = createSVG('text', {
  355. className: 'chart-label',
  356. x: x,
  357. y: 0,
  358. dy: (FONT_SIZE / -2) + 'px',
  359. 'font-size': FONT_SIZE + 'px',
  360. 'text-anchor': 'start',
  361. innerHTML: label+""
  362. });
  363. let line = makeHoriLine(y, '', 0, width, {
  364. stroke: options.stroke || BASE_LINE_COLOR,
  365. className: options.className || '',
  366. lineType: options.lineType
  367. });
  368. line.appendChild(labelSvg);
  369. return line;
  370. }
  371. export function yRegion(y1, y2, width, label, options={}) {
  372. // return a group
  373. let height = y1 - y2;
  374. let rect = createSVG('rect', {
  375. className: `bar mini`, // remove class
  376. styles: {
  377. fill: `rgba(228, 234, 239, 0.49)`,
  378. stroke: BASE_LINE_COLOR,
  379. 'stroke-dasharray': `${width}, ${height}`
  380. },
  381. // 'data-point-index': index,
  382. x: 0,
  383. y: 0,
  384. width: width,
  385. height: height
  386. });
  387. if(!options.labelPos) options.labelPos = 'right';
  388. let x = options.labelPos === 'left' ? LABEL_MARGIN
  389. : width - getStringWidth(label+"", 4.5) - LABEL_MARGIN;
  390. let labelSvg = createSVG('text', {
  391. className: 'chart-label',
  392. x: x,
  393. y: 0,
  394. dy: (FONT_SIZE / -2) + 'px',
  395. 'font-size': FONT_SIZE + 'px',
  396. 'text-anchor': 'start',
  397. innerHTML: label+""
  398. });
  399. let region = createSVG('g', {
  400. transform: `translate(0, ${y2})`
  401. });
  402. region.appendChild(rect);
  403. region.appendChild(labelSvg);
  404. return region;
  405. }
  406. export function datasetBar(x, yTop, width, color, label='', index=0, offset=0, meta={}) {
  407. let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine);
  408. y -= offset;
  409. if(height === 0) {
  410. height = meta.minHeight;
  411. y -= meta.minHeight;
  412. }
  413. let rect = createSVG('rect', {
  414. className: `bar mini`,
  415. style: `fill: ${color}`,
  416. 'data-point-index': index,
  417. x: x,
  418. y: y,
  419. width: width,
  420. height: height
  421. });
  422. label += "";
  423. if(!label && !label.length) {
  424. return rect;
  425. } else {
  426. rect.setAttribute('y', 0);
  427. rect.setAttribute('x', 0);
  428. let text = createSVG('text', {
  429. className: 'data-point-value',
  430. x: width/2,
  431. y: 0,
  432. dy: (FONT_SIZE / 2 * -1) + 'px',
  433. 'font-size': FONT_SIZE + 'px',
  434. 'text-anchor': 'middle',
  435. innerHTML: label
  436. });
  437. let group = createSVG('g', {
  438. 'data-point-index': index,
  439. transform: `translate(${x}, ${y})`
  440. });
  441. group.appendChild(rect);
  442. group.appendChild(text);
  443. return group;
  444. }
  445. }
  446. export function datasetDot(x, y, radius, color, label='', index=0) {
  447. let dot = createSVG('circle', {
  448. style: `fill: ${color}`,
  449. 'data-point-index': index,
  450. cx: x,
  451. cy: y,
  452. r: radius
  453. });
  454. label += "";
  455. if(!label && !label.length) {
  456. return dot;
  457. } else {
  458. dot.setAttribute('cy', 0);
  459. dot.setAttribute('cx', 0);
  460. let text = createSVG('text', {
  461. className: 'data-point-value',
  462. x: 0,
  463. y: 0,
  464. dy: (FONT_SIZE / 2 * -1 - radius) + 'px',
  465. 'font-size': FONT_SIZE + 'px',
  466. 'text-anchor': 'middle',
  467. innerHTML: label
  468. });
  469. let group = createSVG('g', {
  470. 'data-point-index': index,
  471. transform: `translate(${x}, ${y})`
  472. });
  473. group.appendChild(dot);
  474. group.appendChild(text);
  475. return group;
  476. }
  477. }
  478. export function getPaths(xList, yList, color, options={}, meta={}) {
  479. let pointsList = yList.map((y, i) => (xList[i] + ',' + y));
  480. let pointsStr = pointsList.join("L");
  481. let path = makePath("M"+pointsStr, 'line-graph-path', color);
  482. // HeatLine
  483. if(options.heatline) {
  484. let gradient_id = makeGradient(meta.svgDefs, color);
  485. path.style.stroke = `url(#${gradient_id})`;
  486. }
  487. let paths = {
  488. path: path
  489. };
  490. // Region
  491. if(options.regionFill) {
  492. let gradient_id_region = makeGradient(meta.svgDefs, color, true);
  493. let pathStr = "M" + `${xList[0]},${meta.zeroLine}L` + pointsStr + `L${xList.slice(-1)[0]},${meta.zeroLine}`;
  494. paths.region = makePath(pathStr, `region-fill`, 'none', `url(#${gradient_id_region})`);
  495. }
  496. return paths;
  497. }
  498. export let makeOverlay = {
  499. 'bar': (unit) => {
  500. let transformValue;
  501. if(unit.nodeName !== 'rect') {
  502. transformValue = unit.getAttribute('transform');
  503. unit = unit.childNodes[0];
  504. }
  505. let overlay = unit.cloneNode();
  506. overlay.style.fill = '#000000';
  507. overlay.style.opacity = '0.4';
  508. if(transformValue) {
  509. overlay.setAttribute('transform', transformValue);
  510. }
  511. return overlay;
  512. },
  513. 'dot': (unit) => {
  514. let transformValue;
  515. if(unit.nodeName !== 'circle') {
  516. transformValue = unit.getAttribute('transform');
  517. unit = unit.childNodes[0];
  518. }
  519. let overlay = unit.cloneNode();
  520. let radius = unit.getAttribute('r');
  521. let fill = unit.getAttribute('fill');
  522. overlay.setAttribute('r', parseInt(radius) + DOT_OVERLAY_SIZE_INCR);
  523. overlay.setAttribute('fill', fill);
  524. overlay.style.opacity = '0.6';
  525. if(transformValue) {
  526. overlay.setAttribute('transform', transformValue);
  527. }
  528. return overlay;
  529. },
  530. 'heat_square': (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. };
  548. export let updateOverlay = {
  549. 'bar': (unit, overlay) => {
  550. let transformValue;
  551. if(unit.nodeName !== 'rect') {
  552. transformValue = unit.getAttribute('transform');
  553. unit = unit.childNodes[0];
  554. }
  555. let attributes = ['x', 'y', 'width', 'height'];
  556. Object.values(unit.attributes)
  557. .filter(attr => attributes.includes(attr.name) && attr.specified)
  558. .map(attr => {
  559. overlay.setAttribute(attr.name, attr.nodeValue);
  560. });
  561. if(transformValue) {
  562. overlay.setAttribute('transform', transformValue);
  563. }
  564. },
  565. 'dot': (unit, overlay) => {
  566. let transformValue;
  567. if(unit.nodeName !== 'circle') {
  568. transformValue = unit.getAttribute('transform');
  569. unit = unit.childNodes[0];
  570. }
  571. let attributes = ['cx', 'cy'];
  572. Object.values(unit.attributes)
  573. .filter(attr => attributes.includes(attr.name) && attr.specified)
  574. .map(attr => {
  575. overlay.setAttribute(attr.name, attr.nodeValue);
  576. });
  577. if(transformValue) {
  578. overlay.setAttribute('transform', transformValue);
  579. }
  580. },
  581. 'heat_square': (unit, overlay) => {
  582. let transformValue;
  583. if(unit.nodeName !== 'circle') {
  584. transformValue = unit.getAttribute('transform');
  585. unit = unit.childNodes[0];
  586. }
  587. let attributes = ['cx', 'cy'];
  588. Object.values(unit.attributes)
  589. .filter(attr => attributes.includes(attr.name) && attr.specified)
  590. .map(attr => {
  591. overlay.setAttribute(attr.name, attr.nodeValue);
  592. });
  593. if(transformValue) {
  594. overlay.setAttribute('transform', transformValue);
  595. }
  596. },
  597. };