Greasy Fork is available in English.
Adds review and lesson heatmaps to the dashboard.
- // ==UserScript==
- // @name Wanikani Heatmap
- // @namespace http://tampermonkey.net/
- // @version 3.1.11
- // @description Adds review and lesson heatmaps to the dashboard.
- // @author Kumirei
- // @include /^https://(www|preview).wanikani.com/(dashboard)?$/
- // @match https://www.wanikani.com/*
- // @match https://preview.wanikani.com/*
- // @require https://greasyfork.org/scripts/489759-wk-custom-icons/code/CustomIcons.js?version=1417568
- // @require https://greasyfork.org/scripts/410909-wanikani-review-cache/code/Wanikani:%20Review%20Cache.js?version=1193344
- // @require https://greasyfork.org/scripts/410910-heatmap/code/Heatmap.js?version=1251299
- // @grant none
- // ==/UserScript==
- ;(function (wkof, review_cache, Heatmap, Icons) {
- const CSS_COMMIT = '61a780cee6f08eb3a4f8f37068c1e6ce29762e96'
- let script_id = 'heatmap3'
- let script_name = 'Wanikani Heatmap'
- let msh = 60 * 60 * 1000,
- msd = 24 * msh // Milliseconds in hour and day
- Icons.addCustomIcons([
- [
- 'trophy',
- 'M400 0H176c-26.5 0-48.1 21.8-47.1 48.2c.2 5.3 .4 10.6 .7 15.8H24C10.7 64 0 74.7 0 88c0 92.6 33.5 157 78.5 200.7c44.3 43.1 98.3 64.8 138.1 75.8c23.4 6.5 39.4 26 39.4 45.6c0 20.9-17 37.9-37.9 37.9H192c-17.7 0-32 14.3-32 32s14.3 32 32 32H384c17.7 0 32-14.3 32-32s-14.3-32-32-32H357.9C337 448 320 431 320 410.1c0-19.6 15.9-39.2 39.4-45.6c39.9-11 93.9-32.7 138.2-75.8C542.5 245 576 180.6 576 88c0-13.3-10.7-24-24-24H446.4c.3-5.2 .5-10.4 .7-15.8C448.1 21.8 426.5 0 400 0zM48.9 112h84.4c9.1 90.1 29.2 150.3 51.9 190.6c-24.9-11-50.8-26.5-73.2-48.3c-32-31.1-58-76-63-142.3zM464.1 254.3c-22.4 21.8-48.3 37.3-73.2 48.3c22.7-40.3 42.8-100.5 51.9-190.6h84.4c-5.1 66.3-31.1 111.2-63 142.3z',
- 576,
- ],
- [
- 'inbox',
- 'M121 32C91.6 32 66 52 58.9 80.5L1.9 308.4C.6 313.5 0 318.7 0 323.9V416c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V323.9c0-5.2-.6-10.4-1.9-15.5l-57-227.9C446 52 420.4 32 391 32H121zm0 64H391l48 192H387.8c-12.1 0-23.2 6.8-28.6 17.7l-14.3 28.6c-5.4 10.8-16.5 17.7-28.6 17.7H195.8c-12.1 0-23.2-6.8-28.6-17.7l-14.3-28.6c-5.4-10.8-16.5-17.7-28.6-17.7H73L121 96z',
- ],
- ])
- /*-------------------------------------------------------------------------------------------------------------------------------*/
- var reload // Function to reload the heatmap
- // Temporary measure to track reviews while the /reviews endpoint is unavailable
- function main() {
- if (/www.wanikani.com\/(dashboard)?#?$/.test(window.location.href)) {
- // Wait until modules are ready then initiate script
- confirm_wkof()
- wkof.include('Menu,Settings,ItemData,Apiv2')
- wkof.ready('Menu,Settings,ItemData,Apiv2')
- .then(load_settings)
- .then(load_css)
- .then(install_menu)
- .then(initiate)
- window.addEventListener('turbo:load', async (e) => {
- setTimeout(main, 0)
- })
- }
- }
- main()
- // Fetch necessary data then install the heatmap
- async function initiate() {
- review_cache.subscribe(do_stuff)
- async function do_stuff(reviews) {
- reviews ??= []
- // Fetch data
- let items = await wkof.ItemData.get_items('assignments,include_hidden')
- let [forecast, lessons] = get_forecast_and_lessons(items)
- if (wkof.settings[script_id].lessons.recover_lessons) {
- let recovered_lessons = await get_recovered_lessons(items, reviews, lessons)
- lessons = lessons.concat(recovered_lessons).sort((a, b) => (a[0] < b[0] ? -1 : 1))
- }
- // Create heatmap
- reload = function (new_reviews = false) {
- // If start date is invalid, set it to the default
- if (isNaN(Date.parse(wkof.settings[script_id].general.start_date)))
- wkof.settings[script_id].general.start_date = '2012-01-01'
- // Get a timestamp for the start date
- wkof.settings[script_id].general.start_day =
- new Date(wkof.settings[script_id].general.start_date) -
- -new Date(wkof.settings[script_id].general.start_date).getTimezoneOffset() * 60 * 1000
- setTimeout(() => {
- // Make settings dialog respond immediately
- let stats = {
- reviews: calculate_stats('reviews', reviews),
- lessons: calculate_stats('lessons', lessons),
- }
- auto_range(stats, forecast)
- install_heatmap(reviews, forecast, lessons, stats, items)
- }, 0)
- }
- reload()
- }
- }
- /*-------------------------------------------------------------------------------------------------------------------------------*/
- function confirm_wkof() {
- if (!wkof) {
- let response = confirm(
- script_name +
- ' requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.',
- )
- if (response)
- window.location.href =
- 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549'
- return
- }
- }
- // Load settings from WKOF
- function load_settings() {
- let defaults = {
- general: {
- start_date: '2012-01-01',
- week_start: 0,
- day_start: 0,
- reverse_years: false,
- segment_years: true,
- zero_gap: false,
- month_labels: 'all',
- day_labels: true,
- session_limit: 10,
- now_indicator: true,
- color_now_indicator: '#ff0000',
- level_indicator: true,
- color_level_indicator: '#ffffff',
- position: 2,
- theme: 'dark',
- },
- reviews: {
- gradient: false,
- auto_range: true,
- },
- lessons: {
- gradient: false,
- auto_range: true,
- count_zeros: false,
- recover_lessons: false,
- },
- forecast: {
- gradient: false,
- auto_range: true,
- },
- other: {
- visible_years: { reviews: {}, lessons: {} },
- visible_map: 'reviews',
- times_popped: 0,
- times_dragged: 0,
- ported: false,
- },
- }
- return wkof.Settings.load(script_id, defaults).then((settings) => {
- // Workaround for defaults modifying existing settings
- if (!settings.reviews.colors?.length)
- settings.reviews.colors = [
- [0, '#747474'],
- [1, '#ade4ff'],
- [100, '#82c5e6'],
- [200, '#57a5cc'],
- [300, '#2b86b3'],
- [400, '#006699'],
- ]
- if (!settings.lessons.colors?.length)
- settings.lessons.colors = [
- [0, '#747474'],
- [1, '#ff8aa1'],
- [100, '#e46e9e'],
- [200, '#c8539a'],
- [300, '#ad3797'],
- [400, '#911b93'],
- ]
- if (!settings.forecast.colors?.length)
- settings.forecast.colors = [
- [0, '#747474'],
- [1, '#aaaaaa'],
- [100, '#bfbfbf'],
- [200, '#d5d5d5'],
- [300, '#eaeaea'],
- [400, '#ffffff'],
- ]
- // Load settings from old script if possible
- if (!settings.other.ported) port_settings(settings)
- migrate_settings(settings)
- // Make sure current year is visible
- for (let type of ['reviews', 'lessons']) {
- wkof.settings[script_id].other.visible_years[type][new Date().getFullYear()] = true
- }
- wkof.Settings.save(script_id)
- return settings
- })
- }
- // Loads heatmap and jQuery datepicker CSS
- function load_css() {
- // Heatmap CSS
- const heatmapRepo = `//raw.githubusercontent.com/Kumirei/Userscripts/${CSS_COMMIT}/Wanikani/Heatmap`
- wkof.load_css(`${heatmapRepo}/Heatmap/Heatmap.css`, true)
- wkof.load_css(`${heatmapRepo}/heatmap3.css`, true)
- }
- // Installs the settings button in the menu
- function install_menu() {
- let config = {
- name: script_id,
- submenu: 'Settings',
- title: 'Heatmap',
- on_click: open_settings,
- }
- wkof.Menu.insert_script_link(config)
- }
- // Add stuff to the settings dialog before opening
- let applied // Keeps track of whether the settings have been applied
- function modify_settings(dialog) {
- // Make start-date a jQuery datepicker
- //window.jQuery(dialog[0].querySelector('#'+script_id+'_start_date')).datepicker({dateFormat: "yy-mm-dd",changeYear: true,yearRange: "2012:+0"});
- // Add apply button
- applied = false
- let apply = create_elem({
- type: 'button',
- class: 'ui-button ui-corner-all ui-widget',
- child: 'Apply',
- onclick: (e) => {
- applied = true
- reload()
- },
- })
- dialog[0].nextElementSibling
- .getElementsByClassName('ui-dialog-buttonset')[0]
- .insertAdjacentElement('afterbegin', apply)
- // Updates the color labels with new hex values
- let update_label = function (input) {
- if (!input.nextElementSibling)
- input.insertAdjacentElement(
- 'afterend',
- create_elem({ type: 'div', class: 'color-label', child: input.value }),
- )
- else input.nextElementSibling.innerText = input.value
- if (!Math.round(hex_to_rgb(input.value).reduce((a, b) => a + b / 3, 0) / 255 - 0.15))
- input.nextElementSibling.classList.remove('light-color')
- else input.nextElementSibling.classList.add('light-color')
- }
- // Add color settings
- dialog[0]
- .querySelectorAll('#' + script_id + '_general ~ div .wkof_group > div:nth-of-type(2)')
- .forEach((elem, i) => {
- let type = ['reviews', 'lessons', 'forecast'][i]
- // Update the settings object with data from the settings dialog
- let update_color_settings = (_) => {
- wkof.settings[script_id][type].colors = []
- Array.from(elem.nextElementSibling.children[1].children).forEach((child, i) => {
- wkof.settings[script_id][type].colors.push([
- child.children[0].children[0].value,
- child.children[1].children[0].value,
- ])
- })
- }
- // Creates a new interval setting
- let create_row = (value, color) => {
- return create_elem({
- type: 'div',
- class: 'row',
- children: [
- create_elem({
- type: 'div',
- class: 'text',
- child: create_elem({ type: 'input', input: 'number', value: value }),
- }),
- create_elem({
- type: 'div',
- class: 'color',
- child: create_elem({
- type: 'input',
- input: 'color',
- value: color,
- callback: (e) => e.addEventListener('change', (_) => update_label(e)),
- }),
- callback: (e) => update_label(e.children[0]),
- }),
- create_elem({
- type: 'div',
- class: 'delete',
- child: create_elem({
- type: 'button',
- onclick: (e) => {
- e.target.closest('.row').remove()
- update_color_settings()
- },
- child: Icons.customIcon('trash'),
- }),
- }),
- ],
- })
- }
- // Creates the interface for color settings
- let panel = create_elem({
- type: 'div',
- class: 'right',
- children: [
- create_elem({
- type: 'button',
- class: 'adder',
- onclick: (e) => {
- e.target.nextElementSibling.append(create_row(0, '#ffffff'))
- update_color_settings()
- },
- child: 'Add interval',
- }),
- create_elem({ type: 'div', class: 'row panel' }),
- ],
- })
- // Update the settings when they change
- panel.addEventListener('change', update_color_settings)
- // Add the existing settings
- for (let [value, color] of wkof.settings[script_id][type].colors)
- panel.children[1].append(create_row(value, color))
- // Make sure that reviews and forecast have the same zero-color
- if (i == 0 || i == 2)
- panel.children[1].children[0].addEventListener('change', (e) => {
- let input = e.target
- .closest('#' + script_id + '_tabs')
- .querySelector(
- '#' +
- script_id +
- '_' +
- (i == 0 ? 'forecast' : 'reviews') +
- ' .panel > .row:first-child .color input',
- )
- if (input.value != e.target.value) {
- input.value = e.target.value
- input.dispatchEvent(new Event('change'))
- wkof.settings[script_id][i == 0 ? 'forecast' : 'reviews'].colors[0][1] = e.target.value
- }
- })
- // Install
- elem.insertAdjacentElement('afterend', panel)
- })
- // Disable the first interval's bound input so that it can't be changed from 0
- dialog[0]
- .querySelectorAll('#' + script_id + '_general ~ div .panel .row:first-child .text input')
- .forEach((elem) => (elem.disabled = true))
- // Add labels to all color inputs
- dialog[0].querySelectorAll('#' + script_id + '_general input[type="color"]').forEach((input) => {
- input.addEventListener('change', () => update_label(input))
- update_label(input)
- })
- // Add functionality to review inserter
- dialog[0].querySelector('#insert_reviews_button').addEventListener('click', (event) => {
- const date = dialog[0].querySelector('#insert_reviews_date').value
- const count = Number(dialog[0].querySelector('#insert_reviews_count').value)
- const spr = Number(dialog[0].querySelector('#insert_reviews_time').value) || 0 // Seconds per review
- if (!date || !count) return
- const mspr = spr * 1000 // MS per review
- const dayStart = wkof.settings[script_id].general.day_start
- const startHour = Math.floor(dayStart)
- const startMin = Math.floor((dayStart % 1) * 60)
- const time = Date.parse(date + `T${String(startHour).padStart(2, 0)}:${String(startMin).padStart(2, 0)}`)
- const reviews = new Array(count).fill(null).map((_, i) => [time + i * mspr, 1, 1, 0, 0])
- review_cache.insert(reviews)
- })
- }
- // Open the settings dialog
- function open_settings() {
- let config = {
- script_id: script_id,
- title: 'Heatmap',
- on_save: (_) => (applied = true),
- on_close: reload_on_change,
- content: {
- tabs: {
- type: 'tabset',
- content: {
- general: {
- type: 'page',
- label: 'General',
- hover_tip: 'Settings pertaining to the general functions of the script',
- content: {
- control: {
- type: 'group',
- label: 'Control',
- content: {
- position: {
- type: 'dropdown',
- label: 'Position',
- default: 2,
- hover_tip: 'Where on the dashboard to install the heatmap',
- content: {
- 0: 'Top',
- 1: 'Below forecast',
- 2: 'Below SRS',
- 3: 'Below panels',
- 4: 'Bottom',
- },
- path: '@general.position',
- },
- start_date: {
- type: 'input',
- subtype: 'date',
- label: 'Start date',
- default: '2012-01-01',
- hover_tip: 'All data before this date will be ignored',
- path: '@general.start_date',
- },
- week_start: {
- type: 'dropdown',
- label: 'First day of the week',
- default: 0,
- hover_tip: 'Determines which day of the week is at the top of the heatmaps',
- content: {
- 0: 'Monday',
- 1: 'Tuesday',
- 2: 'Wednesday',
- 3: 'Thursday',
- 4: 'Friday',
- 5: 'Saturday',
- 6: 'Sunday',
- },
- path: '@general.week_start',
- },
- day_start: {
- type: 'number',
- label: 'New day starts at',
- default: 0,
- placeholder: '(hours after midnight)',
- hover_tip:
- 'Offset for those who tend to stay up after midnight. If you want the new day to start at 4 AM, input 4.',
- path: '@general.day_start',
- },
- session_limit: {
- type: 'number',
- label: 'Session time limit (minutes)',
- default: 10,
- placeholder: '(minutes)',
- hover_tip:
- 'Max number of minutes between review/lesson items to still count within the same session',
- path: '@general.session_limit',
- },
- theme: {
- type: 'dropdown',
- label: 'Theme',
- default: 'dark',
- hover_tip: 'Changes the background color and other things',
- content: { light: 'Light', dark: 'Dark', 'breeze-dark': 'Breeze Dark' },
- path: '@general.theme',
- },
- },
- },
- layout: {
- type: 'group',
- label: 'Layout',
- content: {
- reverse_years: {
- type: 'checkbox',
- label: 'Reverse year order',
- default: false,
- hover_tip: 'Puts the most recent years on the bottom instead of the top',
- path: '@general.reverse_years',
- },
- segment_years: {
- type: 'checkbox',
- label: 'Segment year',
- default: true,
- hover_tip: 'Put a gap between months',
- path: '@general.segment_years',
- },
- zero_gap: {
- type: 'checkbox',
- label: 'No gap',
- default: false,
- hover_tip: `Don't display any gap between days`,
- path: '@general.zero_gap',
- },
- day_labels: {
- type: 'dropdown',
- label: 'Day of week labels',
- default: 'english',
- hover_tip:
- 'Adds letters to the left of the heatmaps indicating which row represents which weekday',
- content: { none: 'None', english: 'English', kanji: 'Kanji' },
- path: '@general.day_labels',
- },
- month_labels: {
- type: 'dropdown',
- label: 'Month labels',
- default: 'all',
- hover_tip: 'Display month labels above each month',
- content: { all: 'All', top: 'Only at the top', none: 'None' },
- path: '@general.month_labels',
- },
- },
- },
- indicators: {
- type: 'group',
- label: 'Indicators',
- content: {
- now_indicator: {
- type: 'checkbox',
- label: 'Current day indicator',
- default: true,
- hover_tip: 'Puts a border around the current day',
- path: '@general.now_indicator',
- },
- level_indicator: {
- type: 'checkbox',
- label: 'Level-up indicators',
- default: true,
- hover_tip: 'Puts borders around the days on which you leveled up',
- path: '@general.level_indicator',
- },
- color_now_indicator: {
- type: 'color',
- label: 'Color for current day',
- hover_tip: 'The border around the current day will have this color',
- default: '#ff0000',
- path: '@general.color_now_indicator',
- },
- color_level_indicator: {
- type: 'color',
- label: 'Color for level-ups',
- hover_tip: 'The borders around level-ups will have this color',
- default: '#ffffff',
- path: '@general.color_level_indicator',
- },
- },
- },
- },
- },
- reviews: {
- type: 'page',
- label: 'Reviews',
- hover_tip: 'Settings pertaining to the review heatmaps',
- content: {
- reviews_settings: {
- type: 'group',
- label: 'Review Settings',
- content: {
- reviews_section: { type: 'section', label: 'Intervals' },
- reviews_auto_range: {
- type: 'checkbox',
- label: 'Auto range intervals',
- default: true,
- hover_tip: 'Automatically decide what the intervals should be',
- path: '@reviews.auto_range',
- },
- reviews_gradient: {
- type: 'checkbox',
- label: 'Use gradients',
- default: false,
- hover_tip:
- 'Interpolate colors based on the exact number of items on that day',
- path: '@reviews.gradient',
- },
- reviews_generate: {
- type: 'button',
- label: 'Generate colors',
- text: 'Generate',
- hover_tip: 'Generate new colors from the first and last non-zero interval',
- on_click: generate_colors,
- },
- add_reviews_section: { type: 'section', label: 'Manually Register Reviews' },
- reviews_insert: {
- type: 'html',
- html: `
- <div>
- <div><label>Date <input id="insert_reviews_date" type="date"/></label></div>
- <div><label>Count <input id="insert_reviews_count" type="number" min="0" placeholder="Number of reviews" /></label></div>
- <div><label>Seconds Per Review <input id="insert_reviews_time" type="number" min="0" placeholder="seconds" value=10 /></label></div>
- <div style="display: flex; justify-content: flex-end;"><button id="insert_reviews_button">Register</button></div>
- </div>
- `,
- },
- // reviews_section2: { type: 'section', label: 'Other' },
- // reload_button: {
- // type: 'button',
- // label: 'Reload review data',
- // text: 'Reload',
- // hover_tip: 'Deletes review cache and starts a new fetch',
- // on_click: () => review_cache.reload().then((reviews) => reload(reviews)),
- // },
- },
- },
- },
- },
- lessons: {
- type: 'page',
- label: 'Lessons',
- hover_tip: 'Settings pertaining to the lesson heatmaps',
- content: {
- lessons_settings: {
- type: 'group',
- label: 'Lesson Settings',
- content: {
- lessons_section: { type: 'section', label: 'Intervals' },
- lessons_auto_range: {
- type: 'checkbox',
- label: 'Auto range intervals',
- default: true,
- hover_tip: 'Automatically decide what the intervals should be',
- path: '@lessons.auto_range',
- },
- lessons_gradient: {
- type: 'checkbox',
- label: 'Use gradients',
- default: false,
- hover_tip:
- 'Interpolate colors based on the exact number of items on that day',
- path: '@lessons.gradient',
- },
- lessons_generate: {
- type: 'button',
- label: 'Generate colors',
- text: 'Generate',
- hover_tip: 'Generate new colors from the first and last non-zero interval',
- on_click: generate_colors,
- },
- lessons_section2: { type: 'section', label: 'Other' },
- lessons_count_zeros: {
- type: 'checkbox',
- label: 'Include zeros in streak',
- default: false,
- hover_tip: 'Counts days with no lessons available towards the streak',
- path: '@lessons.count_zeros',
- },
- recover_lessons: {
- type: 'checkbox',
- label: 'Recover reset lessons',
- default: false,
- hover_tip:
- 'Allow the Heatmap to guess when you did lessons for items that have been reset',
- path: '@lessons.recover_lessons',
- },
- },
- },
- },
- },
- forecast: {
- type: 'page',
- label: 'Review Forecast',
- hover_tip: 'Settings pertaining to the forecast',
- content: {
- forecast_settings: {
- type: 'group',
- label: 'Forecast Settings',
- content: {
- forecast_section: { type: 'section', label: 'Intervals' },
- forecast_auto_range: {
- type: 'checkbox',
- label: 'Auto range intervals',
- default: true,
- hover_tip: 'Automatically decide what the intervals should be',
- path: '@forecast.auto_range',
- },
- forecast_gradient: {
- type: 'checkbox',
- label: 'Use gradients',
- default: false,
- hover_tip:
- 'Interpolate colors based on the exact number of items on that day',
- path: '@forecast.gradient',
- },
- forecast_generate: {
- type: 'button',
- label: 'Generate colors',
- text: 'Generate',
- hover_tip: 'Generate new colors from the first and last non-zero interval',
- on_click: generate_colors,
- },
- },
- },
- },
- },
- },
- },
- },
- }
- let dialog = new wkof.Settings(config)
- config.pre_open = (elem) => {
- dialog.refresh()
- modify_settings(elem)
- } // Refresh to populate settings before modifying
- delete wkof.settings[script_id].wkofs_active_tabs // Make settings dialog always open in first tab because it is so much taller
- dialog.open()
- }
- // Fetches user's v2 settings if they exist
- async function port_settings(settings) {
- if (wkof.file_cache.dir['wkof.settings.wanikani_heatmap']) {
- let old = await wkof.file_cache.load('wkof.settings.wanikani_heatmap')
- settings.general.start_date = old.general.start_date
- settings.general.week_start = old.general.week_start ? 0 : 6
- settings.general.day_start = old.general.hours_offset
- settings.general.reverse_years = old.general.reverse_years
- settings.general.segment_years = old.general.segment_years
- settings.general.day_labels = old.general.day_labels
- settings.general.now_indicator = old.general.today
- settings.general.color_now_indicator = old.general.today_color
- settings.general.level_indicator = old.general.level_ups
- settings.general.color_level_indicator = old.general.level_ups_color
- settings.reviews.auto_range = old.reviews.auto_range
- settings.reviews.colors = [
- [0, '#747474'],
- [1, old.reviews.color1],
- [old.reviews.interval1, old.reviews.color2],
- [old.reviews.interval2, old.reviews.color3],
- [old.reviews.interval3, old.reviews.color4],
- [old.reviews.interval4, old.reviews.color5],
- ]
- settings.forecast.colors = [
- [0, '#747474'],
- [1, old.reviews.forecast_color1],
- [old.reviews.interval1, old.reviews.forecast_color2],
- [old.reviews.interval2, old.reviews.forecast_color3],
- [old.reviews.interval3, old.reviews.forecast_color4],
- [old.reviews.interval4, old.reviews.forecast_color5],
- ]
- settings.forecast.auto_range = old.reviews.auto_range
- settings.lessons.colors = [
- [0, '#747474'],
- [1, old.lessons.color1],
- [old.lessons.interval1, old.lessons.color2],
- [old.lessons.interval2, old.lessons.color3],
- [old.lessons.interval3, old.lessons.color4],
- [old.lessons.interval4, old.lessons.color5],
- ]
- settings.lessons.auto_range = old.lessons.auto_range
- settings.lessons.count_zeros = old.lessons.count_zeros
- }
- settings.other.ported = true
- }
- // Updates settings if someone has outdated settings
- function migrate_settings(settings) {
- // Changed day labels from checkbox to dropdown
- if (typeof settings.general.day_labels === 'boolean')
- settings.general.day_labels = settings.general.day_labels ? 'english' : 'none'
- }
- // Reload the heatmap if settings have been changed
- function reload_on_change(settings) {
- if (applied) reload()
- }
- // Generates new colors for the intervals in the settings dialog
- function generate_colors(setting_name) {
- // Find the intervals
- let type = setting_name.split('_')[0]
- let panel = document.getElementById(script_id + '_' + type + '_settings').querySelector('.panel')
- let colors = wkof.settings[script_id][type].colors
- // Interpolate between first and last non-zero interval
- let first = colors[1]
- let last = colors[colors.length - 1]
- for (let i = 2; i < colors.length; i++) {
- colors[i][1] = interpolate_color(first[1], last[1], (i - 1) / (colors.length - 2))
- }
- // Refresh settings
- panel.querySelectorAll('.color input').forEach((input, i) => {
- input.value = colors[i][1]
- input.dispatchEvent(new Event('change'))
- })
- }
- /*-------------------------------------------------------------------------------------------------------------------------------*/
- // Extract upcoming reviews and completed lessons from the WKOF cache
- function get_forecast_and_lessons(data) {
- let forecast = [],
- lessons = []
- let time_now = Date.now()
- let vacation_offset = time_now - new Date(wkof.user.current_vacation_started_at || time_now)
- for (let item of data) {
- if (item.assignments?.started_at && item.assignments.unlocked_at) {
- // If the assignment has been started add a lesson containing staring date, id, level, and unlock date
- lessons.push([
- Date.parse(item.assignments.started_at),
- item.id,
- item.data.level,
- Date.parse(item.assignments.unlocked_at),
- ])
- // If item is in the future and it is not hidden by Wanikani, add the item to the forecast array
- if (
- item.assignments.available_at &&
- Date.parse(item.assignments.available_at) > time_now &&
- item.data.hidden_at === null
- ) {
- // If the assignment is scheduled add a forecast item ready for sending to the heatmap module
- let forecast_item = [
- Date.parse(item.assignments.available_at) + vacation_offset,
- { forecast: 1 },
- { 'forecast-ids': item.id },
- ]
- forecast_item[1]['forecast-srs1-' + item.assignments.srs_stage] = 1
- forecast.push(forecast_item)
- }
- }
- }
- // Sort lessons by started_at for easy extraction of chronological info
- lessons.sort((a, b) => (a[0] < b[0] ? -1 : 1))
- return [forecast, lessons]
- }
- // Fetch recovered lessons from storage or recover lessons then return them
- async function get_recovered_lessons(items, reviews, real_lessons) {
- if (!wkof.file_cache.dir.recovered_lessons) {
- let recovered_lessons = await recover_lessons(items, reviews, real_lessons)
- wkof.file_cache.save('recovered_lessons', recovered_lessons)
- return recovered_lessons
- } else return await wkof.file_cache.load('recovered_lessons')
- }
- // Use review data to guess when the lesson was done for all reset items
- async function recover_lessons(items, reviews, real_lessons) {
- // Fetch and prepare data
- let resets = await wkof.Apiv2.get_endpoint('resets')
- let items_id = wkof.ItemData.get_index(items, 'subject_id')
- let delay = 4 * msh
- let app1_reviews = reviews
- .filter((a) => a[2] == 1)
- .map((item) => [item[0] - delay, item[1], items_id[item[1]].data.level, item[0] - delay])
- // Check reviews based on reset intervals
- let last_date = 0,
- recovered_lessons = []
- Object.values(resets)
- .sort((a, b) => (a.data.confirmed_at < b.data.confirmed_at ? -1 : 1))
- .forEach((reset) => {
- let ids = {},
- date = Date.parse(reset.data.confirmed_at)
- // Filter out items not belonging to the current reset period
- let reset_reviews = app1_reviews.filter((a) => a[0] > last_date && a[0] < date)
- // Choose the earliest App1 review
- reset_reviews.forEach((item) => {
- if (!ids[item[1]] || ids[item[1]][0] > item[0]) ids[item[1]] = item
- })
- // Remove items that still have lesson data
- real_lessons.filter((a) => a[0] < date).forEach((item) => delete ids[item[1]])
- // Save recovered lessons to array
- Object.values(ids).forEach((item) => recovered_lessons.push(item))
- last_date = date
- })
- return recovered_lessons
- }
- // Calculate overall stats for lessons and reviews
- function calculate_stats(type, data) {
- let settings = wkof.settings[script_id]
- let streaks = get_streaks(type, data)
- let longest_streak = Math.max(...Object.values(streaks))
- let current_streak = streaks[new Date(Date.now() - msh * settings.general.day_start).toDateString()]
- let stats = {
- total: [0, 0, 0, 0, 0, 0], // [total, year, month, week, day, today]
- days_studied: [0, 0], // [days studied, percentage]
- average: [0, 0, 0], // [average, per studied, standard deviation]
- streak: [longest_streak, current_streak], // [longest streak, current streak]
- sessions: 0, // Number of sessions
- time: [0, 0, 0, 0, 0, 0], // [total, year, month, week, day, today]
- days: 0, // Number of days since first review
- max_done: [0, 0], // Max done in one day [count, date]
- streaks, // Streaks object
- }
- let last_day = new Date(0) // Last item's date
- let today = new Date() // Today
- let d = new Date(Date.now() - msd) // 24 hours ago
- let week = new Date(Date.now() - 7 * msd) // 7 days ago
- let month = new Date(Date.now() - 30 * msd) // 30 days ago
- let year = new Date(Date.now() - 365 * msd) // 365 days ago
- let last_time = 0 // Last item's timestamp
- let done_day = 0 // Total done on the date of the item
- let done_days = [] // List of total done on each day
- let start_date = new Date(settings.general.start_day) // User's start date
- for (let item of data) {
- let day = new Date(item[0] - msh * settings.general.day_start)
- if (day < start_date) continue // If item is before start, discard it
- // If it's a new day
- if (last_day.toDateString() != day.toDateString()) {
- stats.days_studied[0]++
- done_days.push(done_day)
- done_day = 0
- }
- // Update done this day
- done_day++
- if (done_day > stats.max_done[0]) stats.max_done = [done_day, day.toDateString().replace(/... /, '')]
- let minutes = (item[0] - last_time) / 60000
- // Update sessions
- if (minutes > settings.general.session_limit) {
- stats.sessions++
- minutes = 0
- }
- // Update totals
- stats.total[0]++
- stats.time[0] += minutes
- // Done in the last year
- if (year < day) {
- stats.total[1]++
- stats.time[1] += minutes
- }
- // Done in the last month
- if (month < day) {
- stats.total[2]++
- stats.time[2] += minutes
- }
- // Done in the last week
- if (week < day) {
- stats.total[3]++
- stats.time[3] += minutes
- }
- // Done in the last 24 hours
- if (d < day) {
- stats.total[4]++
- stats.time[4] += minutes
- }
- // Done today
- if (today.toDateString() == day.toDateString()) {
- stats.total[5]++
- stats.time[5] += minutes
- }
- // Store values for next item
- last_day = day
- last_time = item[0]
- }
- // Update averages
- done_days.push(done_day)
- const day_start_adjust = msh * settings.general.day_start // Adjust for the user's start of day setting
- const first_date = data?.[0]?.[0] || day_start_adjust
- stats.days =
- Math.round(
- (Date.parse(new Date().toDateString()) -
- Math.max(
- Date.parse(new Date(first_date).toDateString()),
- new Date(settings.general.start_day).getTime(),
- )) /
- msd,
- ) + 1
- stats.days_studied[1] = Math.round((stats.days_studied[0] / stats.days) * 100)
- stats.average[0] = Math.round(stats.total[0] / stats.days)
- stats.average[1] = Math.round(stats.total[0] / stats.days_studied[0])
- stats.average[2] = Math.sqrt(
- (1 / stats.days_studied[0]) *
- done_days.map((x) => Math.pow(x - stats.average[1], 2)).reduce((a, b) => a + b, 0),
- )
- return stats
- }
- // Finds streaks
- function get_streaks(type, data) {
- let settings = wkof.settings[script_id]
- let day_start_adjust = msh * settings.general.day_start // Adjust for the user's start of day setting
- // Initiate dates
- let streaks = {},
- zeros = {}
- const first_date = data?.[0]?.[0] || day_start_adjust
- for (
- let day = new Date(Math.max(first_date - day_start_adjust, new Date(settings.general.start_day).getTime()));
- day <= new Date();
- day.setDate(day.getDate() + 1)
- ) {
- streaks[day.toDateString()] = 0
- zeros[day.toDateString()] = true
- }
- // For all dates where something was done, set streak to 1
- for (let [date] of data)
- if (new Date(date) > new Date(settings.general.start_day))
- streaks[new Date(date - day_start_adjust).toDateString()] = 1
- // If user wants to count days where no lessons were available, set those streaks to 1 as well
- if (type === 'lessons' && settings.lessons.count_zeros) {
- // Delete dates where lessons were available
- for (let [started_at, id, level, unlocked_at] of data) {
- for (
- let day = new Date(unlocked_at - day_start_adjust);
- day <= new Date(started_at - day_start_adjust);
- day.setDate(day.getDate() + 1)
- ) {
- delete zeros[day.toDateString()]
- }
- }
- // Set all remaining dates to streak 1
- for (let date of Object.keys(zeros)) streaks[date] = 1
- }
- // Cumulate streaks
- let streak = 0
- for (
- let day = new Date(Math.max(first_date - day_start_adjust, new Date(settings.general.start_day).getTime()));
- day <= new Date().setHours(24);
- day.setDate(day.getDate() + 1)
- ) {
- if (streaks[day.toDateString()] === 1) streak++
- else streak = 0
- streaks[day.toDateString()] = streak
- }
- if (streaks[new Date().toDateString()] == 0)
- streaks[new Date().toDateString()] = streaks[new Date(new Date().setHours(-12)).toDateString()] || 0
- return streaks
- }
- // Get level up dates from API and lesson history
- async function get_level_ups(items) {
- let level_progressions = await wkof.Apiv2.get_endpoint('level_progressions')
- let first_recorded_date = level_progressions[Math.min(...Object.keys(level_progressions))].data.unlocked_at
- // Find indefinite level ups by looking at lesson history
- let levels = {}
- // Sort lessons by level then unlocked date
- items.forEach((item) => {
- if (
- item.object !== 'kanji' ||
- !item.assignments ||
- !item.assignments.unlocked_at ||
- item.assignments.unlocked_at >= first_recorded_date
- )
- return
- let date = new Date(item.assignments.unlocked_at).toDateString()
- if (!levels[item.data.level]) levels[item.data.level] = {}
- if (!levels[item.data.level][date]) levels[item.data.level][date] = 1
- else levels[item.data.level][date]++
- })
- // Discard dates with less than 10 unlocked
- // then discard levels with no dates
- // then keep earliest date for each level
- for (let [level, data] of Object.entries(levels)) {
- for (let [date, count] of Object.entries(data)) {
- if (count < 10) delete data[date]
- }
- if (Object.keys(levels[level]).length == 0) {
- delete levels[level]
- continue
- }
- levels[level] = Object.keys(data).reduce((low, curr) => (low < curr ? low : curr), Date.now())
- }
- // Map to array of [[level0, date0], [level1, date1], ...] Format
- levels = Object.entries(levels).map(([level, date]) => [Number(level), date])
- // Add definite level ups from API
- Object.values(level_progressions).forEach((level) =>
- levels.push([level.data.level, new Date(level.data.unlocked_at).toDateString()]),
- )
- return levels
- }
- /*-------------------------------------------------------------------------------------------------------------------------------*/
- // Create and install the heatmap
- async function install_heatmap(reviews, forecast, lessons, stats, items) {
- let settings = wkof.settings[script_id]
- // Create elements
- let heatmap =
- document.getElementById('heatmap') ||
- create_elem({
- type: 'section',
- id: 'heatmap',
- class: 'heatmap ' + (settings.other.visible_map === 'reviews' ? 'reviews' : ''),
- position: settings.general.position,
- onclick: day_click({ reviews, forecast, lessons }),
- })
- let buttons = create_buttons()
- let views = create_elem({ type: 'div', class: 'views' })
- heatmap.onmousedown = heatmap.onmouseup = heatmap.onmouseover = click_and_drag({ reviews, forecast, lessons })
- heatmap.setAttribute('theme', settings.general.theme)
- heatmap.style.setProperty(
- '--color-now',
- settings.general.now_indicator ? settings.general.color_now_indicator : 'transparent',
- )
- heatmap.style.setProperty(
- '--color-level',
- settings.general.level_indicator ? settings.general.color_level_indicator : 'transparent',
- )
- // Create heatmaps
- let cooked_reviews = cook_data('reviews', reviews)
- let cooked_lessons = cook_data('lessons', lessons)
- let level_ups = await get_level_ups(items)
- let reviews_view = create_view(
- 'reviews',
- stats,
- level_ups,
- reviews?.[0]?.[0] || Date.now(),
- forecast.reduce((max, a) => (max > a[0] ? max : a[0]), 0),
- cooked_reviews.concat(forecast),
- )
- let lessons_view = create_view(
- 'lessons',
- stats,
- level_ups,
- lessons?.[0]?.[0] || Date.now(),
- lessons.reduce((max, a) => (max > a[0] ? max : a[0]), 0),
- cooked_lessons,
- )
- let popper = create_popper({ reviews: cooked_reviews, forecast, lessons: cooked_lessons })
- views.append(reviews_view, lessons_view, popper)
- // Install
- heatmap.innerHTML = ''
- heatmap.append(buttons, views)
- let position = [
- ['.dashboard__content', 'beforebegin'],
- ['.dashboard__srs-progress', 'afterbegin'],
- ['.srs-progress', 'afterend'],
- ['.dashboard__item-lists', 'beforeend'],
- ['.dashboard__content', 'afterend'],
- ][settings.general.position]
- if (!document.getElementById('heatmap') || heatmap.getAttribute('position') != settings.general.position)
- document.querySelector(position[0]).insertAdjacentElement(position[1], heatmap)
- heatmap.setAttribute('position', settings.general.position)
- // Fire event to let people know it's finished loading
- fire_event('heatmap-loaded', heatmap)
- }
- // Creates the buttons at the top of the heatmap
- function create_buttons() {
- let buttons = create_elem({ type: 'div', class: 'buttons' })
- add_transitions(buttons)
- const leftButtons = create_elem({ type: 'div', class: 'left' })
- let settings_button = create_elem({
- type: 'button',
- class: 'settings-button hover-wrapper-target button',
- 'aria-label': 'Settings',
- children: [
- create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Settings' }),
- Icons.customIcon('settings'),
- ],
- onclick: open_settings,
- })
- let helpButton = create_elem({
- type: 'a',
- class: 'help-button hover-wrapper-target button',
- 'aria-label': 'Settings',
- href: 'https://community.wanikani.com/t/userscript-wanikani-heatmap',
- target: '_blank',
- children: [
- create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Help' }),
- Icons.customIcon('circle-question'),
- ],
- })
- let infoButton = create_elem({
- type: 'a',
- class: 'info-button hover-wrapper-target button',
- 'aria-label': 'Settings',
- href: 'https://community.wanikani.com/t/api-changes-get-all-reviews/61617',
- target: '_blank',
- children: [
- create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Why you might be missing reviews' }),
- Icons.customIcon('warning'),
- ],
- })
- leftButtons.append(settings_button, helpButton, infoButton)
- let toggle_button = create_elem({
- type: 'button',
- class: 'toggle-button hover-wrapper-target button',
- 'aria-label': 'Toggle between reviews and lessons',
- children: [
- create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Toggle view' }),
- Icons.customIcon('inbox'),
- ],
- onclick: toggle_visible_map,
- })
- buttons.append(leftButtons, toggle_button)
- return buttons
- }
- // Prepares data for the heatmap
- function cook_data(type, data) {
- if (type === 'reviews') {
- let ans = (srs, err) => {
- let srs2 = srs - Math.ceil(err / 2) * (srs < 5 ? 1 : 2) + (err == 0 ? 1 : 0)
- return srs2 < 1 ? 1 : srs2
- }
- return data.map((item) => {
- let cooked = [
- item[0],
- { reviews: 1, pass: item[3] + item[4] == 0 ? 1 : 0, incorrect: item[3] + item[4], streak: item[5] },
- { 'reviews-ids': item[1] },
- ]
- cooked[1][type + '-srs1-' + item[2]] = 1
- cooked[1][type + '-srs2-' + ans(item[2], item[3] + item[4])] = 1
- return cooked
- })
- } else if (type === 'lessons')
- return data.map((item) => [item[0], { lessons: 1, streak: item[4] }, { 'lessons-ids': item[1] }])
- else if (type === 'forecast') return data
- }
- // Create heatmaps and peripherals such as stats
- function create_view(type, stats, level_ups, first_date, last_date, data) {
- let settings = wkof.settings[script_id]
- let level_marks = level_ups.map(([level, date]) => [date, 'level-up' + (level == 60 ? ' level-60' : '')])
- // New heatmap instance
- let heatmap = new Heatmap(
- {
- type: 'year',
- id: type,
- week_start: settings.general.week_start,
- day_start: settings.general.day_start,
- first_date:
- Math.max(new Date(settings.general.start_day).getTime(), first_date) -
- settings.general.day_start * msh,
- last_date: last_date,
- segment_years: settings.general.segment_years,
- zero_gap: settings.general.zero_gap,
- markings: [[new Date(Date.now() - msh * settings.general.day_start), 'today'], ...level_marks],
- day_labels: settings.general.day_labels === 'kanji' && ['月', '火', '水', '木', '金', '土', '日'],
- day_hover_callback: (date, day_data) => {
- let type2 = type
- let time = new Date(date[0], date[1] - 1, date[2], 0, 0).getTime()
- if (
- type2 === 'reviews' &&
- time > Date.now() - msh * settings.general.day_start &&
- day_data.counts.forecast
- )
- type2 = 'forecast'
- let string = `${(day_data.counts[type2] || 0).toLocaleString()} ${
- type2 === 'forecast'
- ? 'reviews upcoming'
- : day_data.counts[type2] === 1
- ? type2.slice(0, -1)
- : type2
- } on ${
- new Date(time).toDateString().replace(/... /, '') + ' ' + kanji_day(new Date(time).getDay())
- }`
- if (time >= new Date(settings.general.start_day).getTime() && time > first_date) {
- string += `\nDay ${(
- Math.round(
- (time -
- Date.parse(
- new Date(
- Math.max(data[0]?.[0] || 0, new Date(settings.general.start_day).getTime()),
- ).toDateString(),
- )) /
- msd,
- ) + 1
- ).toLocaleString()}`
- }
- if (
- time < Date.now() &&
- time >= new Date(settings.general.start_day).getTime() &&
- time > first_date
- )
- string += `, Streak ${stats[type].streaks[new Date(time).toDateString()] || 0}`
- string += '\n'
- if (
- type2 === 'reviews' &&
- day_data.counts.forecast &&
- new Date(time).toDateString() == new Date().toDateString()
- ) {
- string += `\n${day_data.counts.forecast} more reviews upcoming`
- }
- if (type2 !== 'lessons' && day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')])
- string += '\nBurns ' + day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')]
- let level = (level_ups.find((a) => a[1] == new Date(time).toDateString()) || [undefined])[0]
- if (level) string += '\nYou reached level ' + level + '!'
- if (wkof.settings[script_id].other.times_popped < 5 && Object.keys(day_data.counts).length !== 0)
- string += '\nClick for details!'
- if (
- wkof.settings[script_id].other.times_popped >= 5 &&
- wkof.settings[script_id].other.times_dragged < 3 &&
- Object.keys(day_data.counts).length !== 0
- )
- string += '\nDid you know that you can click and drag, too?'
- return [string]
- },
- color_callback: (date, day_data) => color_picker(type, date, day_data),
- },
- data,
- )
- modify_heatmap(type, heatmap)
- // Create layout
- let view = create_elem({ type: 'div', class: type + ' view' })
- let title = create_elem({ type: 'div', class: 'title', child: type.toProper() })
- let [head_stats, foot_stats] = create_stats_elements(type, stats[type])
- let years = create_elem({ type: 'div', class: 'years' + (settings.general.reverse_years ? ' reverse' : '') })
- if (Math.max(...Object.keys(heatmap.maps)) > new Date().getFullYear()) {
- if (settings.other.visible_years[type][new Date().getFullYear() + 1] !== false)
- years.classList.add('visible-future')
- years.classList.add('has-future')
- }
- years.setAttribute('month-labels', settings.general.month_labels)
- years.setAttribute('day-labels', settings.general.day_labels)
- for (let year of Object.values(heatmap.maps).reverse()) years.prepend(year)
- view.append(title, head_stats, years, foot_stats)
- return view
- }
- // Make changes to the heatmap object before it is displayed
- function modify_heatmap(type, heatmap) {
- for (let [year, map] of Object.entries(heatmap.maps)) {
- let target = map.querySelector('.year-labels')
- let up = create_elem({
- type: 'div',
- class: 'toggle-year up hover-wrapper-target',
- onclick: toggle_year,
- children: [
- create_elem({
- type: 'div',
- class: 'hover-wrapper above',
- child: create_elem({
- type: 'div',
- child:
- 'Click to ' + (year == new Date().getFullYear() ? 'show next' : 'hide this') + ' year',
- }),
- }),
- Icons.customIcon('chevron-up'),
- ],
- })
- let down = create_elem({
- type: 'div',
- class: 'toggle-year down hover-wrapper-target',
- onclick: toggle_year,
- children: [
- create_elem({
- type: 'div',
- class: 'hover-wrapper below',
- child: create_elem({
- type: 'div',
- child:
- 'Click to ' +
- (year <= new Date().getFullYear() ? 'show previous' : 'hide this') +
- ' year',
- }),
- }),
- Icons.customIcon('chevron-down'),
- ],
- })
- target.append(up, down)
- if (wkof.settings[script_id].other.visible_years[type][year] === false) map.classList.add('hidden')
- }
- }
- // Create the header and footer stats for a view
- function create_stats_elements(type, stats) {
- // Create an single stat element complete with hover info
- let create_stat_element = (label, value, hover) => {
- return create_elem({
- type: 'div',
- class: 'stat hover-wrapper-target',
- children: [
- create_elem({ type: 'div', class: 'hover-wrapper above', child: hover }),
- create_elem({ type: 'span', class: 'stat-label', child: label }),
- create_elem({ type: 'span', class: 'value', child: value }),
- ],
- })
- }
- // Create the elements
- let head_stats = create_elem({
- type: 'div',
- class: 'head-stats stats',
- children: [
- create_stat_element(
- 'Days Studied',
- stats.days_studied[1] + '%',
- stats.days_studied[0].toLocaleString() + ' out of ' + stats.days.toLocaleString(),
- ),
- create_stat_element(
- 'Done Daily',
- stats.average[0] + ' / ' + (stats.average[1] || 0),
- 'Per Day / Days studied\nMax: ' + stats.max_done[0].toLocaleString() + ' on ' + stats.max_done[1],
- ),
- create_stat_element('Streak', stats.streak[1] + ' / ' + stats.streak[0], 'Current / Longest'),
- ],
- })
- let foot_stats = create_elem({
- type: 'div',
- class: 'foot-stats stats',
- children: [
- create_stat_element(
- 'Sessions',
- stats.sessions.toLocaleString(),
- (Math.floor(stats.total[0] / stats.sessions) || 0) + ' per session',
- ),
- create_stat_element(
- type.toProper(),
- stats.total[0].toLocaleString(),
- create_table('left', [
- ['Year', stats.total[1].toLocaleString()],
- ['Month', stats.total[2].toLocaleString()],
- ['Week', stats.total[3].toLocaleString()],
- ['24h', stats.total[4].toLocaleString()],
- ]),
- ),
- create_stat_element(
- 'Time',
- m_to_hm(stats.time[0]),
- create_table('left', [
- ['Year', m_to_hm(stats.time[1])],
- ['Month', m_to_hm(stats.time[2])],
- ['Week', m_to_hm(stats.time[3])],
- ['24h', m_to_hm(stats.time[4])],
- ]),
- ),
- ],
- })
- add_transitions(head_stats)
- add_transitions(foot_stats)
- return [head_stats, foot_stats]
- }
- // Add hover transition
- function add_transitions(elem) {
- elem.addEventListener('mouseover', (event) => {
- const elem = event.target.closest('.hover-wrapper-target')
- if (!elem) return
- elem.classList.add('heatmap-transition')
- setTimeout((_) => elem.classList.remove('heatmap-transition'), 20)
- })
- }
- // Initiates the popper element
- function create_popper(data) {
- // Create layout
- let popper = create_elem({ type: 'div', id: 'popper' })
- let header = create_elem({ type: 'div', class: 'header' })
- let minimap = create_elem({
- type: 'div',
- class: 'minimap',
- children: [
- create_elem({ type: 'span', class: 'minimap-label', child: 'Hours minimap' }),
- create_elem({ type: 'div', class: 'hours-map' }),
- ],
- })
- let stats = create_elem({ type: 'div', class: 'stats' })
- let items = create_elem({ type: 'div', class: 'items' })
- popper.append(header, minimap, stats, items)
- document.addEventListener('click', (event) => {
- if (!event.composedPath().find((a) => a === popper || (a.classList && a.classList.contains('years'))))
- popper.classList.remove('popped')
- })
- // Create header
- header.append(
- create_elem({
- type: 'div',
- class: 'clear hover-wrapper-target',
- children: [
- create_elem({
- type: 'div',
- class: 'hover-wrapper above',
- child: 'Clear all reviews from this day',
- }),
- create_elem({ type: 'button', id: 'clear_reviews', child: Icons.customIcon('trash') }),
- ],
- }),
- create_elem({ type: 'div', class: 'date' }),
- create_elem({
- type: 'div',
- class: 'subheader',
- children: [create_elem({ type: 'span', class: 'count' }), create_elem({ type: 'span', class: 'time' })],
- }),
- create_elem({
- type: 'div',
- class: 'score hover-wrapper-target',
- children: [
- create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Net progress of SRS levels' }),
- create_elem({ type: 'span' }),
- ],
- }),
- )
- header.querySelector('#clear_reviews').addEventListener('click', async () => {
- let [start, end] = header
- .querySelector('.date')
- .textContent.split('-')
- .map((d) => new Date(d.replace(/\s*.\s*$/, '')))
- if (!end) end = start
- end = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1).getTime() // Include end of interval
- const reviews = await review_cache.get_reviews()
- const newReviews = reviews.filter((review) => review[0] < start || review[0] >= end) // Omit reviews
- await review_cache.reload() // Since API returns empty array this clears cache
- await review_cache.insert(newReviews)
- })
- // Create minimap and stats
- stats.append(
- create_table(
- 'left',
- [['Levels'], [' 1-10', 0], ['11-20', 0], ['21-30', 0], ['31-40', 0], ['41-50', 0], ['51-60', 0]],
- { class: 'levels' },
- true,
- ),
- create_table(
- 'left',
- [
- ['SRS'],
- ['Before / After'],
- ['App', 0, 0],
- ['Gur', 0, 0],
- ['Mas', 0, 0],
- ['Enl', 0, 0],
- ['Bur', 0, 0],
- ],
- {
- class: 'srs hover-wrapper-target',
- child: create_elem({
- type: 'div',
- class: 'hover-wrapper below',
- child: create_elem({ type: 'table' }),
- }),
- },
- ),
- create_table('left', [['Type'], ['Rad', 0], ['Kan', 0], ['Voc', 0]], { class: 'type' }),
- create_table('left', [['Summary'], ['Pass', 0], ['Fail', 0], ['Acc', 0]], { class: 'summary' }),
- create_table('left', [['Answers'], ['Right', 0], ['Wrong', 0], ['Acc', 0]], {
- class: 'answers hover-wrapper-target',
- child: create_elem({
- type: 'div',
- class: 'hover-wrapper above',
- child: 'The total number of correct and incorrect answers',
- }),
- }),
- )
- return popper
- }
- // Creates a new minimap for the popper
- function create_minimap(type, data) {
- let settings = wkof.settings[script_id]
- let multiplier = 2
- return new Heatmap(
- {
- type: 'day',
- id: 'hours-map',
- first_date: Date.parse(new Date(data[0][0] - settings.general.day_start * msh).toDateString()),
- last_date: Date.parse(new Date(data[0][0] + msd - settings.general.day_start * msh).toDateString()),
- day_start: settings.general.day_start,
- day_hover_callback: (date, day_data) => {
- let type2 = type
- if (type2 === 'reviews' && Date.parse(date.join('-')) > Date.now() && day_data.counts.forecast)
- type2 = 'forecast'
- let string = [
- `${(day_data.counts[type2] || 0).toLocaleString()} ${
- type2 === 'forecast'
- ? 'reviews upcoming'
- : day_data.counts[type2] === 1
- ? type2.slice(0, -1)
- : type2
- } at ${date[3]}:00`,
- ]
- if (type2 !== 'lessons' && day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')])
- string += '\nBurns ' + day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')]
- return string
- },
- color_callback: (date, day_data) => color_picker(type, date, day_data, 2),
- },
- data,
- )
- }
- /*-------------------------------------------------------------------------------------------------------------------------------*/
- // Automatically determines what the user's interval bounds should be using quantiles
- function auto_range(stats, forecast_items) {
- let settings = wkof.settings[script_id]
- // Forecast needs to have some calculations done
- let forecast_days = {}
- for (let [date] of Object.values(forecast_items)) {
- let string = new Date(date).toDateString()
- if (!forecast_days[string]) forecast_days[string] = 1
- else forecast_days[string]++
- }
- let forecast_mean = forecast_items.length / Object.keys(forecast_days).length
- let forecast_sd =
- Math.sqrt(
- (1 / (forecast_items.length / forecast_mean)) *
- Object.values(forecast_days)
- .map((x) => Math.pow(x - forecast_mean, 2))
- .reduce((a, b) => a + b, 0),
- ) || 1
- // Get intervals
- let range = (length, gradient, mean, sd) => [
- 1,
- ...Array((length < 2 ? 2 : length) - 2)
- .fill(null)
- .map(
- (_, i) =>
- Math.round(ifcdf(((gradient ? 0.9 : 1) * (i + 1)) / (length - (gradient ? 1 : 0)), mean, sd)) ||
- 1,
- ),
- ]
- let reviews = range(
- settings.reviews.colors.length,
- settings.reviews.gradient,
- stats.reviews.average[1],
- stats.reviews.average[2],
- )
- let lessons = range(
- settings.lessons.colors.length,
- settings.lessons.gradient,
- stats.lessons.average[1],
- stats.lessons.average[2],
- )
- let forecast = range(settings.forecast.colors.length, settings.forecast.gradient, forecast_mean, forecast_sd)
- if (settings.reviews.auto_range)
- for (let i = 1; i < settings.reviews.colors.length; i++) settings.reviews.colors[i][0] = reviews[i - 1]
- if (settings.lessons.auto_range)
- for (let i = 1; i < settings.lessons.colors.length; i++) settings.lessons.colors[i][0] = lessons[i - 1]
- if (settings.forecast.auto_range)
- for (let i = 1; i < settings.forecast.colors.length; i++) settings.forecast.colors[i][0] = forecast[i - 1]
- wkof.Settings.save(script_id)
- }
- // Picks colors for the heatmap days
- function color_picker(type, date, day_data, multiplier = 1) {
- let settings = wkof.settings[script_id]
- let type2 = type
- if (
- type2 === 'reviews' &&
- new Date(date[0], date[1] - 1, date[2], 0, 0).getTime() > Date.now() - msh * settings.general.day_start &&
- day_data.counts.forecast
- )
- type2 = 'forecast'
- let colors = settings[type2].colors
- // If gradients are not enabled, use intervals
- if (!settings[type2].gradient) {
- for (let [bound, color] of colors.slice().reverse()) {
- if (day_data.counts[type2] * multiplier >= bound) {
- return color
- }
- }
- return colors[0][1]
- // If gradients are enabled, interpolate colors
- } else {
- // Multiplier is used for minimap to get better ranges
- if (!day_data.counts[type2] * multiplier) return colors[0][1]
- if (day_data.counts[type2] * multiplier >= colors[colors.length - 1][0]) return colors[colors.length - 1][1]
- for (let i = 2; i < colors.length; i++) {
- if (day_data.counts[type2] * multiplier <= colors[i][0]) {
- let percentage =
- (day_data.counts[type2] * multiplier - colors[i - 1][0]) / (colors[i][0] - colors[i - 1][0])
- return interpolate_color(colors[i - 1][1], colors[i][1], percentage)
- }
- }
- }
- }
- // Toggles between lessons and reviews
- function toggle_visible_map() {
- let heatmap = document.getElementById('heatmap')
- heatmap.classList.toggle('reviews')
- wkof.settings[script_id].other.visible_map = heatmap.classList.contains('reviews') ? 'reviews' : 'lessons'
- wkof.Settings.save(script_id)
- }
- // Toggles the visibility of the years
- function toggle_year(event) {
- let visible_years = wkof.settings[script_id].other.visible_years
- let year_elem = event.target.closest('.year')
- let up = event.target.closest('.toggle-year').classList.contains('up')
- let year = Number(year_elem.getAttribute('data-year'))
- let future = year > new Date().getFullYear()
- let type = year_elem.classList.contains('reviews') ? 'reviews' : 'lessons'
- if (up || (!up && future)) {
- if (year == new Date().getFullYear()) {
- visible_years[type][year + 1] = true
- year_elem.nextElementSibling.classList.remove('hidden')
- year_elem.parentElement.classList.add('visible-future')
- } else {
- visible_years[type][year] = false
- year_elem.classList.add('hidden')
- if (!up && future) year_elem.parentElement.classList.remove('visible-future')
- }
- } else {
- visible_years[type][year - 1] = true
- year_elem.previousElementSibling.classList.remove('hidden')
- }
- // Make sure at least one year is visible
- if (!Object.values(visible_years[type]).find((a) => a == true)) {
- visible_years[type][year] = true
- }
- wkof.Settings.save(script_id)
- }
- // Updates the popper with new info
- async function update_popper(event, type, title, info, minimap_data, burns, time) {
- let items_id = await wkof.ItemData.get_index(await wkof.ItemData.get_items('include_hidden'), 'subject_id')
- let popper = document.getElementById('popper')
- // Get info
- let levels = new Array(61).fill(0)
- levels[0] = new Array(6).fill(0)
- let item_types = { rad: 0, kan: 0, voc: 0 }
- for (let id of info.lists[type + '-ids']) {
- let item = items_id[id]
- if (!item) continue
- levels[0][Math.floor((item.data.level - 1) / 10)]++
- levels[item.data.level]++
- const type = item.object === 'kana_vocabulary' ? 'voc' : item.object.slice(0, 3)
- item_types[type]++
- }
- let srs = new Array(10).fill(null).map((_) => [0, 0])
- for (let i = 1; i < 10; i++) {
- srs[i][0] = info.counts[type + '-srs1-' + i] || 0
- srs[i][1] = info.counts[type + '-srs2-' + i] || 0
- }
- let srs_counter = (index, start, end) =>
- srs.map((a, i) => (i >= start ? (i <= end ? a[index] : 0) : 0)).reduce((a, b) => a + b, 0)
- srs[0] = [
- [srs_counter(0, 1, 4), srs_counter(1, 1, 4)],
- [srs_counter(0, 5, 6), srs_counter(1, 5, 6)],
- srs[7],
- srs[8],
- srs[9],
- ]
- let srs_diff = Object.entries(srs.slice(1)).reduce((a, b) => a + b[0] * (b[1][1] - b[1][0]), 0)
- let pass = [
- info.counts.pass,
- info.counts.reviews - info.counts.pass,
- Math.floor((info.counts.pass / info.counts.reviews) * 100),
- ]
- let answers = [
- info.counts.reviews * 2 - item_types.rad,
- info.counts.incorrect,
- Math.floor(
- ((info.counts.reviews * 2 - item_types.rad) /
- (info.counts.incorrect + info.counts.reviews * 2 - item_types.rad)) *
- 100,
- ),
- ]
- let item_elems = []
- const ids = [...new Set(info.lists[type + '-ids'])]
- const svgs = {}
- const svgPromises = []
- for (const id of ids) {
- if (!items_id[id] || items_id[id]?.data?.characters) continue
- svgPromises.push(
- wkof
- .load_file(
- items_id[id].data.character_images.find(
- (a) => a.content_type == 'image/svg+xml' && a.metadata.inline_styles,
- ).url,
- )
- .then((svg) => {
- let svgElem = document.createElement('span')
- svgElem.innerHTML = svg.replace(/<svg /, `<svg class="radical-svg" `)
- svgs[id] = svgElem.firstChild
- }),
- )
- }
- await Promise.allSettled(svgPromises)
- for (let id of ids) {
- let item = items_id[id]
- if (!item) continue
- let burn = burns.includes(id)
- const type = item.object === 'kana_vocabulary' ? 'vocabulary' : item.object
- item_elems.push(
- create_elem({
- type: 'a',
- class: 'item ' + type + ' hover-wrapper-target' + (burn ? ' burn' : ''),
- href: item.data.document_url,
- children: [
- create_elem({
- type: 'div',
- class: 'hover-wrapper above',
- children: [
- create_elem({
- type: 'a',
- class: 'characters',
- href: item.data.document_url,
- child: item.data.characters || svgs[id].cloneNode(true),
- }),
- create_table(
- 'left',
- [
- ['Meanings', item.data.meanings.map((i) => i.meaning).join(', ')],
- [
- 'Readings',
- item.data.readings
- ? item.data.readings.map((i) => i.reading).join('、 ')
- : '-',
- ],
- ['Level', item.data.level],
- ],
- { class: 'info' },
- ),
- ],
- }),
- create_elem({
- type: 'a',
- class: 'characters',
- child: item.data.characters || svgs[id].cloneNode(true),
- }),
- ],
- }),
- )
- }
- let time_str = ms_to_hms(time)
- let count = info.lists[type + '-ids'].length
- let count_str =
- (type === 'forecast' ? 'upcoming review' : type.slice(0, type.length - 1)) + (count === 1 ? '' : 's')
- // Populate popper
- popper.className = type
- popper.querySelector('.date').innerText = title
- popper.querySelector('.count').innerText = count.toLocaleString() + ' ' + count_str
- popper.querySelector('.time').innerText = type == 'forecast' ? '' : time_str ? ' (' + time_str + ')' : ''
- popper.querySelector('.score > span').innerText = (srs_diff < 0 ? '' : '+') + srs_diff.toLocaleString()
- popper.querySelectorAll('.levels .hover-wrapper > *').forEach((e) => e.remove())
- popper.querySelectorAll('.levels > tr > td').forEach((e, i) => {
- e.innerText = levels[0][i].toLocaleString()
- e.parentElement.setAttribute('data-count', levels[0][i])
- e.parentElement.children[0].append(
- create_table(
- 'left',
- levels
- .slice(1)
- .map((a, j) => [j + 1, a.toLocaleString()])
- .filter((a) => Math.floor((a[0] - 1) / 10) == i && a[1] != 0),
- ),
- )
- })
- popper.querySelectorAll('.srs > tr > td').forEach((e, i) => {
- e.innerText = srs[0][Math.floor(i / 2)][i % 2].toLocaleString()
- })
- popper
- .querySelector('.srs .hover-wrapper table')
- .replaceWith(
- create_table('left', [
- ['SRS'],
- ['Before / After'],
- ...srs
- .slice(1)
- .map((a, i) => [
- ['App 1', 'App 2', 'App 3', 'App 4', 'Gur 1', 'Gur 2', 'Mas', 'Enl', 'Bur'][i],
- ...a.map((_) => _.toLocaleString()),
- ]),
- ]),
- )
- popper.querySelectorAll('.type td').forEach((e, i) => {
- e.innerText = item_types[['rad', 'kan', 'voc'][i]].toLocaleString()
- })
- popper.querySelectorAll('.summary td').forEach((e, i) => {
- e.innerText = (pass[i] || 0).toLocaleString()
- })
- popper.querySelectorAll('.answers td').forEach((e, i) => {
- e.innerText = (answers[i] || 0).toLocaleString()
- })
- popper.querySelector('.items').replaceWith(create_elem({ type: 'div', class: 'items', children: item_elems }))
- popper.querySelector('.minimap > .hours-map').replaceWith(create_minimap(type, minimap_data).maps.day)
- popper.style.top = event.pageY + 50 + 'px'
- popper.classList.add('popped')
- wkof.settings[script_id].other.times_popped++
- wkof.Settings.save(script_id)
- }
- /*-------------------------------------------------------------------------------------------------------------------------------*/
- // Returns the function that handles clicks on days. Wrapped for data storage
- function day_click(data) {
- function event_handler(event) {
- let settings = wkof.settings[script_id]
- let elem = event.target
- if (elem.classList.contains('day')) {
- let date = elem.getAttribute('data-date').split('-')
- date = new Date(date[0], date[1] - 1, date[2], 0, 0)
- let type = elem.closest('.view').classList.contains('reviews')
- ? date < new Date()
- ? 'reviews'
- : 'forecast'
- : 'lessons'
- if (Object.keys(elem.info.lists).length) {
- let title = `${date.toDateString().slice(4)} ${kanji_day(date.getDay())}`
- let today = new Date(new Date().toDateString()).getTime()
- let offset = wkof.settings[script_id].general.day_start * msh
- let day_data = data[type].filter(
- (a) => a[0] >= date.getTime() + offset && a[0] < date.getTime() + msd + offset,
- )
- let minimap_data = cook_data(type, day_data)
- let burns = day_data
- .filter((item) => item[2] === 8 && item[3] + item[4] === 0)
- .map((item) => item[1])
- let time = minimap_data
- .map((a, i) => a[0] - (minimap_data[i - 1] || [0])[0])
- .filter((a) => a < settings.general.session_limit * 60 * 1000)
- .reduce((a, b) => a + b, 0)
- update_popper(event, type, title, elem.info, minimap_data, burns, time)
- }
- }
- }
- return event_handler
- }
- // Returns the function that handles click and drag. Wrapped for data storage
- function click_and_drag(data) {
- let down,
- first_day,
- first_date,
- marked = []
- function event_handler(event) {
- let elem = event.target
- // If event concerns a day element, proceed
- if (elem.classList.contains('day')) {
- let date = elem.getAttribute('data-date').split('-')
- date = new Date(date[0], date[1] - 1, date[2], 0, 0)
- let type = elem.closest('.view').classList.contains('reviews')
- ? date < new Date()
- ? 'reviews'
- : 'forecast'
- : 'lessons'
- // Start selection
- if (event.type === 'mousedown') {
- event.preventDefault()
- down = true
- first_day = elem
- first_date = new Date(elem.getAttribute('data-date'))
- }
- // End selection
- if (event.type === 'mouseup') {
- if (first_day !== elem) {
- // Gather the data then update popper
- let second_date = new Date(elem.getAttribute('data-date'))
- let start_date = first_date < second_date ? first_date : second_date
- let end_date = first_date < second_date ? second_date : first_date
- type = elem.closest('.view').classList.contains('reviews')
- ? start_date < new Date()
- ? 'reviews'
- : 'forecast'
- : 'lessons'
- let title = `${start_date.toDateString().slice(4)} ${kanji_day(
- start_date.getDay(),
- )} - ${end_date.toDateString().slice(4)} ${kanji_day(end_date.getDay())}`
- let today = new Date(new Date().toDateString()).getTime()
- let offset = wkof.settings[script_id].general.day_start * msh
- let day_data = data[type].filter(
- (a) => a[0] > start_date.getTime() + offset && a[0] < end_date.getTime() + msd + offset,
- )
- let mapped_day_data = day_data.map((a) => [
- today + new Date(a[0]).getHours() * msh + wkof.settings[script_id].general.day_start * msh,
- ...a.slice(1),
- ])
- let minimap_data = cook_data(type, mapped_day_data)
- let popper_info = { counts: {}, lists: {} }
- for (let item of minimap_data) {
- for (let [key, value] of Object.entries(item[1])) {
- if (!popper_info.counts[key]) popper_info.counts[key] = 0
- popper_info.counts[key] += value
- }
- for (let [key, value] of Object.entries(item[2])) {
- if (!popper_info.lists[key]) popper_info.lists[key] = []
- popper_info.lists[key].push(value)
- }
- }
- let burns = day_data
- .filter((item) => item[2] === 8 && item[3] + item[4] === 0)
- .map((item) => item[1])
- let time = day_data
- .map((a, i) => Math.floor((a[0] - (day_data[i - 1] || [0])[0]) / (60 * 1000)))
- .filter((a) => a < 10)
- .reduce((a, b) => a + b, 0)
- update_popper(event, type, title, popper_info, minimap_data, burns, time)
- wkof.settings[script_id].other.times_dragged++
- }
- }
- // Update selection
- if (event.type === 'mouseover' && down) {
- let view = document.querySelector('#heatmap .view.' + (type === 'forecast' ? 'reviews' : type))
- if (!view) return
- for (let m of marked) {
- m.classList.remove('selected')
- }
- marked = []
- elem.classList.add('selected')
- marked.push(elem)
- let d = new Date(first_date.getTime())
- while (d.toDateString() !== date.toDateString()) {
- let e = view.querySelector(
- `.day[data-date="${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}"]`,
- )
- e.classList.add('selected')
- marked.push(e)
- d.setDate(d.getDate() + (d < date ? 1 : -1))
- }
- }
- }
- // If mouse is let go, remove selection
- if (event.type === 'mouseup') {
- down = false
- for (let m of marked) {
- m.classList.remove('selected')
- }
- marked = []
- }
- }
- return event_handler
- }
- /*-------------------------------------------------------------------------------------------------------------------------------*/
- // Shorthand for creating new elements. Keys that do not have a special function will be added as attributes
- function create_elem(config) {
- let div = document.createElement(config.type)
- for (let [attr, value] of Object.entries(config)) {
- if (attr === 'type') continue
- else if (attr === 'child') div.append(value)
- else if (attr === 'children') div.append(...value)
- else if (attr === 'value') div.value = value
- else if (attr === 'input') div.setAttribute('type', value)
- else if (attr === 'onclick') div.onclick = value
- else if (attr === 'callback') continue
- else div.setAttribute(attr, value)
- }
- if (config.callback) config.callback(div)
- return div
- }
- // Creates a table from a matrix
- function create_table(header, data, table_attr, tr_hover) {
- let table = create_elem(Object.assign({ type: 'table' }, table_attr))
- for (let [i, row] of Object.entries(data)) {
- let tr_config = { type: 'tr' }
- if (tr_hover) {
- tr_config.class = 'hover-wrapper-target'
- tr_config.child = create_elem({ type: 'div', class: 'hover-wrapper below' })
- }
- let tr = create_elem(tr_config)
- for (let [j, cell] of Object.entries(row)) {
- let cell_type = (header == 'top' && i == 0) || (header == 'left' && j == 0) ? 'th' : 'td'
- tr.append(create_elem({ type: cell_type, child: cell }))
- }
- table.append(tr)
- }
- return table
- }
- // Returns the kanij for the day
- function kanji_day(day) {
- return ['日', '月', '火', '水', '木', '金', '土'][day]
- }
- // Converts minutes to a timestamp string "#h #m"
- function m_to_hm(minutes) {
- return Math.floor(minutes / 60) + 'h ' + Math.floor(minutes % 60) + 'm'
- }
- // Converts ms to a timestamp string "#h #m #s" where only the first two non-zero values are included
- function ms_to_hms(ms) {
- const hms = [
- [ms + 1, msh, 'h'],
- [msh, 60 * 1000, 'm'],
- [60 * 1000, 1000, 's'],
- ]
- return hms
- .map((a) => Math.floor((ms % a[0]) / a[1]) + a[2])
- .filter((a) => a[0] !== '0')
- .slice(0, 2)
- .join(' ')
- }
- // Capitalizes the first character in a string. "proper" → "Proper"
- String.prototype.toProper = function () {
- return this.slice(0, 1).toUpperCase() + this.slice(1)
- }
- // Returns a hex color between the left and right hex colors
- function interpolate_color(left, right, index) {
- if (isNaN(index)) return left
- left = hex_to_rgb(left)
- right = hex_to_rgb(right)
- let r###lt = [0, 0, 0]
- for (let i = 0; i < 3; i++) r###lt[i] = Math.round(left[i] + index * (right[i] - left[i]))
- return rgb_to_hex(r###lt)
- }
- // Converts a hex color to rgb
- function hex_to_rgb(hex) {
- let r###lt = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
- return [parseInt(r###lt[1], 16), parseInt(r###lt[2], 16), parseInt(r###lt[3], 16)]
- }
- // Converts an rgb color to hex
- function rgb_to_hex(cols) {
- let rgb = cols[2] | (cols[1] << 8) | (cols[0] << 16)
- return '#' + (0x1000000 + rgb).toString(16).slice(1)
- }
- // Crude approximation of inverse folded cumulative distribution function
- // Used for the quantiles in auto-ranging
- function ifcdf(p, m, sd) {
- // Folded cumulative distribution function
- function fcdf(x, mean, sd) {
- // Error function
- function erf(x) {
- let sign = x >= 0 ? 1 : -1
- x = Math.abs(x)
- let a1 = 0.254829592,
- a2 = -0.284496736
- let a3 = 1.421413741,
- a4 = -1.453152027
- let a5 = 1.061405429,
- p = 0.3275911
- let t = 1 / (1 + p * x)
- let y = 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x)
- return sign * y
- }
- return 0.5 * (erf((x + mean) / (sd * Math.sqrt(2))) + erf((x - mean) / (sd * Math.sqrt(2))))
- }
- let p2 = 0,
- items = 0,
- step = Math.ceil(sd / 10)
- while (p2 < p) {
- items += step
- p2 = fcdf(items, m, sd)
- }
- return items
- }
- // Fires a custom event on an element
- function fire_event(event_name, elem) {
- const event = document.createEvent('Event')
- event.initEvent(event_name, true, true)
- elem.dispatchEvent(event)
- }
- })(window.wkof, window.review_cache, window.Heatmap, window.Icons)