- // ==UserScript==
- // @name AO3 Regraph
- // @description Draw the statistics bar chart on your AO3 stats page without using google and with more user options.
- // @namespace AO3 Userscripts
- // @match http*://archiveofourown.org/users/*/stats*
- // @version 1.0
- // @history v1.0 Full bar chart completed, y-axis titled, all spaces auto-calculated, variable font size and graph size introduced. READY 06/05/2024.
- // @history v0.3 Basic bar chart created (minus y-axis) WITH fandom sorting, 20/04/2024.
- // @history v0.2 Data extraction completed 20/04/2024.
- // @history v0.1 Created 16/04/2024, initial testing only.
- // @author Ardil the Traveller
- // @license MIT
- // @grant none
- // ==/UserScript==
-
- // This is how Ardil goes to hell.
- // If you are reading this you are probably less of a noob at scripting than me. I apologise for the suffering you are about to endure.
- // I apparently will only learn any web-centric language by throwing myself wildly at it while screaming. PHP didn't deserve this abuse either.
-
- // The only part of the webpage this script edits is the <div id="stat_chart" class="statistics chart">.
-
- (function () {
- "use strict";
-
- /* Settings */
-
- const barFillColour = "#993333"; // Insert the hex value of your favourite colour here. At last your stats chart can be eye-melting yellow if you want.
- const barTextColour = "#BBAA88"; // Set the text that displays at the bottom of your bar to a colour you can read against your other colour.
- let numberOfBars = 8; // Don't set this above 10 unless you have a mega-wide screen...
-
- // Vertical height settings for the graph. The width is read from the webpage so that it still fits in whatever the original horizontal space on your screen was.
- const chart_ratio = 5; // Height will be width divided by this number. The google value on my screen is about 10. I like 5 or so, personally.
- // A value of 1 will make the chart square.
-
- // Font settings for the chart:
- const yaxis_title_size = 16; // font size in px for the Y-axis title.
- const yaxis_title_font = "sans-serif"; // change the text inside the quotes if you want another font for the Y-axis title.
- const data_labels_size = 12; // font size in px for the various data labels.
- const data_labels_font = "sans-serif"; // change the text inside the quotes if you want another font for the data labels.
-
- /* End Settings */
-
- /*---------------------------------------------------------------------------------------------------------------------------------------------*/
-
- /* Function definitions */
- function addFancyChart(statistics, sorting, bar_count) {
- // magic for inserting chart goes here
-
- let plottingdata = []; // I use this to avoid one million switch statements to pick the right data points.
- let plottinglabels = [];
- let yaxis_title = "";
-
- // Choose what to sort on.
- // If we are in flat view, everything is already fine. If we are in fandom view, we will need to sort everything.
-
- if (!sorting.flatView) {
- // More complicated magic for fandom mode...
- // This sorts ascending.
- switch (sorting.sortCol) {
- case "hits" :
- // Sort by hits.
- statistics.sort(function(a, b){return a.hits - b.hits});
- yaxis_title = "Hits";
- break;
- case "kudos.count" :
- // Sort by kudos.
- statistics.sort(function(a, b){return a.kudos - b.kudos});
- yaxis_title = "Kudos";
- break;
- case "comment_thread_count" :
- // Sort by comments.
- statistics.sort(function(a, b){return a.comments - b.comments});
- yaxis_title = "Comments";
- break;
- case "bookmarks.count" :
- // Sort by bookmarks.
- statistics.sort(function(a, b){return a.bookmarks - b.bookmarks});
- yaxis_title = "Bookmarks";
- break;
- case "subscriptions.count" :
- // Sort by subscriptions.
- statistics.sort(function(a, b){return a.subscriptions - b.subscriptions});
- yaxis_title = "Subscriptions";
- break;
- case "word_count" :
- // Sort by word count.
- statistics.sort(function(a, b){return a.words - b.words});
- yaxis_title = "Words";
- break;
- default :
- // What the fuck we should never be here.
- console.log("Sorting type not recognised! Graph aborted.");
- return "OW";
- } // End switch.
- if (sorting.desc) {
- // Descending sort. Reverse the order of everything.
- statistics.reverse();
- }
- }
-
- switch (sorting.sortCol) {
- case "hits" :
- // Sort by hits.
- for (let i = 0; i < statistics.length; i++) {
- plottingdata.push(statistics[i].hits);
- plottinglabels.push(statistics[i].title);
- }
- yaxis_title = "Hits";
- break;
- case "kudos.count" :
- // Sort by kudos.
- for (let i = 0; i < statistics.length; i++) {
- plottingdata.push(statistics[i].kudos);
- plottinglabels.push(statistics[i].title);
- }
- yaxis_title = "Kudos";
- break;
- case "comment_thread_count" :
- // Sort by comments.
- for (let i = 0; i < statistics.length; i++) {
- plottingdata.push(statistics[i].comments);
- plottinglabels.push(statistics[i].title);
- }
- yaxis_title = "Comments";
- break;
- case "bookmarks.count" :
- // Sort by bookmarks.
- for (let i = 0; i < statistics.length; i++) {
- plottingdata.push(statistics[i].bookmarks);
- plottinglabels.push(statistics[i].title);
- }
- yaxis_title = "Bookmarks";
- break;
- case "subscriptions.count" :
- // Sort by subscriptions.
- for (let i = 0; i < statistics.length; i++) {
- plottingdata.push(statistics[i].subscriptions);
- plottinglabels.push(statistics[i].title);
- }
- yaxis_title = "Subscriptions";
- break;
- case "word_count" :
- // Sort by word count.
- for (let i = 0; i < statistics.length; i++) {
- plottingdata.push(statistics[i].words);
- plottinglabels.push(statistics[i].title);
- }
- yaxis_title = "Words";
- break;
- default :
- // What the fuck we should never be here.
- console.log("Sorting type not recognised! Graph aborted.");
- return "OW";
- } // End switch.
-
- // Make a canvas.
- let boundaries = document.getElementById("stat_chart").getBoundingClientRect();
- //console.log("Boundaries of image area: " + JSON.stringify(boundaries));
- let inside_width = document.getElementById("stat_chart").clientWidth;
- //console.log("Client width reports: " + inside_width);
-
- let chart_height = inside_width / chart_ratio; // Make the graph have a constant size ratio.
- chart_height = chart_height.toFixed(0); // And make that be an actual integer number of pixels please.
- document.getElementById("stat_chart").style.height = chart_height + "px";
-
- // Build canvas instructions because I can't put a JS variable in my HTML string can I, that would be silly.
- document.getElementById("stat_chart").innerHTML = '<canvas id="inserted_stats" width="' + inside_width + '" height="' + chart_height + '" style="border:1px solid #AAAAAA;">';
-
- // Set up canvas and brush for drawing.
- const stats_canvas = document.getElementById("inserted_stats");
- const brush = stats_canvas.getContext("2d");
- // Build the font strings.
- const yaxis_title_info = yaxis_title_size + "px " + yaxis_title_font;
- const data_labels_info = data_labels_size + "px " + data_labels_font;
- brush.font = data_labels_info; // Start in data label font as we only ever need the other one once.
-
- // Sort out the bar count so that we don't try to display more bars than there are.
- bar_count = Math.min(bar_count, statistics.length);
- // Dump the data for bars we won't display so that the ymax doesn't look stupid.
- plottingdata = plottingdata.slice(0,bar_count);
- plottinglabels = plottinglabels.slice(0, bar_count);
-
- // Determine the y-axis maximum.
- let ymax = 0;
- if (sorting.desc) {
- // Sorted descending.
- ymax = plottingdata[0];
- } else {
- // Sorted ascending.
- ymax = plottingdata[plottingdata.length - 1];
- }
-
- // There should be space for the left axis. Subtract that off first. We can work out the space needed using the width of the longest bit of text, i.e. max y
- const left_axis_space = Math.ceil(brush.measureText(ymax).width) + yaxis_title_size + 10; // Allows a few px either side of the labels.
- const bottom_axis_space = data_labels_size + 4; // Allows a couple of px either side of the labels.
- const top_space = 10;
- const plot_width = inside_width - left_axis_space;
- const plot_height = chart_height - bottom_axis_space - top_space;
- //console.log("Plot height is now: " + plot_height + " px, and full chart height is " + chart_height + " px.");
-
- // There are bar_count columns and bar_count+1 padding spaces. Padding space should be, let's say, 1/4 the width of a column.
- // So there must be 4*bar_count + (bar_count + 1) units to divide the available space into.
- const pad_space = plot_width / (5 * bar_count + 1);
- const col_width = pad_space * 4;
-
- // Determine the y-axis scale, using shitty rounding for approximately evenly spaced non-decimal labels.
- ymax = ymax.toExponential(1);
- let the_exponent = ymax.slice(3);
- ymax = parseFloat(ymax.slice(0,3)) + 0.1;
- ymax = ymax.toFixed(1) + the_exponent;
- ymax = parseFloat(ymax);
- let ystep = parseInt(ymax / 4);
-
- // Draw y-axis values and major lines.
- const ticklen = 5;
- const tickgap = parseInt(plot_height / 4);
- brush.strokeStyle = "#888899"
- brush.lineWidth = 1;
- brush.beginPath();
- brush.moveTo(left_axis_space - ticklen, top_space);
- brush.lineTo(inside_width, top_space);
- brush.stroke();
- brush.beginPath();
- brush.moveTo(left_axis_space - ticklen, top_space + tickgap);
- brush.lineTo(inside_width, top_space + tickgap);
- brush.stroke();
- brush.beginPath();
- brush.moveTo(left_axis_space - ticklen, top_space + 2 * tickgap);
- brush.lineTo(inside_width, top_space + 2 * tickgap);
- brush.stroke();
- brush.beginPath();
- brush.moveTo(left_axis_space - ticklen, top_space + 3 * tickgap);
- brush.lineTo(inside_width, top_space + 3 * tickgap);
- brush.stroke();
-
- // Draw axes. Done second to avoid axes being cut by lines.
- brush.strokeStyle = "black";
- brush.beginPath();
- brush.moveTo(left_axis_space, 0);
- brush.lineTo(left_axis_space, plot_height + top_space);
- brush.lineTo(inside_width, plot_height + top_space);
- brush.stroke();
- // Y-axis labels.
- brush.textAlign = "end"
- brush.textBaseline = "middle"
- brush.fillText(ymax, left_axis_space - ticklen, top_space);
- // console.log("measure text " + brush.measureText(ymax).width + "left space" + left_axis_space);
- brush.fillText(ymax - ystep, left_axis_space - ticklen, top_space + tickgap);
- brush.fillText(ymax - 2*ystep, left_axis_space - ticklen, top_space + 2*tickgap);
- brush.fillText(ymax - 3*ystep, left_axis_space - ticklen, top_space + 3*tickgap);
- brush.fillText(0, left_axis_space - ticklen, top_space + plot_height);
- // Y-axis text involves rotating, which is complicated as it rotates as if it were THE WHOLE PICTURE. Is there a saner way to do this???
- brush.rotate(-90 * Math.PI / 180); //(0,0) now in top right corner, so the y-coord is our x value and the x-coord is our -y.
- brush.textAlign = "center";
- brush.textBaseline = "top";
- brush.font = yaxis_title_info;
- brush.fillText(yaxis_title, - (top_space + 2*tickgap), 2);
- brush.font = data_labels_info;
- brush.rotate(90 * Math.PI / 180); // Put it back to draw the rest!
-
- // Draw bars and x-axis labels.
- brush.strokeStyle = "black";
- brush.lineWidth = 1;
- brush.textAlign = "center";
- for (let i = 0; i < bar_count; i++) {
- let bar_height = plot_height * (plottingdata[i] / ymax);
- let bar_top = top_space + plot_height - bar_height;
- brush.fillStyle = barFillColour;
- brush.beginPath();
- brush.fillRect(left_axis_space + pad_space + (i * (col_width + pad_space)), bar_top, col_width, bar_height);
- brush.strokeRect(left_axis_space + pad_space + (i * (col_width + pad_space)), bar_top, col_width, bar_height);
-
- // Magic number "+/- 2" is an offset to get the text away from the X-axis.
- brush.fillStyle = "black";
- brush.textBaseline = "top";
- brush.fillText(plottinglabels[i], left_axis_space + pad_space + (i * (col_width + pad_space)) + col_width/2, top_space + plot_height + 2);
-
- brush.fillStyle = barTextColour;
- brush.textBaseline = "bottom";
- brush.fillText(plottingdata[i], left_axis_space + pad_space + (i * (col_width + pad_space)) + col_width/2, top_space + plot_height - 2);
- }
-
- // this is shit magic for inserting text instead as a test
- // document.getElementById("stat_chart").innerHTML = "<p>I WILL BE PRETTY ONE DAY</p>";
- } //End of addFancyChart function.
-
- function getViewType(statspage_href) {
- // Magic for finding out what kind of view we are displaying.
- // Needs to return Fandom/Flat, Sort Column, and Asc/Desc.
- // All of these have defaults: Fandom, Hits, and Desc.
- let view_description = {flatView : false, sortCol : "hits", desc : true};
-
- // Analyse the input web address for flat view: if we're using flat view, the string "flat_view=true" will appear somewhere.
- if (statspage_href.search("flat_view=true") != -1) {
- view_description.flatView = true;
- }
-
- // Analyse the input web address for sort ascending / descending:
- if (statspage_href.search("sort_direction=ASC") != -1) {
- view_description.desc = false;
- }
-
- // Analyse the input web address for the sort column. This is the tricky one.
- let iscolsorted = statspage_href.search("sort_column=");
- if (iscolsorted != -1) {
- // A sort column has been chosen. Dice the string to find out which one.
- // First, dispose of everything up to and including "sort_column=".
- let sort_type = statspage_href.substring(iscolsorted + 12);
- let nextampersand = sort_type.search("&");
- sort_type = sort_type.substring(0, nextampersand);
- if (sort_type.length > 0) {
- view_description.sortCol = sort_type;
- }
- }
-
- return view_description;
- } // End of getViewType function.
-
- function getStats(sorting_type) {
- // Magic for getting the stats out of the stats page.
- // Might have to rewrite this if AO3 ever change the stats page layout.
-
- let all_stories = document.getElementsByClassName("fandom listbox group");
- //console.log("All stories object is: " + all_stories + ", a " + typeof(all_stories) + " with length: " + all_stories.length);
- //console.log("All stories element 0 is: " + all_stories[0] + ", a " + typeof(all_stories[0]));
- //console.log("Contents of all stories [0]: " + all_stories[0].innerHTML);
-
- const stories = []; // Create a stories array to store our stories. We'll return this at the end.
-
- if (sorting_type.flatView) {
- // In this case, all_stories will have length 1, as there is only one listbox group.
-
- // Get all the list elements. There should be one of these per story, each one containing one set of story tat.
- let story_list = all_stories[0].getElementsByTagName("li");
- let story_count = story_list.length;
- // I can use this to work out where the subscriptions are, and anything else that might or might not exist.
- // Involves too much string munging for my liking for me to do it that way for everything.
-
- // To get the fic names, find all of the <a href>s - these should only contain the names (and fic IDs if I get what's in the href).
- let extract_titles = all_stories[0].getElementsByTagName("a");
- //console.log("Extracted title 0: " + extract_titles[0].innerText);
-
- // Get the fandoms, conveniently in class "fandom".
- let extract_fandoms = all_stories[0].getElementsByClassName("fandom");
- //console.log("Extracted fandom 0: " + extract_fandoms[0].innerText);
-
- // Get the wordcounts, class "words".
- let extract_wordcounts = all_stories[0].getElementsByClassName("words");
- //console.log("Extracted wordcount 0: " + extract_wordcounts[0].innerText);
-
- // Get the hits. Hits text is in dt.hits, number is in dd.hits.
- let extract_hits = all_stories[0].querySelectorAll("dd.hits");
- //console.log("Extracted hits 0: " + extract_hits[0].innerText);
-
- // Get the kudos. Kudos text is in dt.kudos, number is in dd.kudos.
- let extract_kudos = all_stories[0].querySelectorAll("dd.kudos");
- //console.log("Extracted kudos 0: " + extract_kudos[0].innerText);
-
- // Get the bookmarks. Bookmarks text is in dt.bookmarks, number is in dd.bookmarks.
- let extract_bookmarks = all_stories[0].querySelectorAll("dd.bookmarks");
- //console.log("Extracted bookmarks 0: " + extract_bookmarks[0].innerText);
-
- // Get the comments. Comments text is in dt.comments, number is in dd.comments.
- let extract_comments = all_stories[0].querySelectorAll("dd.comments");
- //console.log("Extracted comments 0: " + extract_comments[0].innerText);
-
- // Process and clean the various numerical strings to get numbers. Pop each piece of data into an object. Array of objects. Thing.
- for (let i = 0; i < story_count; i++) {
- // Cut wordcount down to numbers only. Format is "(X words)" where X is the number, so we remove [0] and everything after [-7].
- let wordcount = extract_wordcounts[i].innerText;
- wordcount = wordcount.slice(1, wordcount.length-7);
- // Sort out subscriptions where they exist. They don't always exist, so be careful.
- let subs = 0;
- let subs_exist = story_list[i].innerText.search("Subscriptions: ");
- if (subs_exist != -1) {
- subs = story_list[i].innerText.slice(subs_exist+15);
- subs = subs.slice(0, subs.search(/\s/));
- subs = parseInt(subs.replace(/,/g, ""));
- }
-
- // Create a new story object and add it to our stories array.
- let the_story = {
- title : extract_titles[i].innerText,
- fandoms : extract_fandoms[i].innerText.slice(1,extract_fandoms[i].innerText.length-1),
- words : parseInt(wordcount.replace(/,/g, "")),
- hits : parseInt(extract_hits[i].innerText.replace(/,/g, "")),
- kudos : parseInt(extract_kudos[i].innerText.replace(/,/g, "")),
- bookmarks : parseInt(extract_bookmarks[i].innerText.replace(/,/g, "")),
- comments : parseInt(extract_comments[i].innerText.replace(/,/g, "")),
- subscriptions : subs
- };
- stories.push(the_story);
- }
- // And we're done with flat view!
-
- } else {
- //console.log("Fandom View Detected.");
-
- let fandom_count = all_stories.length;
- //console.log("Assessing stories for " + fandom_count + " fandoms.");
-
- let titles_list = [];
- for (let i = 0; i < fandom_count; i++) {
- let fandom_name = all_stories[i].getElementsByClassName("heading");
- let fandom = fandom_name[0].innerText;
- let story_list = all_stories[i].getElementsByTagName("li");
- let story_count = story_list.length;
-
- // To get the fic names, find all of the <a href>s - these should only contain the names (and fic IDs if I get what's in the href).
- let extract_titles = all_stories[i].getElementsByTagName("a");
- //console.log("Extracted title 0: " + extract_titles[0].innerText);
-
- // Get the wordcounts, class "words".
- let extract_wordcounts = all_stories[i].getElementsByClassName("words");
- //console.log("Extracted wordcount 0: " + extract_wordcounts[0].innerText);
-
- // Get the hits. Hits text is in dt.hits, number is in dd.hits.
- let extract_hits = all_stories[i].querySelectorAll("dd.hits");
- //console.log("Extracted hits 0: " + extract_hits[0].innerText);
-
- // Get the kudos. Kudos text is in dt.kudos, number is in dd.kudos.
- let extract_kudos = all_stories[i].querySelectorAll("dd.kudos");
- //console.log("Extracted kudos 0: " + extract_kudos[0].innerText);
-
- // Get the bookmarks. Bookmarks text is in dt.bookmarks, number is in dd.bookmarks.
- let extract_bookmarks = all_stories[i].querySelectorAll("dd.bookmarks");
- //console.log("Extracted bookmarks 0: " + extract_bookmarks[0].innerText);
-
- // Get the comments. Comments text is in dt.comments, number is in dd.comments.
- let extract_comments = all_stories[i].querySelectorAll("dd.comments");
- //console.log("Extracted comments 0: " + extract_comments[0].innerText);
- for (let j = 0; j < story_count; j++) {
- let title_location = titles_list.indexOf(extract_titles[j].innerText);
- if ( title_location == -1 ) {
- // No story by this title is yet recorded. Fill in the story object.
- // Cut wordcount down to numbers only. Format is "(X words)" where X is the number, so we remove [0] and everything after [-7].
- let wordcount = extract_wordcounts[j].innerText;
- wordcount = wordcount.slice(1, wordcount.length-7);
- // Find out if subscriptions are real.
- let subs = 0;
- let subs_exist = story_list[j].innerText.search("Subscriptions: ");
- if (subs_exist != -1) {
- subs = story_list[j].innerText.slice(subs_exist+15);
- subs = subs.slice(0, subs.search(/\s/));
- subs = parseInt(subs.replace(/,/g, ""));
- }
- let the_story = {
- title : extract_titles[j].innerText,
- fandoms : fandom,
- words : parseInt(wordcount.replace(/,/g, "")),
- hits : parseInt(extract_hits[j].innerText.replace(/,/g, "")),
- kudos : parseInt(extract_kudos[j].innerText.replace(/,/g, "")),
- bookmarks : parseInt(extract_bookmarks[j].innerText.replace(/,/g, "")),
- comments : parseInt(extract_comments[j].innerText.replace(/,/g, "")),
- subscriptions : subs
- }
- // Add the story to the story array.
- stories.push(the_story);
- // Add processed title to the titles list.
- titles_list.push(extract_titles[j].innerText);
- } else {
- // We already have a story by this title. Find it and add a fandom to it.
- // The story should be in the same index in stories as the title is in titles_list.
- stories[title_location].fandoms = stories[title_location].fandoms + ", " + fandom;
- }
- }
- }
- }
- // And we're done with fandom view!.
- return stories;
- }
- /* End function definitions */
-
- /* Main Code
- * Super awesome final magic that makes it all work happens here */
-
- // Figure out how we are displaying and sorting our data.
- let view_sort = getViewType(window.location.href);
-
- //Test if it worked:
- //console.log("View instructions: " + JSON.stringify(view_sort));
-
- let story_stats = getStats(view_sort);
- // Test that worked.
- //console.log("Stats exist for " + story_stats.length + " stories.");
- //console.log("First story: " + JSON.stringify(story_stats[0]));
- //console.log("Last story: " + JSON.stringify(story_stats[story_stats.length-1]));
-
- addFancyChart(story_stats, view_sort, numberOfBars); // HOLY SHIT IT WORKED
- })();