Heatmap

Simple script that creates a heatmap

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.gf.qytechs.cn/scripts/410910/1251299/Heatmap.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Heatmap
// @namespace    http://tampermonkey.net/
// @version      1.0.10
// @description  Simple script that can generate heatmaps
// @author       Kumirei
// @include      /^https://(www|preview).wanikani.com/(dashboard)?$/
// @grant        none
// ==/UserScript==

;(function ($) {
    // The heatmap object
    class Heatmap {
        constructor(config, data) {
            this.maps = {}
            this.config = config
            this.data = {}

            this.config.day_labels ||= ['M', 'T', 'W', 'T', 'F', 'S', 'S']

            // If data is provided, initiate right away
            if (data !== undefined) this.initiate(data)
        }

        // Creates heatmaps for the data
        initiate(data) {
            let dates = this._get_dates(data)
            let parsed_data = this._parse_data(data, dates)
            this.data = parsed_data
            if (this.config.type === 'year') {
                for (let year = dates.first_year; year <= dates.last_year; year++) {
                    this.maps[year] = this._init_year(year, parsed_data, dates)
                }
                this._add_markings(this.config.markings, this.maps)
            }
            if (this.config.type === 'day') {
                this.maps.day = this._init_single_day(parsed_data, dates)
            }
        }

        // Parses data into a date structure
        _parse_data(data, dates) {
            // Prepare vessel
            let parsed_data = {}
            for (let year = dates.first_year; year <= dates.last_year; year++) {
                parsed_data[year] = {}
                for (let month = 1; month <= 12; month++) {
                    parsed_data[year][month] = {}
                    for (let day = 0; day <= 31; day++) {
                        parsed_data[year][month][day] = {
                            counts: {},
                            lists: {},
                            hours: new Array(24).fill().map(() => {
                                return { counts: {}, lists: {} }
                            }),
                        }
                    }
                }
            }
            // Populate vessel
            for (let [date, counts, lists] of data) {
                let [year, month, day, hour] = this._get_ymdh(date - 1000 * 60 * 60 * this.config.day_start)
                if (
                    date - 1000 * 60 * 60 * this.config.day_start < new Date(this.config.first_date).getTime() ||
                    date - 1000 * 60 * 60 * this.config.day_start >
                        new Date(this.config.last_date || date + 1).getTime()
                )
                    continue
                let parsed_day = parsed_data[year][month][day]
                for (let [key, value] of Object.entries(counts)) {
                    if (!parsed_day.counts[key]) parsed_day.counts[key] = value || 0
                    else parsed_day.counts[key] += value || 0
                    if (!parsed_day.hours[hour].counts[key]) parsed_day.hours[hour].counts[key] = value
                    else parsed_day.hours[hour].counts[key] += value
                }
                for (let [key, value] of Object.entries(lists)) {
                    if (!parsed_day.lists[key]) parsed_day.lists[key] = [value]
                    else parsed_day.lists[key].push(value)
                    if (!parsed_day.hours[hour].lists[key]) parsed_day.hours[hour].lists[key] = [value]
                    else parsed_day.hours[hour].lists[key].push(value)
                }
            }
            return parsed_data
        }

        // Create a year element for the heatmap
        _init_year(year, data, dates) {
            let cls =
                'year heatmap ' +
                this.config.id +
                (this.config.segment_years ? ' segment_years' : '') +
                (this.config.zero_gap ? ' zero_gap' : '') +
                (year > new Date().getFullYear() ? ' future' : '') +
                (year == new Date().getFullYear() ? ' current' : '')
            let year_elem = this._create_elem({ type: 'div', class: cls })
            year_elem.setAttribute('data-year', year)
            this._add_transitions(year_elem)
            let labels = this._create_elem({ type: 'div', class: 'year-labels', children: this._get_year_labels(year) })
            let months = this._create_elem({ type: 'div', class: 'months' })
            for (let month = 1; month <= 12; month++) {
                months.append(this._init_month(year, month, data, dates))
            }
            year_elem.append(labels, months)
            return year_elem
        }

        // Create labels for the years
        _get_year_labels(year) {
            let year_label = this._create_elem({
                type: 'div',
                class: 'year-label hover-wrapper-target',
                child: String(year),
            })
            let day_labels = this._create_elem({ type: 'div', class: 'day-labels' })
            for (let day = 0; day < 7; day++) {
                day_labels.append(
                    this._create_elem({
                        type: 'div',
                        class: 'day-label',
                        child: this.config.day_labels[(day + Number(this.config.week_start)) % 7],
                    }),
                )
            }
            return [year_label, day_labels]
        }

        // Create a month element for the year
        _init_month(year, month, data, dates) {
            let offset = (new Date(year, month - 1, 1, 0, 0).getDay() + 6 - this.config.week_start) % 7
            let month_elem = this._create_elem({ type: 'div', class: 'month offset-' + offset })
            if (year === dates.first_year && month < dates.first_month) month_elem.classList.add('no-data')
            let label = this._create_elem({
                type: 'div',
                class: 'month-label',
                child: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month - 1],
            })
            let days = this._create_elem({ type: 'div', class: 'days' })
            let days_in_month = this._get_days_in_month(year, month)
            for (let day = 1; day <= days_in_month; day++) {
                days.append(this._init_day(year, month, day, data, dates))
            }
            month_elem.append(label, days)
            return month_elem
        }

        // Create a day element for the month
        _init_day(year, month, day, data, dates) {
            let day_data = data[year][month][day]
            let day_elem = this._create_elem({
                type: 'div',
                class: 'day hover-wrapper-target',
                info: { counts: day_data.counts, lists: day_data.lists },
                child: this._create_elem({
                    type: 'div',
                    class: 'hover-wrapper',
                    child: this.config.day_hover_callback([year, month, day], day_data),
                }),
            })
            day_elem.setAttribute('data-date', `${year}-${month}-${day}`)
            if (year === dates.first_year && month === dates.first_month && day < dates.first_day)
                day_elem.classList.add('no-data')
            day_elem.style.setProperty('background-color', this.config.color_callback([year, month, day], day_data))
            return day_elem
        }

        // Creates a simple heatmap with 24 squares that represent a single day
        _init_single_day(data, dates) {
            let day = this._create_elem({ type: 'div', class: 'single-day ' + this.config.id })
            this._add_transitions(day)
            let hour_data = data[dates.first_year][dates.first_month][dates.first_day].hours
            let current_hour = new Date().getHours()
            for (let i = 0; i < 24; i++) {
                let j = (i + this.config.day_start) % 24
                let hour = this._create_elem({
                    type: 'div',
                    class:
                        'hour hover-wrapper-target' +
                        (j === current_hour && Date.parse(new Date().toDateString()) == this.config.first_date
                            ? ' today marked'
                            : ''),
                    info: { counts: hour_data[i].counts, lists: hour_data[i].lists },
                })
                let hover = this._create_elem({
                    type: 'div',
                    class: 'hover-wrapper',
                    child: this.config.day_hover_callback(
                        [dates.first_year, dates.first_month, dates.first_day, j],
                        hour_data[i],
                    ),
                })
                hour.append(hover)
                hour.style.setProperty(
                    'background-color',
                    this.config.color_callback([dates.first_year, dates.first_month, dates.first_day, j], hour_data[i]),
                )
                day.append(hour)
            }
            day.instance = this
            return day
        }

        // Marks provided dates with a border
        _add_markings(markings, years) {
            for (let [date, mark] of markings) {
                let [year, month, day] = this._get_ymdh(date)
                if (years[year])
                    years[year]
                        .querySelector(`.day[data-date="${year}-${month}-${day}"]`)
                        .classList.add(...mark.split(' '), 'marked')
            }
        }

        // Number of days in each month
        _get_days_in_month(year, month) {
            return [31, this._is_leap_year(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
        }

        // Checks for leap year
        _is_leap_year(year) {
            return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
        }

        // Shorthand for creating new elements
        _create_elem(config) {
            let div = document.createElement(config.type)
            for (let [attr, value] of Object.entries(config)) {
                if (attr === 'type') continue
                else if (attr === 'class') div.className = value
                else if (attr === 'child') div.append(value)
                else if (attr === 'children') div.append(...value)
                else div[attr] = value
            }
            return div
        }

        // Get first and last dates that should be visible in the heatmap
        _get_dates() {
            let [first_year, first_month, first_day] = this._get_ymdh(this.config.first_date)
            let [last_year, last_month, last_day] = this._get_ymdh(this.config.last_date || Date.now())
            return { first_year, first_month, first_day, last_year, last_month, last_day }
        }

        // Convert date into year month date and hour
        _get_ymdh(date) {
            let d = new Date(date)
            return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours()]
        }

        // Add hover transition
        _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)
            })
        }
    }

    // Expose class
    window.Heatmap = Heatmap
})()