AO3: Stat Graphs+

New and improved statistics page for AO3

// ==UserScript==
// @name        AO3: Stat Graphs+
// @version     1.0.1
// @description New and improved statistics page for AO3
// @author      sharkcat
// @namespace   https://github.com/sharkcatshark/Userscripts
// @match       https://archiveofourown.org/users/*/stats*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=archiveofourown.org
// @require     https://code.jquery.com/jquery-3.7.0.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// @license     GNU GPLv3
// ==/UserScript==

// -- START Settings -- //

const maxFicsPerGraph = 10; // how many fics per graph you want to be displayed

const coloursArray = ["#f77078","#eda278","#faff66","#92d86e","#6e92d8","#9679e1","#dd75a6","#363636"]; // colours for graphs // doesnt affect categories, ratings, warnings pie charts
const bordersArray = ["#ff696f","#ffa068","#f7fe68","#88f354","#5587f2","#9063f8","#f75ea1","#4b2827"];

const maxTitleCharacters = 15; // cuts off titles if they get too long for labels (includes spaces)

// Metric Graph Only
const displayZeroMetrics = false; // show a fic in the graph if the value is 0

// these two set the default display for the metric graph on page load
const defaultMetric = "kudos";  // can be 'hits', 'kudos', 'bookmarks', 'comments', 'subs', 'words'
const defaultOrder = "Dsc"; // "Asc" for ascending

// Overview Graph Only
const removeHitsFromOverview = true; // hits can overwhelm the graph and make the other metrics hard to see but you can turn them back on here

// -- END Settings -- //

Chart.register(ChartDataLabels);

class Fic {
    constructor(title, fandoms, words, hits, kudos, comments, subs, bookmarks) {
        this.title = title;
        this.fandom = fandoms;
        this.words = words;
        this.hits = hits;
        this.kudos = kudos;
        this.comments = comments;
        this.subs = subs;
        this.bookmarks = bookmarks;
    }
}

// redirect to flat view
const username = window.location.href.split("/")[4];
if (!window.location.href.endsWith("/stats?flat_view=true")) {
    window.location = "https://archiveofourown.org/users/" + username + "/stats?flat_view=true";
}

// adding in divs for graph placement
$('<div id="charts"></div>').insertAfter('#main > ul.navigation.actions');
$('#charts').append('<div id="mainSection">');
$('#mainSection').append('<div id="summary"></div><div id="metric"></div><div id="overall"></div>');
$('#metric').append('<div id="toggleButtonsDiv"></div><div id="metricGraph" class="graph"></div>');
$("#toggleButtonsDiv").append('<ul id="toggleButtons"><li><button id="hitsToggle" class="metricButton">Hits</button></li><li><button id="kudosToggle" class="metricButton">Kudos</button></li><li><button id="bookmarksToggle" class="metricButton">Bookmarks</button></li><li><button id="commentsToggle" class="metricButton">Comments</button></li><li><button id="subsToggle" class="metricButton">Subs</button></li><li><button id="wordsToggle" class="metricButton">Words</button></li></ul>');
$("#overall").append('<canvas id="overviewGraph"></canvas>');
$('#charts').append('<div id="sidePie"></div>');
$('#sidePie').append('<div id="fandom" class="pie"><canvas id="fandomGraph"></canvas></div>');
$("#sidePie").append('<div id="category" class="pie"><canvas id="categoryGraph"></canvas></div>');
$("#sidePie").append('<div id="rating" class="pie"><canvas id="ratingGraph"></canvas></div>');
$("#sidePie").append('<div id="warning" class="pie"><canvas id="warningGraph"></canvas></div>');

// getting main data
var ficList = [];

const fics = document.querySelectorAll(".statistics.index.group > li > ul > li");
for (let i = 0; i < fics.length; i++) {
    let title = document.querySelectorAll(".index.group a")[i].innerHTML;
    let fandoms = document.querySelectorAll(".index.group span.fandom")[i].innerHTML.slice(1, -1).split(", ");
    let words = Number(document.querySelectorAll("span.words")[i].innerHTML.replace(/\(|,|\swords?\)/g, "")); // why is this Number not parseInt
    let hits = parseInt(document.querySelectorAll(".index.group dd.hits")[i].innerHTML.replace(",", ""));
    let kudos = parseInt(document.querySelectorAll(".index.group dd.kudos")[i].innerHTML.replace(",", ""));
    let comments = parseInt(document.querySelectorAll(".index.group dd.comments")[i].innerHTML.replace(",", ""));
    try {
        var subs = parseInt(document.querySelectorAll(".index.group dd.subscriptions")[i].innerHTML.replace(",", ""));
    }
    catch (error) {
        var subs = 0;
    }
    try {
        var bookmarks = parseInt(document.querySelectorAll(".index.group dd.bookmarks")[i].innerHTML.replace(",", ""));
    }
    catch (error) {
        var bookmarks = 0;
    }
    var newFic = new Fic(title, fandoms, words, hits, kudos, comments, subs, bookmarks); // create instance of class
    ficList.push(newFic); // add instance to ficList array
}

// get list of fic urls to get data from (reading from other pages)
const ficURLS = document.querySelectorAll('ul.statistics > li.fandom > ul > li a');
const urls = [];

for (let i = 0; i < ficURLS.length; i++) {
    let url = ficURLS[i].href;
    urls.push(url);
}

// organising fandom data
var fandomOutput = [];

for (let i = 0; i < ficList.length; i++) {
    for (let j = 0; j < ficList[i].fandom.length; j++) {
        let foundIndex = fandomOutput.map(a => a.fandom).indexOf(ficList[i].fandom[j]);
        if (foundIndex > -1) {
            fandomOutput[foundIndex].count++;
        }
        else {
            var newFandom = {fandom: ficList[i].fandom[j], count: 1};
            fandomOutput.push(newFandom);
        }
    }
}

// sort big to small
fandomOutput.sort(function(a, b) {
    return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
});

// make summary box
var totalWords = 0;
var totalHits = 0;
var totalKudos = 0;
var totalComments = 0;
var totalSubs = 0;
var totalBookmarks = 0;
var userSubs = $("dd.user.subscriptions").html(); // this is the only one you cant calculate

// calculating values as opposed to reading off chart because reasons
for (let i = 0; i < ficList.length; i++) {
    totalWords += ficList[i].words;
    totalHits += ficList[i].hits;
    totalKudos += ficList[i].kudos;
    totalComments += ficList[i].comments;
    totalSubs += ficList[i].subs;
    totalBookmarks += ficList[i].bookmarks;
}

const averageWords = Math.round(totalWords / 3);
const hkRatio = Math.round(totalHits / totalKudos);

// removing old content
$("#stat_chart").remove(); // default graph
$("ul.view.actions").remove(); // view switch options
$("ol.year.actions").remove(); // year nav
$("h3.heading").remove(); // totals heading
$("dl.statistics.meta.group").unwrap(); // wrapper parent for general cleanup
$("dl.statistics.meta.group").remove(); // total summary box
$("div.actions.module").remove(); // default sort by direction

// placing summary box
const summaryCode = "<dl><p>Totals:</p><dt>Words</dt><dd>" + totalWords.toLocaleString("en") + "</dd><dt>Hits</dt><dd>" + totalHits.toLocaleString("en") + "</dd><dt>Kudos</dt><dd>" + totalKudos.toLocaleString("en") + "</dd><dt>Bookmarks</dt><dd>" + totalBookmarks.toLocaleString("en") + "</dd><dt>Comment Threads</dt><dd>" + totalComments.toLocaleString("en") + "</dd><dt>User Subs</dt><dd>" + userSubs.toLocaleString("en") + "</dd><dt>Fic Subs</dt><dd>" + totalSubs.toLocaleString("en") + "</dd><dt>Fandoms Written For</dt><dd>" + fandomOutput.length.toLocaleString("en") + "</dd><p>Averages:</p><dt>Word Count</dt><dd>" + averageWords.toLocaleString("en") + "</dd><dt>Hits/Kudos Ratio</dt><dd>" + hkRatio + ":1</dd></dl>";
$("#summary").append(summaryCode);

// graphs
const overviewData = [
    {
      label: "Hits",
      backgroundColor: coloursArray[0],
      borderColor: bordersArray[0],
      borderWidth: 2,
      data: ficList.map(a => a.hits)
    },
    {
      label: "Kudos",
      backgroundColor: coloursArray[1],
      borderColor: bordersArray[1],
      borderWidth: 2,
      data: ficList.map(a => a.kudos)
    },
    {
      label: "Bookmarks",
      backgroundColor: coloursArray[2],
      borderColor: bordersArray[2],
      borderWidth: 2,
      data: ficList.map(a => a.bookmarks)
    },
    {
      label: "Comments",
      backgroundColor: coloursArray[3],
      borderColor: bordersArray[3],
      borderWidth: 2,
      data: ficList.map(a => a.comments)
    },
    {
      label: "Subscriptions",
      backgroundColor: coloursArray[4],
      borderColor: bordersArray[4],
      borderWidth: 2,
      data: ficList.map(a => a.subs)
    }
];

if (removeHitsFromOverview){
    overviewData.splice(0, 1);
}

ficList.splice(maxFicsPerGraph);
displayGraph("overviewGraph", "bar", ficList.map(a => shortenTitle(a.title)), overviewData, "Stats Overview", true); // overview
generateGraph(defaultMetric, defaultOrder); // metric
makePieChart("fandomGraph", fandomOutput.map(a => a.fandom), coloursArray, fandomOutput.map(a => a.count), "Fics Per Fandom"); // pie chart

async function fetchData(url) {
    const response = await fetch(url);
    const html = await response.text();

    const domParser = new DOMParser();
    const page = domParser.parseFromString(html, "text/html");

    // get elements from fic page
    const rating = page.querySelector("dd.rating > ul.commas > li").innerText;
    const warningArray = Array.from(page.querySelectorAll("dd.warning.tags ul.commas > li"));
    let categoryArray = [];
    try {
        categoryArray = Array.from(page.querySelectorAll("dd.category.tags ul.commas > li"));
    }
    catch {
        categoryArray = ["No Category Given"];
    }
    const warnings = [];
    const category = [];
    for (let i = 0; i < warningArray.length; i++) {
        warnings.push(warningArray[i].innerText);
    }

    for (let i = 0; i < categoryArray.length; i++) {
        category.push(categoryArray[i].innerText);
    }
    const chapters = page.querySelector("dd.chapters").innerText;

    return { rating, warnings, category, chapters };
}

// bonus pie charts and stats
async function getDataFromMultipleURLs(urls) {
  const ficList = [];

  for (let i = 0; i < urls.length; i++) {
    const fic = await fetchData(urls[i], i);
    ficList.push(fic);
  }
  return ficList;
}

getDataFromMultipleURLs(urls).then(ficList => {
    var ratingOutput = [];
    for (let i = 0; i < ficList.length; i++) { // for each fic in ficlist
        let foundIndex = ratingOutput.map(a => a.rating).indexOf(ficList[i].rating);
        if (foundIndex > -1) {
            ratingOutput[foundIndex].count++;
        }
        else {
            var newRating = {rating: ficList[i].rating, count: 1};
            ratingOutput.push(newRating);
        }
    }

    ratingOutput.sort(function(a, b) {
        return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
    });

    var ratingColourArray = getPieMetricColour(ratingOutput);

    makePieChart("ratingGraph", ratingOutput.map(a => a.rating), ratingColourArray, ratingOutput.map(a => a.count), "Ratings");

    var warningOutput = [];
    for (let i = 0; i < ficList.length; i++) { // for each fic in ficlist
        for (let j = 0; j < ficList[i].warnings.length; j++) { // for each warning in array
            let foundIndex = warningOutput.map(a => a.warning).indexOf(ficList[i].warnings[j]); // does the output have warning already?
            if (foundIndex > -1) { // if yes (found will give an index of where it is so above -1 means it exists)
                warningOutput[foundIndex].count++;
            }
            else {
                var newWarning = {warning: ficList[i].warnings[j], count: 1};
                warningOutput.push(newWarning);
            }
        }
    }

    warningOutput.sort(function(a, b) {
        return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
    });

    var warningColourArray = getPieMetricColour(warningOutput);
    makePieChart("warningGraph", warningOutput.map(a => a.warning), warningColourArray, warningOutput.map(a => a.count), "Warnings");

    var categoryOutput = [];
    for (let i = 0; i < ficList.length; i++) {
        for (let j = 0; j < ficList[i].category.length; j++) {
            let foundIndex = categoryOutput.map(a => a.category).indexOf(ficList[i].category[j]);
            if (foundIndex > -1) {
                categoryOutput[foundIndex].count++;
            }
            else {
                var newCategory = {category: ficList[i].category[j], count: 1};
                categoryOutput.push(newCategory);
            }
        }
    }

    // sort big to small
    categoryOutput.sort(function(a, b) {
        return ((b.count < a.count) ? -1 : ((b.count == a.count) ? 0 : 1));
    });

    var completeFics = 0;
    var incompleteFics = 0;
    var chapterCount = 0;

    for (let i = 0; i < ficList.length; i++) {
        if (ficList[i].chapters.match(/\S+(?=\/)/g)[0] == ficList[i].chapters.match(/(?<=\/)\S+/g)[0]) {
            completeFics++;
        }
        else {
            incompleteFics++;
        }

        chapterCount += parseInt(ficList[i].chapters); // will parse as a number until it gets to the / (ie only gets '45' from '45/56')
    }

    var categoryColourArray = getPieMetricColour(categoryOutput);
    makePieChart("categoryGraph", categoryOutput.map(a => a.category), categoryColourArray, categoryOutput.map(a => a.count), "Categories");

    var completeRate = (completeFics/(completeFics+incompleteFics)) * 100;
    var avgChapterCount = chapterCount / ficList.length;
    const summaryCode = "<dt>Fic Completion Rate</dt><dd>" + completeRate + "%</dd><dt>Chapter Count</dt><dd>" + avgChapterCount + "</dd>";
    $("#summary dl").append(summaryCode);
});

// event listeners for buttons
const hitsBtn = document.getElementById("hitsToggle");
hitsBtn.addEventListener("click", function() {
    removeAndFlipGraph("hits");
});

const kudosBtn = document.getElementById("kudosToggle");
kudosBtn.addEventListener("click", function() {
    removeAndFlipGraph("kudos");
});

const subsBtn = document.getElementById("subsToggle");
subsBtn.addEventListener("click", function() {
    removeAndFlipGraph("subs");
});

const wordsBtn = document.getElementById("wordsToggle");
wordsBtn.addEventListener("click", function() {
    removeAndFlipGraph("words");
});

const commentsBtn = document.getElementById("commentsToggle");
commentsBtn.addEventListener("click", function() {
    removeAndFlipGraph("comments");
});

const bookmarksBtn = document.getElementById("bookmarksToggle");
bookmarksBtn.addEventListener("click", function() {
    removeAndFlipGraph("bookmarks");
});

// functions
function makePieChart(elementID, pieLabels, bgColours, pieData, pieTitle) {
    new Chart(document.getElementById(elementID), {
        type: "pie",
        data: {
            labels: pieLabels,
            datasets: [{
                backgroundColor: bgColours,
                borderColor: bgColours,
                data: pieData
            }]
        },
        options: {
            plugins: {
                title: {
                    display: true,
                    text: pieTitle
                },
                datalabels: {
                    color: "#fff",
                    textStrokeColor: '#000',
                    textStrokeWidth: 4,
                    font: {
                        size: 12,
                        weight: "normal"
                    },
                    anchor: "center"
                },
                legend: {
                    display: false
                }
            }
        }
    });
}

function displayGraph(elementID, type, xLabels, dataset, graphTitle, legendDisplayTF) {
    new Chart(document.getElementById(elementID), {
        type: type,
        data: {
            labels: xLabels,
            datasets: dataset
        },
        options: {
            plugins: {
                title: {
                    display: true,
                    text: graphTitle
                },

                legend: {
                    display: legendDisplayTF,
                    position: "top"
                },
                datalabels: {
                    color: "#fff",
                    textStrokeColor: '#000',
                    textStrokeWidth: 4,
                    font: {
                        size: 12,
                        weight: "normal"
                    },
                    anchor: "end",
                    align: "end",
                    offset: 1
                }
            }
        }
    });
}

function generateGraph(metric, order) {
    // clear for next metric
    const titleArray = [];
    const valueArray = [];
    var sortedArray = [];

    var metricColour = getMetricColour(metric);
    var metricBorder = getBorderColour(metric);

    if (order == "Asc") {
        sortedArray = ficList.toSorted((a, b) => parseFloat(a[metric]) - parseFloat(b[metric]));
    }
    else { // "dsc"
        sortedArray = ficList.toSorted((a, b) => parseFloat(b[metric]) - parseFloat(a[metric]));
    }

    if (displayZeroMetrics) { // display fics with 0 values
        for (let i = 0; i < maxFicsPerGraph; i++) {
            if (sortedArray[i] != null) {
                titleArray.push(shortenTitle(sortedArray[i].title));
                valueArray.push(sortedArray[i][metric]);
            }
            else {
                break;
            }
        }
    }
    else {
        for (let i = 0; i < maxFicsPerGraph; i++) {
            if (sortedArray[i] != null && sortedArray[i][metric] != 0) {
                titleArray.push(shortenTitle(sortedArray[i].title));
                valueArray.push(sortedArray[i][metric]);
            }
            else if (sortedArray[i] == null) {
                break;
            }
        }
    }

    const graphID = metric + order; // hitsDsc
    const insertCode = '<canvas id="' + graphID + '"></canvas>'; // hitsDsc
    $("#metricGraph").append(insertCode); // CHANGED FROM #metric to #metricGraph

    var data = [{
        label: metric,
        backgroundColor: metricColour,
        borderColor: metricBorder,
        borderWidth: 2,
        data: valueArray
    }];
    displayGraph(graphID, "bar", titleArray, data, metric, false);
}


function getMetricColour(metric) {
    switch(metric) {
        case "hits": return coloursArray[0];
        case "kudos": return coloursArray[1];
        case "bookmarks": return coloursArray[2];
        case "comments": return coloursArray[3];
        case "subs": return coloursArray[4];
        case "words": return coloursArray[5];
    }
}

function getBorderColour(metric) {
    switch(metric) {
        case "hits": return bordersArray[0];
        case "kudos": return bordersArray[1];
        case "bookmarks": return bordersArray[2];
        case "comments": return bordersArray[3];
        case "subs": return bordersArray[4];
        case "words": return bordersArray[5];
    }
}

function shortenTitle(title) {
    if (title.length > maxTitleCharacters) {
        return title.slice(0, maxTitleCharacters) + "...";
    }
    return title;
}

// gotta be a way for this to be shorter
function getPieMetricColour(array) {
    var pieArrayMetric = [];

    // get key
    const obj = array[0];
    const entries = Object.entries(obj);
    const [firstKey, firstValue] = entries[0];

    let red = "#f77078";
    let orange = "#eda278";
    let yellow = "#faff66";
    let green = "#92d86e";
    let blue = "#6e92d8";
    let purple = "#9679e1";
    let pink = "#dd75a6";
    let black = "#363636";
    let white = "#dedede";

    if (firstKey == "rating") {
        for (let i = 0; i < array.length; i++) {
            switch(array[i].rating) { // array[i] refers to the object // get first object in array and look at first value
                case "General Audiences": pieArrayMetric.push(green); break;
                case "Teen And Up Audiences": pieArrayMetric.push(yellow); break;
                case "Mature": pieArrayMetric.push(orange);break;
                case "Explicit": pieArrayMetric.push(red);break;
                case "Not Rated": pieArrayMetric.push(white); break;
            }
        }
    }
    else if (firstKey == "category") {
        for (let i = 0; i < array.length; i++) {
            switch(array[i].category) {
                case "M/M": pieArrayMetric.push(blue); break;
                case "F/M": pieArrayMetric.push(purple); break;
                case "F/F": pieArrayMetric.push(pink); break;
                case "Multi": pieArrayMetric.push(yellow); break;
                case "Other": pieArrayMetric.push(black); break;
                case "Gen": pieArrayMetric.push(green); break;
                case "No Category Given": pieArrayMetric.push(white); break; // might break, might not, who knows
            }
        }
    }
    else if (firstKey == "warning") {
        for (let i = 0; i < array.length; i++) {
            switch(array[i].warning) {
                case "No Archive Warnings Apply": pieArrayMetric.push(green); break;
                case "Graphic Depictions Of Violence": pieArrayMetric.push(red); break;
                case "Creator Chose Not To Use Archive Warnings": pieArrayMetric.push(orange); break;
                case "Rape/Non-Con": pieArrayMetric.push(purple); break;
                case "Underage": pieArrayMetric.push(blue); break;
                case "Major Character Death": pieArrayMetric.push(black); break;
            }
        }
    }
    else {
        console.log("invalid key idk how you got here man");
    }

    return pieArrayMetric;
}

function removeAndFlipGraph(newMetric) {
    var graph = document.querySelector("#metricGraph canvas");
    var includesMetric = graph.id.includes(newMetric);
    var includesAsc = graph.id.includes("Asc");

    if ((includesMetric && includesAsc) || (!includesMetric && !includesAsc)) {
        graph.remove();
        generateGraph(newMetric, "Dsc");
    }
    else if ((includesMetric && !includesAsc) || !includesMetric && includesAsc) {
        graph.remove();
        generateGraph(newMetric, "Asc");
    }
}

const styles =
`#summary {
    border: 3px solid;
    margin-bottom: 1em;
    margin-right: 1em;
    display: inline-block;
}

#summary dl {
    display: grid;
    padding: 0.5em;
    padding-right: 2em;
}

#summary p, #summary dd {
    margin: 0;
}

#summary dd {
    grid-column-start: 2;
    margin-left: 2em;
}

#summary dt {
    grid-column-start: 1;
}

#summary dl p {
    text-decoration-line: underline;
}

#toggleButtons li {
    padding: 0px 5px;
    display: inline;
}

#metric {
    padding: 0px 5px;
    display: inline-block;
}

#metricGraph {
  max-width: 35em;
}

#charts {
    margin-top: 1.5em;
}

#mainSection, #sidePie {
    float: left;
}

#sidePie {
    display: inline-block;
    max-width: 12em;
}

#overall {
    max-width: 60em;
}

@media screen and (min-width: 670px) and (max-width: 1290px) {
    #mainSection {
        float: none;
    }
    #sidePie {
        display: inline;
        float: none;
        max-width: none;
    }
    .pie {
        float: left;
        width: 12em;
    }
}

@media screen and (max-width: 670px) {
    #mainSection {
        float: none;
    }
    #sidePie {
        display: inline-block;
        float: none;
        max-width: none;
    }
    .pie {
        float: left;
        width: 10em;
    }
}`;

const styleSheet = document.createElement("style");
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);