Published
Edited
Oct 8, 2021
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
base_graph_fun = function(
card_data,
group_data,
label_thresold = text_thresold,
in_graph_caption = graph_caption
) {
let ydomain = card_data.map(d => d.card);

if (group_data.length > 0) {
ydomain = aq
.from(group_data)
.unroll('cards')
.select('cards')
.objects()
.map(d => d.cards);
}

const xdomain = [
d3.min(card_data, d =>
d3.min([d.playable_wr - .03, d.not_playable_wr - .03, .4])
),
.65
];

const graph = Plot.plot({
marginLeft: 150,
//marginTop: 75,
height: 100 + 15 * card_data.length,
grid: true,
color: {
domain: colordomain,
range: colorpallet
},
y: {
domain: ydomain,
label: null
},
x: {
label: 'Win rate %',
tickFormat: '%',
axis: 'top',
domain: xdomain
},
marks: [
// First the average WR of each color
Plot.ruleX(group_data, {
height: 100,
x: 'total_wr',
y1: d => d.cards[0],
y2: d => d.cards[d.cards.length - 1],
strokeOpacity: 0.25,
strokeWidth: 8,
stroke: d => d[d.grouping],
title: group_tooltip_fun
}),
Plot.dot(group_data, {
// For groups that only have one card
x: 'total_wr',
y: d => d.cards[0],
r: 3,
fillOpacity: 0.25,
fill: d => d[d.grouping],
title: group_tooltip_fun,
filter: d => d.cards.length == 1
}),
Plot.text(group_data, {
x: 'total_wr',
y: d => d.cards[d.cards.length - 1],
text: d =>
label_dict.has(d[d.grouping])
? label_dict.get(d[d.grouping])
: d[d.grouping],
dy: 11,
fill: d => d[d.grouping]
}),
// Now the WR and WR Lift for each color
Plot.dot(card_data, {
x: 'playable_wr',
y: 'card',
r: 3,
stroke: 'color',
title: card_tooltip_fun
}),
Plot.dot(card_data, {
x: 'not_playable_wr',
y: 'card',
r: 2,
fill: 'color',
title: card_tooltip_fun
}),
Plot.ruleY(card_data, {
x1: 'playable_wr',
x2: 'not_playable_wr',
y: 'card',
strokeOpacity: 0.3,
title: card_tooltip_fun
}),
// The lift text pointer
Plot.text(card_data.filter(d => d.wr_lift > label_thresold / 100), {
x: 'playable_wr',
dx: 7,
fontWeight: 'bold',
text: d => d3.format('+.1%')(d.wr_lift),
fill: 'olivedrab',
textAnchor: 'start',
y: 'card',
title: card_tooltip_fun
}),
Plot.text(card_data.filter(d => d.wr_lift < -label_thresold / 100), {
x: 'playable_wr',
dx: -7,
fontWeight: 'bold',
text: d => d3.format('+.1%')(d.wr_lift),
fill: 'lightcoral',
textAnchor: 'end',
y: 'card',
title: card_tooltip_fun
}),
Plot.dot(card_data, {
x: 'not_playable_wr',
y: 'card',
r: 7,
opacity: 0,
strokeOpacity: 0,
fillOpacity: 0,
title: card_tooltip_fun
}),
Plot.dot(card_data, {
x: 'playable_wr',
y: 'card',
r: 7,
opacity: 0,
strokeOpacity: 0,
fillOpacity: 0,
title: card_tooltip_fun
})
// This part is a background square for the tooltip
// Plot.barX(card_data, {
// x1: d =>
// d.wr_lift < 0.0 ? d.playable_wr - 0.01 : d.not_playable_wr - 0.0,
// x2: d =>
// d.wr_lift > 0.0 ? d.playable_wr + 0.01 : d.not_playable_wr + 0.0,
// y: 'card',
// //r: 10,
// opacity: 0,
// strokeOpacity: 0,
// fillOpacity: 0,
// title: card_tooltip_fun
// })
],
caption: in_graph_caption
});

return addTooltipsv2(graph, {});
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
performance_data = d3.dsv(
";",
"https://gist.githubusercontent.com/ltostes/37e4809507df9d160e15c57e0c3cd12d/raw/1348b544d6bf5261b10e9fa2e57d8c190d21ce7f/MTG_MID_CardsWinrateDimensions.csv",
d3.autoType
)
Insert cell
Insert cell
card_details_source = d3.csv(
'https://docs.google.com/spreadsheets/d/e/2PACX-1vSBmrCPsPFWqLrDRtJ5K2uKFRgd-XuINN1xtONq7Iv-GvHeAIGnaJGapFppP6im2G7pnoLpwstQnHt_/pub?output=csv',
d => ({
card: d.name,
color: d.color,
cmc: d.cmc > 6 ? '7+' : d.cmc,
type: d.simple_type_2,
type_detail: d.type_line,
removal_detail: d.removal,
removal: [
'Diminish',
'Damage',
'Lasting Tap',
'Exile',
'Destroy',
'Punch',
'Invalidate',
'Gain Control'
].includes(d.removal),
rarity: d.rarity,
legendary: d.legendary,
small_img: d.image_uris.substring(11, 122),
medium_img: d.image_uris.substring(136, 248)
})
)
Insert cell
Insert cell
Insert cell
base_data = performance_data
.map((d) => ({
...d,
status_2: ["Drawn", "OpeningHand"].includes(d.status) // Creating a new status column
? "Playable"
: "Not Playable",
plays: d.int_won_count,
wins: d.int_won_count * d.int_won_mean
}))
.filter((d) => ["Drawn", "OpeningHand", "InDeck"].includes(d.status))
.filter(
(d) =>
card_details_map.get(d.card) &&
!(
card_details_map.get(d.card)[0]["type_detail"].split(" — ")[0] ===
"Basic Land"
)
)
Insert cell
Insert cell
Insert cell
agg_performance_metrics_fun = function(bdata, group_by) {
const bdata_withperformance = d3.rollups(
bdata,
v => ({
[group_by]: v[0][group_by],
plays: d3.sum(v, d => d.plays),
wins: d3.sum(v, d => d.wins),
total_wr: accum_wr(v),
wr_lift: accum_wr_x1(v) - accum_wr_x2(v),
playable_wr: accum_wr_x1(v),
playable_plays: playable_plays(v),
not_playable_wr: accum_wr_x2(v),
not_playable_plays: not_playable_plays(v)
}),
d => d[group_by]
);
return [...bdata_withperformance].map(v => v[1]);
}
Insert cell
Insert cell
graph_data_fun = {
const fun = function(
pre_card_filters = [],
dimension_filters = [],
grouping_column = 'card',
rank_filters = [],
card_sorting_columns = ['plays'],
group_sorting_column = 'plays',
sort_direction = true
) {
let pre_card_data = base_data;

// First step is to filter the base data by the pre-card filters
pre_card_filters.forEach(function(pre_card_filter_fun) {
pre_card_data = pre_card_data.filter(pre_card_filter_fun);
});

// Now grouping into cards and measuring performance
const cards_data_with_performance = agg_performance_metrics_fun(
pre_card_data,
'card'
);

// Adding card details dimensions after calculating performance
const cards_data_with_performance_and_details = cards_data_with_performance.map(
v => ({
...v,
...card_details_map.get(v.card)[0]
})
);

let pre_grouping_data = cards_data_with_performance_and_details;

// Filtering the card data by the dimension filters
dimension_filters.forEach(function(dim_filter_fun) {
pre_grouping_data = pre_grouping_data.filter(dim_filter_fun);
});

// ## Grouping
// First calculating rank
let pre_group_with_rank_df = aq.from(pre_grouping_data);

console.log(pre_group_with_rank_df.objects());

if (grouping_column) {
pre_group_with_rank_df = pre_group_with_rank_df
.groupby(grouping_column)
.orderby([
grouping_column,
...card_sorting_columns.map(d => (sort_direction ? aq.desc(d) : d))
])
.derive({ rank: op.rank()
//, perc_plays: d => d.plays / op.sum('plays')
})
.ungroup()
.params({ grouping_column: grouping_column })
.derive({ grouping: d => grouping_column })
.objects();
} else {
pre_group_with_rank_df = pre_group_with_rank_df
.orderby([
...card_sorting_columns.map(d => (sort_direction ? aq.desc(d) : d))
])
.derive({ rank: op.rank() })
.ungroup()
.derive({ grouping: d => null })
.objects();
}

// Cards df and filtering by rank_filters
let cards_with_rank = pre_group_with_rank_df;

rank_filters.forEach(function(rank_filter_fun) {
cards_with_rank = cards_with_rank.filter(rank_filter_fun);
});

// Returning without group performance if no grouping
if (!grouping_column) {
return {
cards_data: cards_with_rank,
groups_data: []
};
}

// Now calculating group performance
const pre_card_data_with_details = pre_card_data.map(v => ({
...v,
...card_details_map.get(v.card)[0]
}));

const groups_performance_df = aq.from(
agg_performance_metrics_fun(pre_card_data_with_details, grouping_column)
);

// Now collecting the cards in each group and adding to groups infos df
const groups_info_df = aq
.from(cards_with_rank)
.groupby(grouping_column)
.rollup({
cards: op.array_agg_distinct('card')
// perc_plays: d => d.plays / op.sum('plays')
})
.ungroup()
.join(groups_performance_df)
.orderby(aq.desc(group_sorting_column))
.params({ grouping_column: grouping_column })
.derive({ grouping: d => grouping_column });

return {
cards_data: cards_with_rank,
groups_data: groups_info_df.objects()
};
};

return fun;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
img_parameters = {
const img_proportion = 1.4;
const ref_width = 146;
const ref_height = 204;
return {
x: (+ref_width * img_proportion) / 2,
width: ref_width * img_proportion,
height: ref_height * img_proportion
};
}
Insert cell
addTooltipsv2 = (chart, hover_styles = { fill: "blue", opacity: 0.5 }) => {
// Add the hover g

// Workaround if it's in a figure
const type = d3.select(chart).node().tagName;
const wrapper =
type === "FIGURE" ? d3.select(chart).select("svg") : d3.select(chart);

wrapper.style("overflow", "visible"); // to avoid clipping at the edges

wrapper.selectAll("path").style("pointer-events", "visibleStroke"); // only trigger hover for lines in visible area

const tip = wrapper
.selectAll(".hover-tip")
.data([""])
.join("g")
.attr("class", "hover")
.style("pointer-events", "none")
.style("text-anchor", "middle");

// Add a unique id to the chart for styling
const id = id_generator();

// Add the event listeners
d3.select(chart)
.classed(id, true) // using a class selector so that it doesn't overwrite the ID
.selectAll("title")
.each(function() {
// Get the text out of the title, set it as an attribute on the parent, and remove it
const title = d3.select(this); // title element that we want to remove
const parent = d3.select(this.parentNode); // visual mark on the screen
const t = title.text().split(';')[0];
const img = title.text().split(';')[1];
if (t) {
parent.attr("__title", t).classed("has-title", true);
title.remove();
}
// Mouse events
parent
.on("mousemove", function(event) {
const text = d3.select(this).attr("__title");
const pointer = d3.pointer(event, wrapper.node());
if (text) tip.call(hover, pointer, text.split("\n"), img);
else tip.selectAll("*").remove();

// Keep within the parent horizontally
const tipSize = tip.node().getBBox();
if (pointer[0] + tipSize.x < 0)
tip.attr(
"transform",
`translate(${tipSize.width / 2}, ${pointer[1] + 7})`
);
else if (pointer[0] + tipSize.width / 2 > wrapper.attr("width"))
tip.attr(
"transform",
`translate(${wrapper.attr("width") -
tipSize.width / 2}, ${pointer[1] + 7})`
);
})
.on("mouseout", event => {
tip.selectAll("*").remove();
});
});

// Remove the tip if you tap on the wrapper (for mobile)
wrapper.on("touchstart", () => tip.selectAll("*").remove());
// Add styles
const style_string = Object.keys(hover_styles)
.map(d => {
return `${d}:${hover_styles[d]};`;
})
.join("");

// Define the styles
const style = html`<style>
.${id} .has-title {
cursor: pointer;
pointer-events: all;
}
.${id} .has-title:hover {
${style_string}
}
</style>`;
chart.appendChild(style);
return chart;
}
Insert cell
// Function to position the tooltip
hover = (tip, pos, text, img) => {
const side_padding = 10;
const vertical_padding = 5;
const vertical_offset = 25;
const image_x_offset = 15;
const image_y_offset = 15;

// Empty it out
tip.selectAll("*").remove();

// Append the text
tip
.style("text-anchor", "middle")
.style("pointer-events", "none")
.attr("transform", `translate(${pos[0]}, ${pos[1] + 7})`)
.selectAll("text")
.data(text)
.join("text")
.style("dominant-baseline", "ideographic")
.text(d => d)
.attr("y", (d, i) => (i - (text.length - 1)) * 15 - vertical_offset)
.style("font-weight", (d, i) => (i === 0 ? "bold" : "normal"));

const bbox = tip.node().getBBox();

// Append the image
tip
.append("svg:image")
.attr('x', bbox.width / 2 + image_x_offset)
.attr("y", d => -img_parameters.height - image_y_offset)
.attr('width', img_parameters.width)
.attr('height', img_parameters.height)
.attr('text-align', 'center')
.attr("xlink:href", img);

// Add a rectangle (as background)
tip
.append("rect")
.attr("y", bbox.y - vertical_padding)
.attr("x", bbox.x - side_padding)
.attr("width", bbox.width + side_padding * 2)
.attr("height", bbox.height + vertical_padding * 2)
.style("fill", "white")
.style("stroke", "#d3d3d3")
.lower();
}
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more