diff --git a/figures/interactive/top_recommendations.html b/figures/interactive/top_recommendations.html index 04583f4..2431654 100644 --- a/figures/interactive/top_recommendations.html +++ b/figures/interactive/top_recommendations.html @@ -11,6 +11,7 @@ overflow: hidden; display: grid; font-family: system-ui, sans-serif; + background-color: white; } h1, h2 { text-align: center; } h2 { @@ -56,6 +57,25 @@ fieldset { border: 0; } +fieldset:has(input[name="period"]) { + font-size: 14px; +} + +label:has(input:checked) { + border-bottom: 3px solid black; +} +fieldset:has(input[name="score_sort"]) { + text-align: center; + font-size: 12px; +} +label:has(input[name="score_sort"]) { + margin-left: 5px; +} +/* hide the actual radio buttons */ +input[type="radio"] { + display: none; + visibility: hidden; +} input#search { margin: 0 10px; @@ -68,18 +88,33 @@ height: 15px; margin: 2px 2px 0; } -.swatch.period1921::before { +.swatch.period1921::before, .swatch.periodboth::before { background-color:#1f77b4; } .swatch.period1924::before { background-color: #ff7f0e; } +.swatch.periodboth { + position: relative; + margin: 0 2px; +} + +.swatch.periodboth::after { + background-color: #ff7f0e; + position: absolute; + top: 1px; + left: 10px; + content: " "; + display: inline-block; + width: 7px; + height: 14px; +} #imf { grid-area: plot1; } #imf .plot-axis svg { - margin-top: 50px; /* adjust for shape legend on cf plot, so plots align */ + margin-top: 10px; /* adjust for shape legend on cf plot, so plots align */ } #cf { grid-area: plot2; @@ -137,23 +172,27 @@ background-color: white; } +.visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} +
-

Top Recommendations

+

Top Recommendations

Subscription period: - - - + + +
@@ -161,6 +200,23 @@

Top Recommendations

Implicit Matrix Factorization Model

+ +
+ Sort by score: + + + +
+
@@ -174,7 +230,8 @@

Collaborative Filtering Methods

let lenskitdata = await d3.csv("lenskit_top_scores.csv"); let lenskitscores = lenskitdata.map(x => x.score); -let lenskitdomain = [Math.min(...lenskitscores), Math.max(...lenskitscores)]; +// add some padding to domain so ticks don't overlap labels +let lenskitdomain = [Math.min(...lenskitscores) - 0.2, Math.max(...lenskitscores) + 0.2]; let cfdata = await d3.csv("memorycf_top_scores.csv"); let cfscores = cfdata.map(x => x.score); let cfdomain = [Math.min(...cfscores), Math.max(...cfscores)]; @@ -183,26 +240,32 @@

Collaborative Filtering Methods

// create a dict to lookup item title by item id const item_label = titles.reduce( (dict, el, index) => ( - // dict[el.id] = `${el.title}, ${el.author}` + (el.year ? ` (${new Number(el.year).toFixed(0)})` : '') + // title on first line; author and optionally (year) on second line dict[el.id] = `${el.title}\n${el.author}` + (el.year ? ` (${new Number(el.year).toFixed(0)})`:'') , dict), {} ); -// sort highest score first -lenskitdata = d3.sort(lenskitdata, (a, b) => d3.descending(a.score, b.score)); +// calculate aggregrate item scores for lenskit sorting options +const lenskitScores = { + median: d3.rollup(lenskitdata, (D) => d3.median(D, (d) => d.score), (d) => d.item_id), + high: d3.rollup(lenskitdata, (D) => d3.max(D, (d) => d.score), (d) => d.item_id), + low: d3.rollup(lenskitdata, (D) => d3.min(D, (d) => d.score), (d) => d.item_id) +}; + cfdata = d3.sort(cfdata, (a, b) => d3.descending(a.score, b.score)); // common plot config settings const plotConfig = { width: 475, + lenskitWidth: 525, marginLeft: 300, }; function plotLenskitAxis() { /* plot axis only so it can be made sticky when actual plot scrolls */ const plot = Plot.plot({ - width: plotConfig.width, + width: plotConfig.lenskitWidth, height: 30, x: { grid: true, @@ -229,25 +292,35 @@

Collaborative Filtering Methods

} -function plotLenskitData(data) { - let item_ids = new Set(data.map((x) => x.item_id)); +const periodColors = [ "#1f77b4","#ff7f0e"]; -// TODO: on first draw, copy top x-axis and add to svg in container -// so top axis can be made sticky - // (or draw empty plot with legend and axis only?) +const colorByPeriod = { + "1921":"#1f77b4", + "1924": "#ff7f0e" +} + +function plotLenskitData(data, colors = periodColors) { + const currentSort = document.querySelector('input[name="score_sort"]:checked').value; + const sortScore = lenskitScores[currentSort]; + + // get set of unique item ids and sort by currently configured sort option + let item_ids = new Array(... new Set(data.map((x) => x.item_id))); + item_ids = new Set(d3.sort(item_ids, (a, b) => { + return d3.descending(sortScore.get(a), sortScore.get(b)); + })); const plot = Plot.plot({ - width: plotConfig.width, + width: plotConfig.lenskitWidth, // set height to make more space between titles height: item_ids.size * 28 + 60, marginTop: 0, // no top margin since axis is plotted separately - color: {type: 'categorical', scheme: "Category10"}, + // color: {type: 'categorical', scheme: "Tableau10"}, + color: {range: colors}, x: { grid: true, label: "Score", labelAnchor: "center", - // draw both top and bottom unless we have very few items - // axis: item_ids.size > 5 ? "both" : "top", + // axis at bottom only since we draw a second fixed top axis axis: "bottom", domain: lenskitdomain, }, @@ -255,11 +328,6 @@

Collaborative Filtering Methods

domain: item_ids, }, marks: [ - // Plot.axisX({ - // // label: "Score", - // // axis: "bottom", - // // labelAnchor: "center", - // }), Plot.axisY({ label: null, // suppress marginLeft: plotConfig.marginLeft, @@ -268,14 +336,12 @@

Collaborative Filtering Methods

tickFormat: (d) => item_label[d], }), Plot.tickX(data, - // Plot.normalizeX({x: "score", y: "item_id", stroke: d => d.subscription_start.startsWith("1921") ? "lightslategray" : "orange" })) Plot.normalizeX({x: "score", y: "item_id", stroke: "period"})) ] }); const div = document.querySelector("#imfplot"); div.replaceChildren(plot) - // can't make axis sticky; but could copy it to another element that is } const measureLabel = { @@ -315,7 +381,7 @@

Collaborative Filtering Methods

document.querySelector("#cfplot-axis").replaceChildren(plot) } -function plotCFData(data) { +function plotCFData(data, colors = periodColors) { let item_ids = new Set(data.map((x) => x.item_id)); // TODO: on first draw, copy top x-axis and add to svg in container @@ -326,7 +392,8 @@

Collaborative Filtering Methods

width: plotConfig.width, height: item_ids.size * 28 + 60, marginTop: 0, // no top margin since axis is plotted separately - color: {type: 'categorical', scheme: "Tableau10"}, + // color: {type: 'categorical', scheme: "Tableau10"}, + color: {range: colors}, // symbol: {legend: true}, // plotted in axis x: { grid: true, @@ -364,11 +431,6 @@

Collaborative Filtering Methods

plotCFAxis(cfdata); // filtering requires removing and redrawing the plot - - -const searchInput = document.getElementById("search"); -const periodInput = document.querySelectorAll("input[name=subscription]"); - function redrawPlot() { let currentLenskit = lenskitdata; let currentCF = cfdata; @@ -382,21 +444,34 @@

Collaborative Filtering Methods

// optionally filter by subscription time period const selectedSubs = document.querySelector('input[name="subscription"]:checked').value; + // define custom color scheme to avoid colors changing when data changes + let currentColors = periodColors; if (selectedSubs != "both") { currentLenskit = currentLenskit.filter((x) => x.period.startsWith(selectedSubs)); currentCF = currentCF.filter((x) => x.period.startsWith(selectedSubs)); + currentColors = new Array(colorByPeriod[selectedSubs]); } - plotLenskitData(currentLenskit); - plotCFData(currentCF); + plotLenskitData(currentLenskit, currentColors); + plotCFData(currentCF, currentColors); } +// bind handlers to redraw when the options are changed +const searchInput = document.getElementById("search"); +const periodInput = document.querySelectorAll("input[name=subscription]"); +const sortInput = document.querySelectorAll("input[name=score_sort]"); + searchInput.addEventListener("input", (event) => { redrawPlot(); }); periodInput.forEach((elem) => elem.addEventListener("change", (event) => { redrawPlot(); })); +sortInput.forEach((elem) => elem.addEventListener("change", (event) => { + // this one only really needs to redraw the lenskit plot... + redrawPlot(); +})); +