display history of topic updates on topic details page
[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 function grey(percent) {
23 const value = Math.round(95 * (1.0 - percent));
24 return `rgb(${value}%, ${value}%, ${value}%)`;
25 }
26
27 function svgHeader(options, width, height) {
28 return [
29 `<svg
30 version="1.1"
31 baseProfile="full"
32 xmlns="http://www.w3.org/2000/svg"
33 xmlns:xlink="http://www.w3.org/1999/xlink"
34 xmlns:ev="http://www.w3.org/2001/xml-events"
35 width="${width}px" height="${height}px"
36 viewBox="0 0 ${width} ${height}"
37 preserveAspectRatio="xMinYMin meet"`,
38 options.accessibleText ? ` role="img" aria-label="${th.xmlEscape(options.accessibleText)}"` : '',
39 '>',
40 options.title ? `<title>${th.xmlEscape(options.title)}</title>` : '',
41 options.description ? `<desc>${th.xmlEscape(options.description)}</desc>` : '',
42 '\n',
43 ];
44 }
45
46 function svgFrame(options, width) {
47 if (!options.frameColor) {
48 return '';
49 }
50 return `\t<g id="frame">
51 \t\t<rect x="0.5px" y="0.5px" width="${width - 1}px" height="${options.barHeight - 1}px" fill="none" stroke="${options.frameColor}" />
52 \t</g>\n`;
53 }
54
55 function svgTicks(options, width) {
56 if (!options.tickEvery) {
57 return '';
58 }
59 const tickSpacing = options.tickEvery * options.barWidth;
60 const tickOffset = options.barWidth / 2;
61 const numTicks = Math.ceil(width / tickSpacing);
62 const ticks = Array.from({ length: numTicks }, (_, i) => i)
63 .map((index) => `M ${(index * tickSpacing) + tickOffset} ${options.barHeight - 2} v ${options.tickHeight}`)
64 .join(' ');
65
66 return `\t<g id="ticks">
67 \t<path d="${ticks}" stroke="${options.tickColor}" fill="none" stroke-width="0.5px" />
68 \t</g>\n`;
69 }
70
71 function svgLabels(options, width, height) {
72 const labels = [];
73 if (!options.labelHeight) {
74 return labels;
75 }
76 labels.push(`\t<g font-size="${options.labelHeight}px" font-family="${options.fontFamily}" font-variant="small-caps">\n`);
77 const y = height - (options.labelHeight / 2) + 2;
78 if (options.labelZero) {
79 labels.push(`\t\t<text font-size="${options.labelHeight}px" text-anchor="start" x="0" y="${y}">${options.labelZero}</text>\n`);
80 }
81 if (options.labelX) {
82 labels.push(`\t\t<text text-anchor="middle" x="${width / 2}" y="${y}">${options.labelX}</text>\n`);
83 }
84 labels.push('\t</g>\n');
85 return labels;
86 }
87
88 function svgFooter() {
89 return '</svg>';
90 }
91
92 function svgBar(options, value, index, maxValue) {
93 const id = `i${index}`;
94 const x = options.barWidth * index;
95 const width = options.barWidth;
96 const height = options.barHeight;
97 const scale = value / Math.max(1, maxValue);
98 const scaleHeight = options.scaleBars ? height * scale : height;
99 const yOffset = height - scaleHeight;
100 const fill = grey(scale);
101 const emptyFill = grey(0);
102 const title = th.xmlEscape(options.barCaptionFn(index, value));
103 return [
104 ...(options.scaleBars && [
105 `\t<rect id="${id}" x="${x}" y="0" width="${width}" height="${height}" fill="${emptyFill}">`,
106 ...(title && `<title>${title}</title>`),
107 '\t</rect>\n',
108 ]),
109 `\t<rect id="${id}" x="${x}" y="${yOffset}" width="${width}" height="${scaleHeight}" fill="${fill}">`,
110 ...(title && `<title>${title}</title>`),
111 '\t</rect>\n',
112 ].join('');
113 }
114
115 module.exports = (items, options = {}) => {
116 options = Object.assign({}, optionsDefaults, options);
117
118 const maxValue = items.reduce((a, b) => Math.max(a, b), 0);
119 const hasLabel = !!options.labelX || !!options.labelZero;
120 const height = options.barHeight + 2 + (hasLabel ? options.labelHeight + 2 : 0);
121 const width = Math.max(items.length, options.minItems) * options.barWidth;
122
123 return [
124 ...svgHeader(options, width, height),
125 ...items.slice(0, options.maxItems).map((value, index) => svgBar(options, value, index, maxValue)),
126 svgFrame(options, width),
127 svgTicks(options, width),
128 ...svgLabels(options, width, height),
129 svgFooter(),
130 ].join('');
131 };