update dependencies and devDependencies, fix lint issues
[websub-hub] / src / template / histogram-svg.js
1 'use strict';
2
3 const th = require('./template-helper');
4
5 const optionsDefaults = {
6 barWidth: 20,
7 barHeight: 100,
8 scaleBars: true,
9 barCaptionFn: () => '',
10 labelZero: undefined,
11 labelX: undefined,
12 labelHeight: 8,
13 fontFamily: 'DejaVu Sans,Verdana,Geneva,sans-serif',
14 frameColor: 'gray',
15 tickEvery: undefined,
16 tickHeight: 4,
17 tickColor: 'gray',
18 minItems: 0,
19 maxItems: undefined,
20 };
21
22 /**
23 *
24 * @param {number} percent grey value
25 * @returns {string} rgb value
26 */
27 function grey(percent) {
28 const value = Math.round(95 * (1.0 - percent));
29 return `rgb(${value}%, ${value}%, ${value}%)`;
30 }
31
32 /**
33 *
34 * @param {object} options options
35 * @param {number} width width
36 * @param {number} height height
37 * @returns {string} svg element
38 */
39 function svgHeader(options, width, height) {
40 return [
41 `<svg
42 version="1.1"
43 baseProfile="full"
44 xmlns="http://www.w3.org/2000/svg"
45 xmlns:xlink="http://www.w3.org/1999/xlink"
46 xmlns:ev="http://www.w3.org/2001/xml-events"
47 width="${width}px" height="${height}px"
48 viewBox="0 0 ${width} ${height}"
49 preserveAspectRatio="xMinYMin meet"`,
50 options.accessibleText ? ` role="img" aria-label="${th.xmlEscape(options.accessibleText)}"` : '',
51 '>',
52 options.title ? `<title>${th.xmlEscape(options.title)}</title>` : '',
53 options.description ? `<desc>${th.xmlEscape(options.description)}</desc>` : '',
54 '\n',
55 ];
56 }
57
58 /**
59 *
60 * @param {options} options options
61 * @param {number} width width
62 * @returns {string} element
63 */
64 function svgFrame(options, width) {
65 if (!options.frameColor) {
66 return '';
67 }
68 return `\t<g id="frame">
69 \t\t<rect x="0.5px" y="0.5px" width="${width - 1}px" height="${options.barHeight - 1}px" fill="none" stroke="${options.frameColor}" />
70 \t</g>\n`;
71 }
72
73 /**
74 *
75 * @param {object} options options
76 * @param {number} width width
77 * @returns {string} element
78 */
79 function svgTicks(options, width) {
80 if (!options.tickEvery) {
81 return '';
82 }
83 const tickSpacing = options.tickEvery * options.barWidth;
84 const tickOffset = options.barWidth / 2;
85 const numTicks = Math.ceil(width / tickSpacing);
86 const ticks = Array.from({ length: numTicks }, (_, i) => i)
87 .map((index) => `M ${(index * tickSpacing) + tickOffset} ${options.barHeight - 2} v ${options.tickHeight}`)
88 .join(' ');
89
90 return `\t<g id="ticks">
91 \t<path d="${ticks}" stroke="${options.tickColor}" fill="none" stroke-width="0.5px" />
92 \t</g>\n`;
93 }
94
95 /**
96 *
97 * @param {object} options options
98 * @param {number} width width
99 * @param {number} height height
100 * @returns {string} element
101 */
102 function svgLabels(options, width, height) {
103 const labels = [];
104 if (!options.labelHeight) {
105 return labels;
106 }
107 labels.push(`\t<g font-size="${options.labelHeight}px" font-family="${options.fontFamily}" font-variant="small-caps">\n`);
108 const y = height - (options.labelHeight / 2) + 2;
109 if (options.labelZero) {
110 labels.push(`\t\t<text font-size="${options.labelHeight}px" text-anchor="start" x="0" y="${y}">${options.labelZero}</text>\n`);
111 }
112 if (options.labelX) {
113 labels.push(`\t\t<text text-anchor="middle" x="${width / 2}" y="${y}">${options.labelX}</text>\n`);
114 }
115 labels.push('\t</g>\n');
116 return labels;
117 }
118
119 /**
120 * @returns {string} element
121 */
122 function svgFooter() {
123 return '</svg>';
124 }
125
126 /**
127 *
128 * @param {object} options options
129 * @param {number} value value
130 * @param {number} index index
131 * @param {number} maxValue max value
132 * @returns {string} element
133 */
134 function svgBar(options, value, index, maxValue) {
135 const id = `i${index}`;
136 const x = options.barWidth * index;
137 const width = options.barWidth;
138 const height = options.barHeight;
139 const scale = value / Math.max(1, maxValue);
140 const scaleHeight = options.scaleBars ? height * scale : height;
141 const yOffset = height - scaleHeight;
142 const fill = grey(scale);
143 const emptyFill = grey(0);
144 const title = th.xmlEscape(options.barCaptionFn(index, value));
145 return [
146 ...(options.scaleBars && [
147 `\t<rect id="${id}" x="${x}" y="0" width="${width}" height="${height}" fill="${emptyFill}">`,
148 ...(title && `<title>${title}</title>`),
149 '\t</rect>\n',
150 ]),
151 `\t<rect id="${id}" x="${x}" y="${yOffset}" width="${width}" height="${scaleHeight}" fill="${fill}">`,
152 ...(title && `<title>${title}</title>`),
153 '\t</rect>\n',
154 ].join('');
155 }
156
157 module.exports = (items, options = {}) => {
158 options = {
159 ...optionsDefaults,
160 ...options,
161 };
162
163 const maxValue = items.reduce((a, b) => Math.max(a, b), 0);
164 const hasLabel = !!options.labelX || !!options.labelZero;
165 const height = options.barHeight + 2 + (hasLabel ? options.labelHeight + 2 : 0);
166 const width = Math.max(items.length, options.minItems) * options.barWidth;
167
168 return [
169 ...svgHeader(options, width, height),
170 ...items.slice(0, options.maxItems).map((value, index) => svgBar(options, value, index, maxValue)),
171 svgFrame(options, width),
172 svgTicks(options, width),
173 ...svgLabels(options, width, height),
174 svgFooter(),
175 ].join('');
176 };