// ==UserScript==
// @name Last.fm Original Tag Chart
// @namespace http://thlayli.detrave.net
// @require https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js
// @require http://code.jquery.com/jquery-1.9.0.min.js
// @require https://gf.qytechs.cn/scripts/383527-wait-for-key-elements/code/Wait_for_key_elements.js?version=701631
// @require https://cdn.jsdelivr.net/npm/[email protected]/src/jquery.address.min.js
// @description restores legacy "subway" style tag chart on new last.fm report pages
// @include https://www.last.fm/user/*
// @version 1.3
// @license MIT
// @grant none
// ==/UserScript==
String.prototype.toProperCase = function () {
return this.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
};
function main_func(){
// don't run on old style pages
if($("section[data-require='stats/top-tags-v2']").length == 0){
// Create a div to put the chart into
$('<div class="listening-report-row"><div class="report-box-container report-box-container--charts report-box-container--top-tags-over-time"><div class="report-box-header"><h3 class="report-box-title">Classic tag timeline</h3></div><div class="report-box-content legacy-tag-chart" style="text-align: center;"></div></div></div>').insertBefore($('.listening-report-row--2-wide').first());
// get the tag data from the html table
d3.selection.prototype.mapNested = function (f) {
var arr = d3.range(this.size()).map(function () { return []; });
this.each(function (d, i) { arr[i].push(f.call(this, d, i)); });
return arr;
};
var tagData = d3.select(".js-top-tags-over-time-table").selectAll("thead,tbody").selectAll("tr").selectAll("th,td");
var vals = tagData.mapNested(function(d, i, j){ return d3.select(this).text().replace(/\n\s+/g,"") }).filter(v => (v.length > 0));
// set chart size
var scale = (vals.length > 7) ? 2 : 1;
// scale lines slightly less than layout
var lineScale = (vals.length > 7) ? 1.5 : 1;
var margin = 25*scale;
var chartHeight = 350*scale;
var chartWidth = (vals.length > 7) ? 2000 : 850;
// override some styles
$('head').append("<style>.legacy-tag-chart svg {paint-order: stroke;} .legacy-tag-chart .tick text {fill: #a7acb7; stroke: none} .legacy-tag-chart .report-box-container--top-tags-over-time { fill: none; } text.lc-tag.lc-tag-white {fill: white; stroke: black} .lc-no-dot {display: none;} .legacy-tag-chart svg {width: 100%;}</style>");
// set styles for week/month and year layouts
if(vals.length > 7)
$('head').append("<style>.axis-circles .tick text {fill: #313131; font-size: 64px;} .legacy-tag-chart .y-axis {font-size: 36px; font-weight: bold} .legacy-tag-chart .x-axis {font-size: 22px; font-weight: normal}</style>");
else
$('head').append("<style>.axis-circles .tick text {fill: #313131; font-size: 48px;} .legacy-tag-chart .y-axis {font-size: 20px; font-weight: bold} .legacy-tag-chart .x-axis {font-size: 11px; font-weight: normal}</style>");
var tags = vals[0].slice(1);
valsByTag = d3.transpose(vals);
var dates = valsByTag[0].map(v => v);
dates[0] = '';
// collect top 5 tags for each week
var weekData = vals.map(r => r.slice(1).map(function(element, index, array){ return {tag: tags[index], plays: element }}).sort(function(a, b){ return parseInt(b.plays) - parseInt(a.plays); }).slice(0,5));
weekData.shift();
// find first appearance of tag for dots
var firstTag = []
tags.map(function(element, index, array){
var weekSummary = weekData.map(w => w.map(x => (x.tag == element) ? 1 : 0));
var weekBoolean = weekSummary.map(w => (w.indexOf(1) != -1) ? 1 : 0);
firstTag[element] = weekBoolean.indexOf(1);
});
// build d3 data object
var d3data = [];
weekData.map(function(element1, index1, array1){ element1.map(function(element2, index2, array2){ d3data.push({ date: index1+1, rank: index2+1, tag: element2.tag, dot: (firstTag[element2.tag] == index1) }) })});
// svg scalers
const x = d3.scaleLinear().domain([0, 6*scale]).range([margin - 40, chartWidth - margin - 60])
const y = d3.scaleLinear().domain([7, 1]).range([chartHeight - margin + 40, 10 + margin + ((scale ==2) ? 150 : 0)])
var svgContainer = d3.select(".legacy-tag-chart").append("svg")
.attr("preserveAspectRatio", "xMinYMin meet")
.attr("viewBox", "0 0 "+chartWidth+" " +chartHeight);
var g = svgContainer.append("svg:g");
// x axis
var xaxis = d3.axisBottom(x)
.ticks(vals.length-1)
.tickFormat(function (d) {
return dates[d];
});
svgContainer.append("g")
.attr("fill","white")
.attr("stroke","white")
.attr("ticks",vals.length-1)
.attr("class", "x-axis")
.attr("stroke-width","0.5")
.attr("transform", "translate("+(25*lineScale)+"," + (chartHeight - margin ) + ")")
.call(xaxis);
// y axis
var yaxis = d3.axisLeft(y)
.ticks(6)
.tickFormat(function (d) {
return (d < 6) ? d : '';
});
// y axis circles
var yaxisc = d3.axisLeft(y)
.ticks(6)
.tickFormat(function (d) {
return (d < 6) ? "⬤" : '';
});
// draw y axis circles
svgContainer.append("g")
.attr("transform","translate(" + (((scale == 2) ? 38 : 25) + margin) + ",0)")
.call(yaxisc)
.attr("class", "axis-circles")
.append("text")
.attr("y", 6)
.attr("dy", 0.6*scale+"em");
// draw y axis text
svgContainer.append("g")
.attr("fill","white")
.attr("stroke","none")
.attr("class", "y-axis")
.attr("transform","translate(" + (10*scale + margin) + ",0)")
.call(yaxis)
.attr("r", 12)
.append("text")
.attr("y", 6)
.attr("transform","translate("+-10*scale+", 0)")
.attr("dy", (0.71*scale)+"em")
.style("text-anchor", "end");
// delete axis lines
d3.selectAll(".domain,.tick>line").remove();
// draws circles (first tag appearance only)
g.selectAll("tag-nodes")
.data(d3data)
.enter()
.append("svg:circle")
.attr("cx", function(d) { return x(d.date); })
.attr("cy", function(d) { return y(d.rank); })
.attr("r", 8*lineScale)
.attr("class", function(d) { return ((d.dot) ? "" : "lc-no-dot ") + "lc-tag lc-tag-" + tags.indexOf(d.tag) + " top-tags-over-time-colour-" + tags.indexOf(d.tag) % 12})
.append("svg:title")
.text(function(d){ return d.tag.toProperCase(); });
// draws lines
g.selectAll("tag-lines")
.data(d3data)
.enter()
.append('line')
.attr('x1', function(d) { return x(d.date); })
.attr('y1', function(d) { return y(d.rank); })
.attr('x2', function(d) { return x(d.date) + ((scale == 2) ? 70 : 50); })
.attr('y2', function(d) { return y(d.rank); })
.attr("class", function(d) { return "lc-tag lc-tag-" + tags.indexOf(d.tag) + " top-tags-over-time-colour-" + tags.indexOf(d.tag) % 12})
.style("stroke-width", 5*lineScale)
.append("svg:title")
.text(function(d){ return d.tag.toProperCase(); });
// calculate links and draw curves
// provide source column, source row, destination row
function curveMaker(xsrc,xdest,ysrc,ydest){
return [{ x: x(xsrc)+((scale == 2) ? 69 : 49), y: y(ysrc)},
{ x: x(xsrc)+((scale == 2) ? 94 : 64), y: y(ysrc)},
{ x: x(xdest)-15, y: y(ydest)},
{ x: x(xdest)+1, y: y(ydest)}];
}
var curve = d3.line(i)
.x((d) => d.x)
.y((d) => d.y)
.curve(d3.curveBasis);
// iterate over tags
jQuery.each(d3data, function(i,d) {
// check to see if tag appears again
var nextWeekToAppear = 0;
for(n=i+1; n<d3data.length; n++){
if(d3data[n].tag == d.tag){
nextWeekToAppear = d3data[n].date;
nextRow = d3data[n].rank;
break;
}
}
// draw curves to tags that appear in the next week
if(nextWeekToAppear == d.date+1){
// check for the tag in the next date set
if(d.date < vals.length-1 && weekData[d.date]){
var weeklyTags = weekData[d.date].map(r => r.tag);
var row = (weeklyTags.indexOf(d.tag) < 0) ? 6 : weeklyTags.indexOf(d.tag) + 1;
svgContainer.select("g")
.append("path")
.attr("d", curve(curveMaker(d.date,d.date+1,d.rank,row)))
.attr("fill", "none")
.attr("stroke", "white")
.style("stroke-width", 5*lineScale)
.attr("class", function(e) { return "lc-tag lc-tag-" + tags.indexOf(d.tag) + " report-box-container--top-tags-over-time top-tags-over-time-colour-" + tags.indexOf(d.tag) % 12});
}
}
// draw a curve to row 6 for artists who don'tappear next week
if(nextWeekToAppear > d.date+1){
svgContainer.select("g")
.append("path")
.attr("d", curve(curveMaker(d.date,d.date+1,d.rank,6)))
.attr("fill", "none")
.attr("opacity", 0.5)
.attr("stroke", "white")
.style("stroke-width", 5*lineScale)
.attr("class", function(e) { return "lc-dim lc-tag lc-tag-" + tags.indexOf(d.tag) + " report-box-container--top-tags-over-time top-tags-over-time-colour-" + tags.indexOf(d.tag) % 12})
.append("svg:title")
.text(function(e){ return d.tag.toProperCase(); });
}
if(nextWeekToAppear > 2 && 1 < d.date < 5 && d.date < nextWeekToAppear-1){
// draw a line on row 6 from the next week to nextWeekToAppear-1
var scaleDifference = -0.07;
svgContainer.select("g")
.append("path")
.attr("d", curve(curveMaker(parseInt(d.date)+0.63+((scale==2) ? scaleDifference : 0), parseInt(nextWeekToAppear)-0.63-((scale==2) ? scaleDifference : 0), 6, 6)))
.attr("fill", "none")
.attr("stroke", "white")
.attr("opacity", 0.5)
.style("stroke-width", 5*lineScale)
.attr("class", function(e) { return "lc-dim lc-tag lc-tag-" + tags.indexOf(d.tag) + " report-box-container--top-tags-over-time top-tags-over-time-colour-" + tags.indexOf(d.tag) % 12})
.append("svg:title")
.text(function(e){ return d.tag.toProperCase(); });
}
// add curves (behind) from row 6 for returning tags
if(d.date > 2){
var lastWeek = weekData[d.date-2].map(r => r.tag);
var found = lastWeek.indexOf(d.tag);
// if no dot and not in last week, draw curve from row 6
if(d.dot == false && found == -1){
svgContainer.select("g")
.append("path")
.attr("d", curve(curveMaker(d.date-1,d.date,6,d.rank)))
.attr("fill", "none")
.attr("stroke", "white")
.attr("opacity", 0.5)
.style("stroke-width", 5*lineScale)
.attr("class", function(e) { return "lc-dim lc-tag lc-tag-" + tags.indexOf(d.tag) + " report-box-container--top-tags-over-time top-tags-over-time-colour-" + tags.indexOf(d.tag) % 12})
.append("svg:title")
.text(function(e){ return d.tag.toProperCase(); });
}
}
});
// add tag labels (first time only)
g.selectAll("text")
.data(d3data)
.enter()
.append('text')
.attr('x', function(d) { return x(d.date); })
.attr('y', function(d) { return y(d.rank) - 20*lineScale; })
.attr("fill", "white")
.attr("stroke","black")
.attr("font-size", ((scale == 2) ? 20 : 12))
.attr("font-family", "sans-serif")
.attr("class", function(d) { return ((d.dot) ? "" : "lc-no-dot ") + "lc-tag lc-tag-white lc-tag-" + tags.indexOf(d.tag) + " report-box-container--top-tags-over-time top-tags-over-time-colour-" + tags.indexOf(d.tag) % 12})
.attr("text-anchor", "middle")
.text((d) => (d.tag.length > 11) ? d.tag.slice(0,11).toProperCase() + "..." : d.tag.toProperCase());
// set up mouseover event listeners
tags.forEach(function(t) {
g.selectAll(".lc-tag-"+tags.indexOf(t))
.on('mouseover', function (d, i) {
// dim all tags
d3.selectAll(".lc-tag").transition().duration('100').attr('opacity', '.25');
// undim selected
d3.selectAll(".lc-tag-"+tags.indexOf(t)).transition().duration('100').attr('opacity', '1');
// dim selected lc-dim tags to 50%
d3.selectAll(".lc-tag-"+tags.indexOf(t)).filter(function() { return this.classList.contains('lc-dim') }).transition().duration('100').attr('opacity', '0.5');
})
.on('mouseout', function (d, i) {
setTimeout(() => {
// undim all tags
d3.selectAll(".lc-tag").transition().duration('100').attr('opacity', '1');
// dim lc-dim tags to 50%
d3.selectAll(".lc-dim").transition().duration('100').attr('opacity', '0.5');
}, "50");
});
});
}
}
function repair_func(){
// old style page - fix duplicate label glitch
setTimeout(() => {
var extraLabels = d3.selectAll(".highcharts-label text").filter(function() { return d3.select(this).attr("x") == 0 });
console.log("Last.fm Original Tag Chart removed "+extraLabels.size()+" extra labels from the native chart :)");
extraLabels.remove();
}, "2000");
}
var fireOnHashChangesToo = true;
var pageURLCheckTimer = setInterval (
function () {
if ( this.lastPathStr !== location.pathname
|| this.lastQueryStr !== location.search
|| (fireOnHashChangesToo && this.lastHashStr !== location.hash)
) {
this.lastPathStr = location.pathname;
this.lastQueryStr = location.search;
this.lastHashStr = location.hash;
//var ran_once = false;
waitForKeyElements(".js-top-tags-over-time-target > .highcharts-container", main_func);
}
}
, 111
);
waitForKeyElements(".js-top-tags-over-time-target > .highcharts-container", main_func);
waitForKeyElements(".tube-tags-graph svg.highcharts-root", repair_func);