Heatmap

Simple script that creates a heatmap

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.greasyfork.org/scripts/410910/1251299/Heatmap.js

  1. // ==UserScript==
  2. // @name Heatmap
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0.10
  5. // @description Simple script that can generate heatmaps
  6. // @author Kumirei
  7. // @include /^https://(www|preview).wanikani.com/(dashboard)?$/
  8. // @grant none
  9. // ==/UserScript==
  10.  
  11. ;(function ($) {
  12. // The heatmap object
  13. class Heatmap {
  14. constructor(config, data) {
  15. this.maps = {}
  16. this.config = config
  17. this.data = {}
  18.  
  19. this.config.day_labels ||= ['M', 'T', 'W', 'T', 'F', 'S', 'S']
  20.  
  21. // If data is provided, initiate right away
  22. if (data !== undefined) this.initiate(data)
  23. }
  24.  
  25. // Creates heatmaps for the data
  26. initiate(data) {
  27. let dates = this._get_dates(data)
  28. let parsed_data = this._parse_data(data, dates)
  29. this.data = parsed_data
  30. if (this.config.type === 'year') {
  31. for (let year = dates.first_year; year <= dates.last_year; year++) {
  32. this.maps[year] = this._init_year(year, parsed_data, dates)
  33. }
  34. this._add_markings(this.config.markings, this.maps)
  35. }
  36. if (this.config.type === 'day') {
  37. this.maps.day = this._init_single_day(parsed_data, dates)
  38. }
  39. }
  40.  
  41. // Parses data into a date structure
  42. _parse_data(data, dates) {
  43. // Prepare vessel
  44. let parsed_data = {}
  45. for (let year = dates.first_year; year <= dates.last_year; year++) {
  46. parsed_data[year] = {}
  47. for (let month = 1; month <= 12; month++) {
  48. parsed_data[year][month] = {}
  49. for (let day = 0; day <= 31; day++) {
  50. parsed_data[year][month][day] = {
  51. counts: {},
  52. lists: {},
  53. hours: new Array(24).fill().map(() => {
  54. return { counts: {}, lists: {} }
  55. }),
  56. }
  57. }
  58. }
  59. }
  60. // Populate vessel
  61. for (let [date, counts, lists] of data) {
  62. let [year, month, day, hour] = this._get_ymdh(date - 1000 * 60 * 60 * this.config.day_start)
  63. if (
  64. date - 1000 * 60 * 60 * this.config.day_start < new Date(this.config.first_date).getTime() ||
  65. date - 1000 * 60 * 60 * this.config.day_start >
  66. new Date(this.config.last_date || date + 1).getTime()
  67. )
  68. continue
  69. let parsed_day = parsed_data[year][month][day]
  70. for (let [key, value] of Object.entries(counts)) {
  71. if (!parsed_day.counts[key]) parsed_day.counts[key] = value || 0
  72. else parsed_day.counts[key] += value || 0
  73. if (!parsed_day.hours[hour].counts[key]) parsed_day.hours[hour].counts[key] = value
  74. else parsed_day.hours[hour].counts[key] += value
  75. }
  76. for (let [key, value] of Object.entries(lists)) {
  77. if (!parsed_day.lists[key]) parsed_day.lists[key] = [value]
  78. else parsed_day.lists[key].push(value)
  79. if (!parsed_day.hours[hour].lists[key]) parsed_day.hours[hour].lists[key] = [value]
  80. else parsed_day.hours[hour].lists[key].push(value)
  81. }
  82. }
  83. return parsed_data
  84. }
  85.  
  86. // Create a year element for the heatmap
  87. _init_year(year, data, dates) {
  88. let cls =
  89. 'year heatmap ' +
  90. this.config.id +
  91. (this.config.segment_years ? ' segment_years' : '') +
  92. (this.config.zero_gap ? ' zero_gap' : '') +
  93. (year > new Date().getFullYear() ? ' future' : '') +
  94. (year == new Date().getFullYear() ? ' current' : '')
  95. let year_elem = this._create_elem({ type: 'div', class: cls })
  96. year_elem.setAttribute('data-year', year)
  97. this._add_transitions(year_elem)
  98. let labels = this._create_elem({ type: 'div', class: 'year-labels', children: this._get_year_labels(year) })
  99. let months = this._create_elem({ type: 'div', class: 'months' })
  100. for (let month = 1; month <= 12; month++) {
  101. months.append(this._init_month(year, month, data, dates))
  102. }
  103. year_elem.append(labels, months)
  104. return year_elem
  105. }
  106.  
  107. // Create labels for the years
  108. _get_year_labels(year) {
  109. let year_label = this._create_elem({
  110. type: 'div',
  111. class: 'year-label hover-wrapper-target',
  112. child: String(year),
  113. })
  114. let day_labels = this._create_elem({ type: 'div', class: 'day-labels' })
  115. for (let day = 0; day < 7; day++) {
  116. day_labels.append(
  117. this._create_elem({
  118. type: 'div',
  119. class: 'day-label',
  120. child: this.config.day_labels[(day + Number(this.config.week_start)) % 7],
  121. }),
  122. )
  123. }
  124. return [year_label, day_labels]
  125. }
  126.  
  127. // Create a month element for the year
  128. _init_month(year, month, data, dates) {
  129. let offset = (new Date(year, month - 1, 1, 0, 0).getDay() + 6 - this.config.week_start) % 7
  130. let month_elem = this._create_elem({ type: 'div', class: 'month offset-' + offset })
  131. if (year === dates.first_year && month < dates.first_month) month_elem.classList.add('no-data')
  132. let label = this._create_elem({
  133. type: 'div',
  134. class: 'month-label',
  135. child: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month - 1],
  136. })
  137. let days = this._create_elem({ type: 'div', class: 'days' })
  138. let days_in_month = this._get_days_in_month(year, month)
  139. for (let day = 1; day <= days_in_month; day++) {
  140. days.append(this._init_day(year, month, day, data, dates))
  141. }
  142. month_elem.append(label, days)
  143. return month_elem
  144. }
  145.  
  146. // Create a day element for the month
  147. _init_day(year, month, day, data, dates) {
  148. let day_data = data[year][month][day]
  149. let day_elem = this._create_elem({
  150. type: 'div',
  151. class: 'day hover-wrapper-target',
  152. info: { counts: day_data.counts, lists: day_data.lists },
  153. child: this._create_elem({
  154. type: 'div',
  155. class: 'hover-wrapper',
  156. child: this.config.day_hover_callback([year, month, day], day_data),
  157. }),
  158. })
  159. day_elem.setAttribute('data-date', `${year}-${month}-${day}`)
  160. if (year === dates.first_year && month === dates.first_month && day < dates.first_day)
  161. day_elem.classList.add('no-data')
  162. day_elem.style.setProperty('background-color', this.config.color_callback([year, month, day], day_data))
  163. return day_elem
  164. }
  165.  
  166. // Creates a simple heatmap with 24 squares that represent a single day
  167. _init_single_day(data, dates) {
  168. let day = this._create_elem({ type: 'div', class: 'single-day ' + this.config.id })
  169. this._add_transitions(day)
  170. let hour_data = data[dates.first_year][dates.first_month][dates.first_day].hours
  171. let current_hour = new Date().getHours()
  172. for (let i = 0; i < 24; i++) {
  173. let j = (i + this.config.day_start) % 24
  174. let hour = this._create_elem({
  175. type: 'div',
  176. class:
  177. 'hour hover-wrapper-target' +
  178. (j === current_hour && Date.parse(new Date().toDateString()) == this.config.first_date
  179. ? ' today marked'
  180. : ''),
  181. info: { counts: hour_data[i].counts, lists: hour_data[i].lists },
  182. })
  183. let hover = this._create_elem({
  184. type: 'div',
  185. class: 'hover-wrapper',
  186. child: this.config.day_hover_callback(
  187. [dates.first_year, dates.first_month, dates.first_day, j],
  188. hour_data[i],
  189. ),
  190. })
  191. hour.append(hover)
  192. hour.style.setProperty(
  193. 'background-color',
  194. this.config.color_callback([dates.first_year, dates.first_month, dates.first_day, j], hour_data[i]),
  195. )
  196. day.append(hour)
  197. }
  198. day.instance = this
  199. return day
  200. }
  201.  
  202. // Marks provided dates with a border
  203. _add_markings(markings, years) {
  204. for (let [date, mark] of markings) {
  205. let [year, month, day] = this._get_ymdh(date)
  206. if (years[year])
  207. years[year]
  208. .querySelector(`.day[data-date="${year}-${month}-${day}"]`)
  209. .classList.add(...mark.split(' '), 'marked')
  210. }
  211. }
  212.  
  213. // Number of days in each month
  214. _get_days_in_month(year, month) {
  215. return [31, this._is_leap_year(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
  216. }
  217.  
  218. // Checks for leap year
  219. _is_leap_year(year) {
  220. return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
  221. }
  222.  
  223. // Shorthand for creating new elements
  224. _create_elem(config) {
  225. let div = document.createElement(config.type)
  226. for (let [attr, value] of Object.entries(config)) {
  227. if (attr === 'type') continue
  228. else if (attr === 'class') div.className = value
  229. else if (attr === 'child') div.append(value)
  230. else if (attr === 'children') div.append(...value)
  231. else div[attr] = value
  232. }
  233. return div
  234. }
  235.  
  236. // Get first and last dates that should be visible in the heatmap
  237. _get_dates() {
  238. let [first_year, first_month, first_day] = this._get_ymdh(this.config.first_date)
  239. let [last_year, last_month, last_day] = this._get_ymdh(this.config.last_date || Date.now())
  240. return { first_year, first_month, first_day, last_year, last_month, last_day }
  241. }
  242.  
  243. // Convert date into year month date and hour
  244. _get_ymdh(date) {
  245. let d = new Date(date)
  246. return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours()]
  247. }
  248.  
  249. // Add hover transition
  250. _add_transitions(elem) {
  251. elem.addEventListener('mouseover', (event) => {
  252. const elem = event.target.closest('.hover-wrapper-target')
  253. if (!elem) return
  254. elem.classList.add('heatmap-transition')
  255. setTimeout((_) => elem.classList.remove('heatmap-transition'), 20)
  256. })
  257. }
  258. }
  259.  
  260. // Expose class
  261. window.Heatmap = Heatmap
  262. })()