WaniKani Workload Graph

adds a button to the heatmap that displays your average workload over time

当前为 2021-02-27 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         WaniKani Workload Graph
// @namespace    rwesterhof
// @version      0.1
// @description  adds a button to the heatmap that displays your average workload over time
// @match        https://www.wanikani.com/
// @match        https://preview.wanikani.com/
// @match        https://www.wanikani.com/dashboard
// @match        https://preview.wanikani.com/dashboard
// @require      https://greasyfork.org/scripts/410909-wanikani-review-cache/code/Wanikani:%20Review%20Cache.js?version=852495
// @run-at       document-end
// @grant        none
// ==/UserScript==

(function(wkof, review_cache) {
    'use strict';

    /* global $, wkof */

    if (!wkof) {
        let response = confirm('WaniKani Workload Graph script 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;
    }

    // --------------------------- //
    // -------- TRIGGER ---------- //
    // --------------------------- //
    var init = false;
    var graphButton =
        '<button class="sitemap__section-header" data-navigation-section-toggle="" data-expanded="false" aria-expanded="false" type="button" aria-controls="sitemap__levels" onclick="window.graphReviews()">'
      +     '<span lang="en" class="font-sans">Graph</span>'
      + '</button>';

    var heatmapButton =
        '<button class="graph-button hover-wrapper-target" onclick="window.graphReviews()"'
            // todo: put in css for class graph-button
      +     'style="height: 30px; width: 30px; background-color: transparent !important; outline: none !important; box-shadow: none !important; color: var(--color); border: none; left: 25px; top: -5px;">'
      +    '<div class="hover-wrapper above">Workload graph</div>'
      +    '<i class="icon-bar-chart"></i>'
      + '</button>';
    const maxAttempts = 8;
    const attemptInterval = 800; //ms
    setTimeout(attemptHeatmapInstall, attemptInterval, 1);

    function attemptHeatmapInstall(counter) {

        if (document.getElementById('heatmap')) {
            $('#heatmap .views .reviews.view').prepend(heatmapButton);
        }
        else if (counter < maxAttempts) {
            setTimeout(attemptHeatmapInstall, attemptInterval, ++counter);
        }
        else {
            $('#sitemap').prepend(graphButton);
        }
    }

    function graphReviews() {
        if (!init) {
            wkof.include('ItemData');
            wkof.ready('ItemData')
                .then(displayProgressPane)
                .then(stripData)
                .then(cookData)
                .then(createGraph)
                .then(displayGraph);
            init = true;
        }
        else {
            $('#workloadGraph').removeClass('hidden');
        }
    }
    window.graphReviews = graphReviews;

    // --------------------------- //
    // --- OBJECTS & CONSTANTS --- //
    // --------------------------- //
     function getBlankStrippedData() {
         return { lastReviewDate: 0, reviewDays: [], levelUps: [] };
    }

    const categories = [ -1, 0, 0, 0, 0, 1, 1, 2, 3, 4 ];
    function toCategory(stage) {
        return categories[stage];
    }

    function getBlankReviewDayObj() {
        return { day: 0,
                 date: 0,
                 year: 0,
                 reviewsPerStage: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                 reviewsPerCategory: [0, 0, 0, 0],
                 reviewsTotal: 0
        };
    }

    function getBlankCookedDayObj() {
        return { point: 0,
                 year: 0,
                 levelUp: 0,
                 nrOfDays: 0,
                 reviewsPerStage: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                 reviewsPerCategory: [0, 0, 0, 0],
                 reviewsTotal: 0
        };
    }

    const msInDay = 24 * 60 * 60 * 1000;
    const maxWidth = 900;
    const minWidth = maxWidth / 2;
    const maxPointSize = 10;
    const runningAverageDays = 7;
    const xAxisPadding = 40;
    const yAxisPadding = 50;
    const categoryColor = [ "#dd0093", "#882d9e", "#294ddb", "#0093dd" ];
    const signalLevel = 5;

    // ------------------------------------ //
    // ----- RETRIEVAL AND STRIPPING ------ //
    // ------------------------------------ //
    // strips the review_cache down to what is needed for the current script
    async function stripData() {
        var data = await Promise.all([review_cache.get_reviews(), wkof.ItemData.get_items('assignments, include_hidden')]);

        var strippedData = getBlankStrippedData();
        if (data.length < 1 || data[0].length < 1 || data[0][0].length < 1) return strippedData;

        var reviews = data[0];
        var minDate = new Date(data[0][0][0]);
        minDate.setHours(0,0,0,0);
        var minMs = minDate.getTime();
        var curYear = 0;

        var wkDays = 0;
        var progressCounter = data[0].length;

        var stripFunction = function(item) {
            if (--progressCounter % 100000 == 50000) {
                console.log("Processing... " + progressCounter + " reviews left");
            }
            var nextDateMs = minMs + (wkDays * msInDay);
            while (item[0] > nextDateMs) {
                var nextReviewDay = getBlankReviewDayObj();
                nextReviewDay.date = nextDateMs;
                var reviewYear = new Date(nextReviewDay.date).getFullYear();
                if (reviewYear > curYear) {
                    nextReviewDay.year = reviewYear;
                    curYear = reviewYear;
                }

                wkDays++;
                nextDateMs += msInDay;

                nextReviewDay.day = wkDays;
                strippedData.reviewDays.push(nextReviewDay);
            }

            var reviewDay = strippedData.reviewDays[wkDays-1];
            reviewDay.reviewsPerStage[item[2]]++;
            reviewDay.reviewsPerCategory[toCategory(item[2])]++;
            reviewDay.reviewsTotal++;
            strippedData.lastReviewDate = item[0];
        };

        reviews.forEach(stripFunction);

        strippedData.levelUps = await get_level_ups(data[1]);
        return strippedData;
    }

    // plain copy from the heatmap script - wonder if we can store this somewhere so it can be reused rather than refetched. Also
    // requires retrieval of all the items from wkof.
    // 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;
    }

    // -------------------------------------- //
    // ------------- PROCESSING ------------- //
    // -------------------------------------- //
    function getGraphPoint(cookedData, index) {
        if (index >= cookedData.length) return null;

        if (!cookedData[index]) {
            cookedData[index] = getBlankCookedDayObj();
            cookedData[index].point = index;
        }
        return cookedData[index];
    }

    function addArrayTotals(arrayOne, arrayTwo) {
        if (arrayOne == null) return arrayTwo;
        if (arrayTwo == null) return arrayOne;
        if (arrayTwo.length < arrayOne.length) return addArrayTotals(arrayTwo, arrayOne);
        for (var index = 0; index < arrayOne.length; index++) {
            arrayOne[index] += arrayTwo[index];
        }
        return arrayOne;
    }

    // accumulate reviewdata to data points
    function cookData(strippedData) {
        var nrOfEntries = strippedData.reviewDays.length;
        console.log("Scaling for " + nrOfEntries + " review days");
        var daysPerPoint = Math.floor(nrOfEntries / maxWidth);
        if ((nrOfEntries % maxWidth > 0) || (daysPerPoint == 0)) {
            daysPerPoint++;
        }
        var totalPoints = Math.floor(nrOfEntries / daysPerPoint);
        if (nrOfEntries % daysPerPoint > 0) {
            totalPoints++;
        }

        var cookedData = new Array(totalPoints + 1);
        var cookFunction = function(reviewDay) {
            for (var countDay = reviewDay.day; countDay < reviewDay.day + runningAverageDays; countDay++) {
              var graphPoint = Math.ceil(countDay / daysPerPoint);
              var cookedDay = getGraphPoint(cookedData, graphPoint);
              if (!cookedDay) break;
              cookedDay.nrOfDays++;
              cookedDay.reviewsPerStage = addArrayTotals(cookedDay.reviewsPerStage, reviewDay.reviewsPerStage);
              cookedDay.reviewsPerCategory = addArrayTotals(cookedDay.reviewsPerCategory, reviewDay.reviewsPerCategory);
              cookedDay.reviewsTotal += reviewDay.reviewsTotal;
              if (countDay == reviewDay.day) {
                  if (reviewDay.year != 0) {
                      cookedDay.year = reviewDay.year;
                  }
                  const searchDate = new Date(reviewDay.date).toDateString();
                  let level = (strippedData.levelUps.find(a=>a[1]==searchDate) || [undefined])[0];
                  if (level) {
                      cookedDay.levelUp = level;
                  }
              }
            }
        };

        strippedData.reviewDays.forEach(cookFunction);
        return cookedData;
    }

    // ---------------------------------------------- //
    // -------------- DISPLAY ----------------------- //
    // ---------------------------------------------- //
    const graphDiv = '<div id="workloadGraph" class="" style="position: fixed; top: 100px; left: 100px; z-index: 1;"></div>';
    const closeGraphButton = '<button id="closeGraph" onclick="window.hideGraph()" style="cursor:pointer; position:absolute; top: 10px; right: 10px; border: 0px;"><i class="icon-minus"></i></button>';
    function hideGraph() {
        $('#workloadGraph').addClass('hidden');
    }
    window.hideGraph = hideGraph;

    function displayProgressPane() {
        $('.srs-progress').before(graphDiv);
        var graphCanvas = document.createElement("canvas");
        graphCanvas.id="graphCanvas";
        graphCanvas.width = 200;
        graphCanvas.height = 200;

        var progressPane = $('#workloadGraph');
        progressPane.append(graphCanvas);
        progressPane.removeClass('hidden');
        clearGraph("Init..."); // turns out the graph refresh during processing doesn't want to trigger, so we log instead
    }

    // draws a single point on the canvas
    function drawPoint(ctx, toX, toY, xAxisPointSize, fillInd, sameValue, firstPointInd) {
        if (fillInd || (!firstPointInd && !sameValue)) {
            ctx.lineTo(toX, toY);
        }
        else {
            ctx.moveTo(toX, toY);
        }
        if (xAxisPointSize > 1) {
            toX += xAxisPointSize - 1;
            if (fillInd || !sameValue) {
                ctx.lineTo(toX, toY);
            }
            else {
                ctx.moveTo(toX, toY);
            }
        }
    }

    // clears the graph and optionally adds progress text
    function clearGraph(text) {
        var graphCanvas = $('#graphCanvas')[0];
        var ctx = graphCanvas.getContext("2d");
        ctx.save();

        ctx.fillStyle="#f4f4f4";
        ctx.fillRect(0, 0, graphCanvas.width, graphCanvas.height);
        ctx.strokeStyle = "#000000";
        ctx.beginPath();
        ctx.rect(0, 0, graphCanvas.width, graphCanvas.height);
        ctx.stroke();

        if (text) {
            ctx.strokeText(text, xAxisPadding, yAxisPadding);
        }

        ctx.restore();
    }

    // display the graph on canvas
    function displayGraph(graphData) {

        // resize and empty the canvas for redrawing
        var graphCanvas = $('#graphCanvas')[0];
        graphCanvas.width = graphData.xAxisMaxSize + 2*xAxisPadding;
        graphCanvas.height = graphData.yAxisMaxSize + 2*yAxisPadding;
        clearGraph();

        var ctx = graphCanvas.getContext("2d");
        if (graphData.categoryPoints.length < 2) {
            ctx.strokeText("No data", xAxisPadding, yAxisPadding);
            return;
        }
        var fillInd = true;
        ctx.save();

        var baseY = graphCanvas.height - yAxisPadding;
        for (var cat = categoryColor.length - 1; cat >= 0; cat--) {

            var currentX = xAxisPadding;

            ctx.fillStyle = categoryColor[cat];
            ctx.strokeStyle = categoryColor[cat];
            ctx.beginPath();
            ctx.moveTo(currentX, baseY);
            currentX++;

            // nb: 0th entry is not a point!
            for (var pointIndex = 1; pointIndex < graphData.categoryPoints.length; pointIndex++) {
                var currentY = baseY - graphData.categoryPoints[pointIndex][cat];
                var sameValue = (graphData.categoryPoints[pointIndex][cat] == ((cat > 0) ? graphData.categoryPoints[pointIndex][cat - 1] : 0));
                drawPoint(ctx, currentX, currentY, graphData.xAxisPointSize, fillInd, sameValue, (pointIndex == 1));
                currentX += graphData.xAxisPointSize;
            }

            if (fillInd) {
                ctx.lineTo(currentX - 1, baseY);
                ctx.lineTo(xAxisPadding, baseY);
                ctx.fill();
            }
            else {
                ctx.stroke();
            }
        }

        // draw axes
        ctx.strokeStyle = "#000000";
        ctx.beginPath();
        ctx.moveTo(xAxisPadding, baseY);
        ctx.lineTo(xAxisPadding + graphData.xAxisMaxSize, baseY);
        ctx.stroke();
        var middleCorrection = Math.floor(graphData.xAxisPointSize / 2);
        ctx.strokeStyle = "#007700";
        for (var dashIndex = 0; dashIndex < graphData.xAxisYears.length; dashIndex++) {
            var atX = xAxisPadding + graphData.xAxisYears[dashIndex][0] * graphData.xAxisPointSize - middleCorrection;
            ctx.beginPath();
            ctx.moveTo(atX, baseY);
            ctx.lineTo(atX, baseY + 20);
            ctx.stroke();
            ctx.strokeText(graphData.xAxisYears[dashIndex][1], atX - 12, baseY + 35);
        }
        ctx.strokeStyle = "#000000";
        for (dashIndex = 0; dashIndex < graphData.xAxisLevelUps.length; dashIndex++) {
            atX = xAxisPadding + graphData.xAxisLevelUps[dashIndex][0] * graphData.xAxisPointSize - middleCorrection;
            var signalInd = (graphData.xAxisLevelUps[dashIndex][1] % signalLevel == 0);
            ctx.beginPath();
            ctx.moveTo(atX, baseY);
            ctx.lineTo(atX, baseY + (signalInd ? 10 : 5));
            ctx.stroke();
            if (signalInd) {
                ctx.strokeText(graphData.xAxisLevelUps[dashIndex][1], atX - 4, baseY + 20);
            }
        }

        ctx.beginPath();
        ctx.moveTo(xAxisPadding, baseY);
        ctx.lineTo(xAxisPadding, baseY - graphData.yAxisMaxSize);
        ctx.stroke();
        var nrOfDashes = 3;
        var yIndicatorsPer = Math.ceil(graphData.yAxisMaxValue / ((nrOfDashes+1) * 10)) * 10;
        for (var dash = 1; dash <=nrOfDashes; dash++) {
            var atY = baseY - ((yIndicatorsPer * dash) / graphData.yAxisPointValue);
            if (atY < yAxisPadding) break;
            ctx.beginPath();
            ctx.moveTo(xAxisPadding, atY);
            ctx.lineTo(xAxisPadding - 5, atY);
            ctx.stroke();
            ctx.strokeText(yIndicatorsPer * dash, xAxisPadding - 20, atY + 3);
        }

        // title
        ctx.font = "bold 24px Arial, sans-serif";
        ctx.fillStyle="#007700";
        ctx.fillText("Workload - reviews per day", Math.floor(graphData.xAxisMaxSize / 2) - 160 + xAxisPadding, yAxisPadding - 10);
        ctx.restore();

        // allow closing of the graph
        $('#workloadGraph').append(closeGraphButton);
    }

    // translate review datapoints to graph points
    function createGraph(cookedData) {

        var maxValue = 0;
        var maxFinder = function(cookedDay) {
            var newCandidate = Math.ceil(cookedDay.reviewsTotal / cookedDay.nrOfDays);
            if (newCandidate > maxValue) {
                maxValue = newCandidate;
            }
        }
        cookedData.forEach(maxFinder);

        // scaling of axes
        var xAxisMaxSize = cookedData.length;
        var xAxisPointSize = 1;
        if (xAxisMaxSize < minWidth) {
            xAxisPointSize = Math.ceil(minWidth / xAxisMaxSize);
            xAxisMaxSize = xAxisPointSize * cookedData.length;
            if (xAxisPointSize > maxPointSize) {
                xAxisPointSize = maxPointSize;
                xAxisMaxSize = minWidth;
            }
        }
        var yAxisMaxSize = Math.ceil(xAxisMaxSize * 9 / 16); // take a standard aspect ratio
        var yAxisMaxValue = Math.ceil(maxValue * 1.2 / 10) * 10; // let the values climb to abou 80% of the graph
        var yAxisPointValue = yAxisMaxValue / yAxisMaxSize; // not an integer

        // translate review information to graph points
        var categoryPoints = new Array(cookedData.length);
        var xAxisYears = new Array();
        var xAxisLevelUps = new Array();
        var pointFunction = function(cookedData) {
            if (cookedData == null) return;

            categoryPoints[cookedData.point] = [0, 0, 0, 0];
            var divider = (cookedData.nrOfDays * yAxisPointValue);
            categoryPoints[cookedData.point][0] = cookedData.reviewsPerCategory[0] / divider;
            categoryPoints[cookedData.point][1] = (cookedData.reviewsPerCategory[0] + cookedData.reviewsPerCategory[1]) / divider;
            categoryPoints[cookedData.point][2] = (cookedData.reviewsPerCategory[0] + cookedData.reviewsPerCategory[1] + cookedData.reviewsPerCategory[2]) / divider;
            categoryPoints[cookedData.point][3] = (cookedData.reviewsPerCategory[0] + cookedData.reviewsPerCategory[1] + cookedData.reviewsPerCategory[2] + cookedData.reviewsPerCategory[3]) / divider;

            if (cookedData.year != 0) {
                xAxisYears.push([ cookedData.point, cookedData.year ]);
            }
            if (cookedData.levelUp != 0) {
                xAxisLevelUps.push([ cookedData.point, cookedData.levelUp ]);
            }
        };
        cookedData.forEach(pointFunction);

        // return the accumulated graph data for drawing
        return {
            xAxisMaxSize: xAxisMaxSize,
            xAxisPointSize: xAxisPointSize,
            xAxisYears: xAxisYears,
            xAxisLevelUps: xAxisLevelUps,
            yAxisMaxSize: yAxisMaxSize,
            yAxisMaxValue: yAxisMaxValue,
            yAxisPointValue: yAxisPointValue,
            categoryPoints: categoryPoints
        };
    }

})(window.wkof, window.review_cache);