rangeChart.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import * as React from "react";
  2. import { makeStyles } from "@material-ui/styles";
  3. import {
  4. BarChart,
  5. Bar,
  6. XAxis,
  7. YAxis,
  8. CartesianGrid,
  9. Tooltip,
  10. ResponsiveContainer,
  11. Cell,
  12. } from "recharts";
  13. import { primary, greyscale, browns } from "../../constants/colors";
  14. import { toCurrency } from "../../util";
  15. const RangeChart = ({ data, currency, height }) => {
  16. const useStyles = makeStyles({
  17. tooltip: {
  18. borderRadius: 2,
  19. background: "rgba(255, 255, 255, 0.95)",
  20. padding: 12,
  21. },
  22. tooltipLineItem: {
  23. fontSize: "1rem",
  24. margin: 0,
  25. marginBottom: 4,
  26. padding: 0,
  27. },
  28. });
  29. const accents = [...primary, ...greyscale, ...browns];
  30. const _IDLE_ = "__idle__";
  31. const _OTHER_ = "others";
  32. const getItemCost = (item) => {
  33. return item.value;
  34. };
  35. function toBar({ end, graph, start }) {
  36. const points = graph.map((item) => ({
  37. ...item,
  38. window: { end, start },
  39. }));
  40. const dateFormatter = Intl.DateTimeFormat(navigator.language, {
  41. year: "numeric",
  42. month: "numeric",
  43. day: "numeric",
  44. timeZone: "UTC",
  45. });
  46. const timeFormatter = Intl.DateTimeFormat(navigator.language, {
  47. hour: "numeric",
  48. minute: "numeric",
  49. timeZone: "UTC",
  50. });
  51. const s = new Date(start);
  52. const e = new Date(end);
  53. const interval = (e.valueOf() - s.valueOf()) / 1000 / 60 / 60;
  54. const bar = {
  55. end: new Date(end),
  56. key: interval >= 24 ? dateFormatter.format(s) : timeFormatter.format(s),
  57. items: {},
  58. start: new Date(start),
  59. };
  60. points.forEach((item) => {
  61. const windowStart = new Date(item.window.start);
  62. const windowEnd = new Date(item.window.end);
  63. const windowHours =
  64. (windowEnd.valueOf() - windowStart.valueOf()) / 1000 / 60 / 60;
  65. if (windowHours >= 24) {
  66. bar.key = dateFormatter.format(bar.start);
  67. } else {
  68. bar.key = timeFormatter.format(bar.start);
  69. }
  70. bar.items[item.name] = getItemCost(item);
  71. });
  72. return bar;
  73. }
  74. const getDataForCloudDay = (dayData) => {
  75. const { end, start } = dayData;
  76. const copy = [...dayData.items];
  77. // find items for idle and other
  78. const idleIndex = copy.findIndex((item) => item.name === _IDLE_);
  79. let idle = undefined;
  80. if (idleIndex > -1) {
  81. idle = copy[idleIndex];
  82. copy.splice(idleIndex, 1);
  83. }
  84. const otherIndex = copy.findIndex(
  85. (i) => i.name === _OTHER_ || i.name === "other"
  86. );
  87. let other = undefined;
  88. if (otherIndex > -1) {
  89. other = { ...copy[otherIndex], name: "other" };
  90. copy.splice(otherIndex, 1);
  91. }
  92. // sort and remove any items < top 8
  93. const sortedItems = copy.slice().sort((a, b) => {
  94. return a.value > b.value ? -1 : 1;
  95. });
  96. const top8 = sortedItems.slice(0, 8);
  97. // get items that didn't make the cut and shove into other
  98. const lefovers = sortedItems.slice(8);
  99. if (lefovers.length > 0) {
  100. const othersTotal = lefovers.reduce((a, b) => a.value + b.value);
  101. if (other) {
  102. other.value += othersTotal;
  103. } else if (othersTotal) {
  104. other = {
  105. name: "other",
  106. value: othersTotal,
  107. };
  108. }
  109. }
  110. // add in idle and other
  111. if (idle) {
  112. top8.unshift(idle);
  113. }
  114. if (other) {
  115. top8.unshift(other);
  116. }
  117. return { end, start, graph: top8 };
  118. };
  119. const getDataForGraph = (dataPoints) => {
  120. // for each day, we want top 8 + Idle and Other
  121. const orderedDataPoints = dataPoints.map(getDataForCloudDay);
  122. const bars = orderedDataPoints.map(toBar);
  123. const keyToFill = {};
  124. // we want to keep track of the order of fill assignment
  125. const assignmentOrder = [];
  126. let p = 0;
  127. orderedDataPoints.forEach(({ graph, start, end }) => {
  128. graph.forEach(({ name }) => {
  129. const key = name;
  130. if (keyToFill[key] === undefined) {
  131. assignmentOrder.push(key);
  132. if (key === _IDLE_) {
  133. keyToFill[key] = browns;
  134. } else if (key === _OTHER_ || key === "other") {
  135. keyToFill[key] = greyscale;
  136. } else {
  137. // non-idle/other allocations get the next available color
  138. keyToFill[key] = accents[p];
  139. p = (p + 1) % accents.length;
  140. }
  141. }
  142. });
  143. });
  144. // list of dataKeys and fillColors in order of importance (price w/ 'others' last)
  145. const labels = assignmentOrder.map((dataKey) => ({
  146. dataKey,
  147. fill: keyToFill[dataKey],
  148. }));
  149. return { bars, labels, keyToFill };
  150. };
  151. const { bars: barData, labels: barLabels, keyToFill } = getDataForGraph(data);
  152. const classes = useStyles();
  153. const CustomTooltip = (params) => {
  154. const { active, payload } = params;
  155. if (!payload || payload.length == 0) {
  156. return null;
  157. }
  158. const total = payload.reduce((sum, item) => sum + item.value, 0.0);
  159. if (active) {
  160. return (
  161. <div className={classes.tooltip}>
  162. <p
  163. className={classes.tooltipLineItem}
  164. style={{ color: "#000000" }}
  165. >{`Total: ${toCurrency(total, currency)}`}</p>
  166. {payload
  167. .slice()
  168. .map((item, i) => (
  169. <div
  170. key={item.name}
  171. style={{
  172. display: "grid",
  173. gridTemplateColumns: "20px 1fr",
  174. gap: ".5em",
  175. margin: ".25em",
  176. }}
  177. >
  178. <div>
  179. <div
  180. style={{
  181. backgroundColor: keyToFill[item.payload.items[i][0]],
  182. width: 18,
  183. height: 18,
  184. }}
  185. />
  186. </div>
  187. <div>
  188. <p className={classes.tooltipLineItem}>{`${
  189. item.payload.items[i][0]
  190. }: ${toCurrency(item.value, currency)}`}</p>
  191. </div>
  192. </div>
  193. ))
  194. .reverse()}
  195. </div>
  196. );
  197. }
  198. return null;
  199. };
  200. const orderedBars = barData.map((bar) => {
  201. return {
  202. ...bar,
  203. items: Object.entries(bar.items).sort((a, b) => {
  204. if (a[0] === "other") {
  205. return -1;
  206. }
  207. if (b[0] === "other") {
  208. return 1;
  209. }
  210. return a[1] > b[1] ? -1 : 1;
  211. }),
  212. };
  213. });
  214. return (
  215. <ResponsiveContainer height={height} width={"100%"}>
  216. <BarChart
  217. data={orderedBars}
  218. margin={{ top: 30, right: 35, left: 30, bottom: 45 }}
  219. >
  220. <CartesianGrid strokeDasharray={"3 3"} vertical={false} />
  221. <XAxis dataKey={"key"} />
  222. <YAxis tickFormatter={(val) => toCurrency(val, currency, 2, true)} />
  223. <Tooltip content={<CustomTooltip />} wrapperStyle={{ zIndex: 1000 }} />
  224. {new Array(10).fill(0).map((item, idx) => (
  225. <Bar
  226. dataKey={(entry) => (entry.items[idx] ? entry.items[idx][1] : null)}
  227. stackId="x"
  228. >
  229. {orderedBars.map((bar) =>
  230. bar.items[idx] ? (
  231. <Cell fill={keyToFill[bar.items[idx][0]]} />
  232. ) : (
  233. <Cell />
  234. )
  235. )}
  236. </Bar>
  237. ))}
  238. </BarChart>
  239. </ResponsiveContainer>
  240. );
  241. };
  242. export default RangeChart;