gimm260-data-visualization/index.html
2022-12-18 13:09:27 -07:00

1216 lines
57 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Kaj Forney -- GIMM 260 Data Visualization Narrative</title>
<link rel="stylesheet" href="assets/css/main.css">
<script src="https://kit.fontawesome.com/55072ce5fd.js" crossorigin="anonymous"></script>
</head>
<body class="parallax">
<div class="row">
<div class="col-3"></div>
<div class="col-6">
<h1 class="titlePulse">
<i class="fa-solid fa-gamepad"></i>
Gaming on Linux
<i class="fa-brands fa-steam"></i>
</h1>
</div>
<div class="col-4"></div>
</div>
<div class="row">
<div class="col-1"></div>
<div class="col-5 roundCorners card">
<h2>Introduction</h2>
<p>I have been a user of Linux for the past 14 years. In that time, I've observed a notable improvement in the ease of running games on the system, particularly games that were originally made for Windows.</p>
<p>This page will display a sample of Linux game compatibility data.</p>
</div>
<div class="col-1"></div>
<div class="col-3 bigIcon">
<i class="fa-brands fa-linux wobble"></i>
</div>
<div class="col-2"></div>
</div>
<div class="spacer"></div>
<div class="row reverseOnMobile">
<div class="col-2"></div>
<div class="col-3 bigIcon lastOnMobile">
<i class="fa-solid fa-wine-glass wobble"></i>
</div>
<div class="col-1"></div>
<div class="col-5 roundCorners card firstOnMobile">
<h2>WINE</h2>
<p>The WINE Is Not an Emulator (WINE) project is the foundation for any method of running Windows applications on Linux, originally created in 1993 to support Windows 3.1 applications.</p>
<p>Essentially, it translates Windows system calls into Linux calls, without the performance penalties of full emulation.</p>
</div>
</div>
<div class="spacer"></div>
<div class="row">
<div class="col-1"></div>
<div class="col-5 roundCorners card">
<h2>Proton</h2>
<p>Proton is a tool built on top of WINE by Valve and CodeWeavers, initially released in 2018 and integrated into Steam.</p>
<p>The primary difference between Proton and base WINE is that Proton incorporates multiple libraries designed to improve rendering performance.</p>
<p>It is Proton that makes it possible to run Windows games with relative ease on Valve's Steam Deck console.</p>
</div>
<div class="col-1"></div>
<div class="col-4">
<div class="youtube-container">
<iframe class="youtube-iframe" src="https://www.youtube-nocookie.com/embed/_OAqvtlgfGA" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="spacer"></div>
<div class="row">
<div class="col-1"></div>
<div class="col-4 overflow">
<script type="module" id="overviewIcicle">
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
let parsedData = {
"name": "Total Reports",
"children": [
{"name": "Working",
"children": [
{"name": "Windowing Faults", "size": 0},
{"name": "Performance Faults", "size": 0},
{"name": "Save Game Faults", "size": 0},
{"name": "Graphical Faults", "size": 0},
{"name": "Audio Faults", "size": 0},
{"name": "Input Faults", "size": 0},
{"name": "Stability Faults", "size": 0},
{"name": "Significant Bugs", "size": 0},
{"name": "No Issues", "size": 0}
]},
{"name": "Nonfunctional", "size": 0}
]
};
const jsonData = d3.json("http://localhost:8888");
//const jsonData = d3.json("gamedata.json");
jsonData.then(function (data){
let dataset = data.reports;
processData(dataset);
});
function processData(data) {
for(let i = 0; i < data.length; i++) {
if(data[i].verdict == 0)
{
parsedData.children[1].size++;
}
else if(data[i].verdict == 1) {
if(data[i].windowingFaults == 1) {
parsedData.children[0].children[0].size++;
continue;
}
if(data[i].performanceFaults == 1) {
parsedData.children[0].children[1].size++;
continue;
}
if(data[i].saveGameFaults == 1) {
parsedData.children[0].children[2].size++;
continue;
}
if(data[i].graphicalFaults == 1) {
parsedData.children[0].children[3].size++;
continue;
}
if(data[i].audioFaults == 1) {
parsedData.children[0].children[4].size++;
continue;
}
if(data[i].inputFaults == 1) {
parsedData.children[0].children[5].size++;
continue;
}
if(data[i].stabilityFaults == 1) {
parsedData.children[0].children[6].size++;
continue;
}
if(data[i].significantBugs == 1) {
parsedData.children[0].children[7].size++;
continue;
}
if((
data[i].windowingFaults == 0 &&
data[i].performanceFaults == 0 &&
data[i].saveGameFaults == 0 &&
data[i].graphicalFaults == 0 &&
data[i].audioFaults == 0 &&
data[i].inputFaults == 0 &&
data[i].stabilityFaults == 0 &&
data[i].significantBugs == 0
) || (data[i].verdictOOB == 1)
) {
parsedData.children[0].children[8].size++;
}
}
};
let chart = Icicle(parsedData, {
value: d => d.size, // size of each node (file); null for internal nodes (folders)
label: d => d.name, // display name for each cell
title: (d, n) => `${n.ancestors().reverse().map(d => d.data.name).join(".")}\n${n.value.toLocaleString("en")}`, // hover text
color: d3.scaleOrdinal(d3.schemeBlues[3])
})
document.getElementById("visOverview").appendChild(chart);
}
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/icicle
function Icicle(data, { // data is either tabular (array of objects) or hierarchy (nested objects)
path, // as an alternative to id and parentId, returns an array identifier, imputing internal nodes
id = Array.isArray(data) ? d => d.id : null, // if tabular data, given a d in data, returns a unique identifier (string)
parentId = Array.isArray(data) ? d => d.parentId : null, // if tabular data, given a node d, returns its parents identifier
children, // if hierarchical data, given a d in data, returns its children
format = ",", // format specifier string or function for values
value, // given a node d, returns a quantitative value (for area encoding; null for count)
sort = (a, b) => d3.descending(a.value, b.value), // how to sort nodes prior to layout
label, // given a node d, returns the name to display on the rectangle
title, // given a node d, returns its hover text
link, // given a node d, its link (if any)
linkTarget = "_blank", // the target attribute for links (if any)
width = 500, // outer width, in pixels
height = 500, // outer height, in pixels
margin = 0, // shorthand for margins
marginTop = margin, // top margin, in pixels
marginRight = margin, // right margin, in pixels
marginBottom = margin, // bottom margin, in pixels
marginLeft = margin, // left margin, in pixels
padding = 1, // cell padding, in pixels
round = false, // whether to round to exact pixels
color = d3.interpolateRainbow, // color scheme, if any
fill = "#ccc", // fill for node rects (if no color encoding)
fillOpacity = 0.6, // fill opacity for node rects
} = {}) {
// If id and parentId options are specified, or the path option, use d3.stratify
// to convert tabular data to a hierarchy; otherwise we assume that the data is
// specified as an object {children} with nested objects (a.k.a. the “flare.json”
// format), and use d3.hierarchy.
const root = path != null ? d3.stratify().path(path)(data)
: id != null || parentId != null ? d3.stratify().id(id).parentId(parentId)(data)
: d3.hierarchy(data, children);
// Compute the values of internal nodes by aggregating from the leaves.
value == null ? root.count() : root.sum(d => Math.max(0, value(d)));
// Compute formats.
if (typeof format !== "function") format = d3.format(format);
// Sort the leaves (typically by descending value for a pleasing layout).
if (sort != null) root.sort(sort);
// Compute the partition layout. Note that x and y are swapped!
d3.partition()
.size([height - marginTop - marginBottom, width - marginLeft - marginRight])
.padding(padding)
.round(round)
(root);
// Construct a color scale.
if (color != null) {
color = d3.scaleSequential([0, root.children.length - 1], color).unknown(fill);
root.children.forEach((child, i) => child.index = i);
}
const svg = d3.create("svg")
.attr("viewBox", [-marginLeft, -marginTop, width, height])
.attr("preserveAspectRatio", "xMinYMin meet")
.classed("svg-content", true)
//.attr("width", width)
//.attr("height", height)
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.attr("font-family", "sans-serif")
.attr("font-size", 10);
const cell = svg
.selectAll("a")
.data(root.descendants())
.join("a")
.attr("xlink:href", link == null ? null : d => link(d.data, d))
.attr("target", link == null ? null : linkTarget)
.attr("transform", d => `translate(${d.y0},${d.x0})`);
cell.append("rect")
.attr("width", d => d.y1 - d.y0)
.attr("height", d => d.x1 - d.x0)
.attr("fill", color ? d => color(d.ancestors().reverse()[1]?.index) : fill)
.attr("fill-opacity", fillOpacity);
const text = cell.filter(d => d.x1 - d.x0 > 10).append("text")
.attr("x", 4)
.attr("y", d => Math.min(9, (d.x1 - d.x0) / 2))
.attr("dy", "0.32em");
if (label != null) text.append("tspan")
.text(d => label(d.data, d));
text.append("tspan")
.attr("fill-opacity", 0.7)
.attr("dx", label == null ? null : 3)
.text(d => format(d.value));
if (title != null) cell.append("title")
.text(d => title(d.data, d));
return svg.node();
}
</script>
<svg id="visOverview" height="500" width="500" class="overflow"></svg>
</div>
<div class="col-1"></div>
<div class="col-5 roundCorners card">
<h2>Overview</h2>
<p>The chart on the left provides a general overview of the data.</p>
<p>Of the 3,533 compatibility reports gathered, only 856 reported games tested as being entirely nonfunctional.</p>
<p>Of the 2,677 reports of working gameplay, 1,510 had no issues whatsoever, with the game running perfectly out of the box.</p>
</div>
<div class="col-1"></div>
</div>
<div class="spacer"></div>
<div class="row">
<div class="col-1"></div>
<div class="col-5 roundCorners card">
<h2>Typical Game Compatibility</h2>
<p>Both Valve and the community at large make changes to Proton as needed to support new games.</p>
<p>This data visualization illustrates both the speed at which a newly released game can now become Linux-compatible, and the occasional periods of instability some games go through following major updates, using the Definitive Edition release of Age of Empires II as an example.</p>
<p>In general, however, single-player games tend to be more stable, as can be seen below with Outer Wilds and Cuphead.</p>
<p>The percentage of reports in green indicate that the game was playable, while the reports in orange indicate that the game was entirely unplayable.</p>
</div>
<div class="col-1"></div>
<div class="col-4 overflow">
<script type="module" id="aoe2">
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
const jsonData = d3.json("http://localhost:8888");
//const jsonData = d3.json("gamedata.json");
jsonData.then(function (data){
let dataset = data.reports;
processData(dataset);
});
function processData(data) {
console.log(data);
let parsedAOE2Data = [];
let indexWorking = 0;
let indexBroken = 1;
let prevDate = 14;
parsedAOE2Data[indexWorking] = new Object();
parsedAOE2Data[indexWorking].date = new Date("2019-11-14");
parsedAOE2Data[indexWorking].title = "Working";
parsedAOE2Data[indexWorking].number = 0;
parsedAOE2Data[indexBroken] = new Object();
parsedAOE2Data[indexBroken].date = new Date("2019-11-14");
parsedAOE2Data[indexBroken].title = "Borked";
parsedAOE2Data[indexBroken].number = 0;
for(let i = 0; i < data.length; i++) {
let currentReport = data[i];
let currentDate = new Date(data[i].timestamp * 1000);
let dateString = currentDate.getFullYear().toString() + "-" + currentDate.getMonth().toString() + "-" + currentDate.getDate().toString();
if(data[i].game_title != "Age of Empires II: Definitive Edition")
{continue;} else {
console.log("AOE2!");
if(currentDate.getDate() != prevDate) {
prevDate = currentDate.getDate();
indexBroken = indexBroken + 2;
indexWorking = indexWorking + 2;
parsedAOE2Data[indexWorking] = new Object();
parsedAOE2Data[indexWorking].date = currentDate;
parsedAOE2Data[indexWorking].title = "Working";
parsedAOE2Data[indexWorking].number = 0;
parsedAOE2Data[indexBroken] = new Object();
parsedAOE2Data[indexBroken].date = currentDate;
parsedAOE2Data[indexBroken].title = "Borked";
parsedAOE2Data[indexBroken].number = 0;
}
if(data[i].verdict == 0) {
parsedAOE2Data[indexBroken].number++;
}
if(data[i].verdict == 1) {
parsedAOE2Data[indexWorking].number++;
}
}
}
console.log(parsedAOE2Data);
document.getElementById("visAOE2").appendChild(StackedAreaChart(parsedAOE2Data, {
x: d => d.date,
y: d => d.number,
z: d => d.title,
yLabel: "Percentage of Reports--Age of Empires II: Definitive Edition",
width: 700,
height: 250
}
));
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/normalized-stacked-area-chart
function StackedAreaChart(data, {
x = ([x]) => x, // given d in data, returns the (ordinal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
z = () => 1, // given d in data, returns the (categorical) z-value
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleUtc, // type of x-scale
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // type of y-scale
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
zDomain, // array of z-values
offset = d3.stackOffsetExpand, // stack offset method
order = d3.stackOrderNone, // stack order method
yLabel, // a label for the y-axis
xFormat, // a format specifier string for the x-axis
yFormat = "%", // a format specifier string for the y-axis
colors = d3.schemeDark2, // an array of colors for the (z) categories
} = {}) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const Z = d3.map(data, z);
// Compute default x- and z-domains, and unique the z-domain.
if (xDomain === undefined) xDomain = d3.extent(X);
if (zDomain === undefined) zDomain = Z;
zDomain = new d3.InternSet(zDomain);
// Omit any data not present in the z-domain.
const I = d3.range(X.length).filter(i => zDomain.has(Z[i]));
// Compute a nested array of series where each series is [[y1, y2], [y1, y2],
// [y1, y2], …] representing the y-extent of each stacked rect. In addition,
// each tuple has an i (index) property so that we can refer back to the
// original data point (data[i]). This code assumes that there is only one
// data point for a given unique x- and z-value.
const series = d3.stack()
.keys(zDomain)
.value(([x, I], z) => Y[I.get(z)])
.order(order)
.offset(offset)
(d3.rollup(I, ([i]) => i, i => X[i], i => Z[i]))
.map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));
// Compute the default y-domain. Note: diverging stacks can be negative.
if (yDomain === undefined) yDomain = d3.extent(series.flat(2));
// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const color = d3.scaleOrdinal(zDomain, colors);
const xAxis = d3.axisBottom(xScale).ticks(width / 80, xFormat).tickSizeOuter(0);
const yAxis = d3.axisLeft(yScale).ticks(height / 50, yFormat);
const area = d3.area()
.x(({i}) => xScale(X[i]))
.y0(([y1]) => yScale(y1))
.y1(([, y2]) => yScale(y2));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.selectAll("path")
.data(series)
.join("path")
.attr("fill", ([{i}]) => color(Z[i]))
.attr("d", area)
.append("title")
.text(([{i}]) => Z[i]);
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.call(g => g.select(".domain").remove());
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line")
.filter(d => d === 0 || d === 1)
.clone()
.attr("x2", width - marginLeft - marginRight))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
return Object.assign(svg.node(), {scales: {color}});
}
}
</script>
<h2>Age of Empires II: Definitive Edition</h2>
<svg width="500" height="500" id="visAOE2" class="overflow"></svg>
</div>
<div class="col-1"></div>
</div>
<div class="row">
<div class="col-1"></div>
<div class="col-4">
<script type="module" id="OuterWilds">
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
const jsonData = d3.json("http://localhost:8888");
jsonData.then(function (data){
let dataset = data.reports;
processData(dataset);
});
function processData(data) {
let parsedOuterWildsData = [];
let indexWorking = 0;
let indexBroken = 1;
let prevDate = 14;
parsedOuterWildsData[indexWorking] = new Object();
parsedOuterWildsData[indexWorking].date = new Date("2020-06-18");
parsedOuterWildsData[indexWorking].title = "Working";
parsedOuterWildsData[indexWorking].number = 0;
parsedOuterWildsData[indexBroken] = new Object();
parsedOuterWildsData[indexBroken].date = new Date("2020-06-18");
parsedOuterWildsData[indexBroken].title = "Borked";
parsedOuterWildsData[indexBroken].number = 0;
for(let i = 0; i < data.length; i++) {
let currentReport = data[i];
let currentDate = new Date(data[i].timestamp * 1000);
let dateString = currentDate.getFullYear().toString() + "-" + currentDate.getMonth().toString() + "-" + currentDate.getDate().toString();
if(data[i].game_title != "Outer Wilds")
{continue;} else {
if(currentDate.getDate() != prevDate) {
prevDate = currentDate.getDate();
indexBroken = indexBroken + 2;
indexWorking = indexWorking + 2;
parsedOuterWildsData[indexWorking] = new Object();
parsedOuterWildsData[indexWorking].date = currentDate;
parsedOuterWildsData[indexWorking].title = "Working";
parsedOuterWildsData[indexWorking].number = 0;
parsedOuterWildsData[indexBroken] = new Object();
parsedOuterWildsData[indexBroken].date = currentDate;
parsedOuterWildsData[indexBroken].title = "Borked";
parsedOuterWildsData[indexBroken].number = 0;
}
if(data[i].verdict == 0) {
parsedOuterWildsData[indexBroken].number++;
}
if(data[i].verdict == 1) {
parsedOuterWildsData[indexWorking].number++;
}
}
}
document.getElementById("visOW").appendChild(StackedAreaChart(parsedOuterWildsData, {
x: d => d.date,
y: d => d.number,
z: d => d.title,
yLabel: "Number of Reports--Outer Wilds",
width: 700,
height: 250
}
));
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/normalized-stacked-area-chart
function StackedAreaChart(data, {
x = ([x]) => x, // given d in data, returns the (ordinal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
z = () => 1, // given d in data, returns the (categorical) z-value
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleUtc, // type of x-scale
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // type of y-scale
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
zDomain, // array of z-values
offset = d3.stackOffsetExpand, // stack offset method
order = d3.stackOrderNone, // stack order method
yLabel, // a label for the y-axis
xFormat, // a format specifier string for the x-axis
yFormat = "%", // a format specifier string for the y-axis
colors = d3.schemeDark2, // an array of colors for the (z) categories
} = {}) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const Z = d3.map(data, z);
// Compute default x- and z-domains, and unique the z-domain.
if (xDomain === undefined) xDomain = d3.extent(X);
if (zDomain === undefined) zDomain = Z;
zDomain = new d3.InternSet(zDomain);
// Omit any data not present in the z-domain.
const I = d3.range(X.length).filter(i => zDomain.has(Z[i]));
// Compute a nested array of series where each series is [[y1, y2], [y1, y2],
// [y1, y2], …] representing the y-extent of each stacked rect. In addition,
// each tuple has an i (index) property so that we can refer back to the
// original data point (data[i]). This code assumes that there is only one
// data point for a given unique x- and z-value.
const series = d3.stack()
.keys(zDomain)
.value(([x, I], z) => Y[I.get(z)])
.order(order)
.offset(offset)
(d3.rollup(I, ([i]) => i, i => X[i], i => Z[i]))
.map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));
// Compute the default y-domain. Note: diverging stacks can be negative.
if (yDomain === undefined) yDomain = d3.extent(series.flat(2));
// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const color = d3.scaleOrdinal(zDomain, colors);
const xAxis = d3.axisBottom(xScale).ticks(width / 80, xFormat).tickSizeOuter(0);
const yAxis = d3.axisLeft(yScale).ticks(height / 50, yFormat);
const area = d3.area()
.x(({i}) => xScale(X[i]))
.y0(([y1]) => yScale(y1))
.y1(([, y2]) => yScale(y2));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.selectAll("path")
.data(series)
.join("path")
.attr("fill", ([{i}]) => color(Z[i]))
.attr("d", area)
.append("title")
.text(([{i}]) => Z[i]);
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.call(g => g.select(".domain").remove());
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line")
.filter(d => d === 0 || d === 1)
.clone()
.attr("x2", width - marginLeft - marginRight))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
return Object.assign(svg.node(), {scales: {color}});
}
}
</script>
<h2>Outer Wilds</h2>
<svg width="500" height="500" id="visOW" class="overflow"></svg>
</div>
<div class="col-2"></div>
<div class="col-4">
<script type="module" id="cuphead">
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
const jsonData = d3.json("http://localhost:8888");
jsonData.then(function (data){
let dataset = data.reports;
processData(dataset);
});
function processData(data) {
let parsedCupheadData = [];
let indexWorking = 0;
let indexBroken = 1;
let prevDate = 14;
parsedCupheadData[indexWorking] = new Object();
parsedCupheadData[indexWorking].date = new Date("2019-11-01");
parsedCupheadData[indexWorking].title = "Working";
parsedCupheadData[indexWorking].number = 0;
parsedCupheadData[indexBroken] = new Object();
parsedCupheadData[indexBroken].date = new Date("2019-11-01");
parsedCupheadData[indexBroken].title = "Borked";
parsedCupheadData[indexBroken].number = 0;
for(let i = 0; i < data.length; i++) {
let currentReport = data[i];
let currentDate = new Date(data[i].timestamp * 1000);
let dateString = currentDate.getFullYear().toString() + "-" + currentDate.getMonth().toString() + "-" + currentDate.getDate().toString();
if(data[i].game_title != "Cuphead")
{continue;} else {
if(currentDate.getDate() != prevDate) {
prevDate = currentDate.getDate();
indexBroken = indexBroken + 2;
indexWorking = indexWorking + 2;
parsedCupheadData[indexWorking] = new Object();
parsedCupheadData[indexWorking].date = currentDate;
parsedCupheadData[indexWorking].title = "Working";
parsedCupheadData[indexWorking].number = 0;
parsedCupheadData[indexBroken] = new Object();
parsedCupheadData[indexBroken].date = currentDate;
parsedCupheadData[indexBroken].title = "Borked";
parsedCupheadData[indexBroken].number = 0;
}
if(data[i].verdict == 0) {
parsedCupheadData[indexBroken].number++;
}
if(data[i].verdict == 1) {
parsedCupheadData[indexWorking].number++;
}
}
}
document.getElementById("visCuphead").appendChild(StackedAreaChart(parsedCupheadData, {
x: d => d.date,
y: d => d.number,
z: d => d.title,
yLabel: "Number of Reports--Cuphead",
width: 700,
height: 250
}
));
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/normalized-stacked-area-chart
function StackedAreaChart(data, {
x = ([x]) => x, // given d in data, returns the (ordinal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
z = () => 1, // given d in data, returns the (categorical) z-value
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleUtc, // type of x-scale
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // type of y-scale
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
zDomain, // array of z-values
offset = d3.stackOffsetExpand, // stack offset method
order = d3.stackOrderNone, // stack order method
yLabel, // a label for the y-axis
xFormat, // a format specifier string for the x-axis
yFormat = "%", // a format specifier string for the y-axis
colors = d3.schemeDark2, // an array of colors for the (z) categories
} = {}) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const Z = d3.map(data, z);
// Compute default x- and z-domains, and unique the z-domain.
if (xDomain === undefined) xDomain = d3.extent(X);
if (zDomain === undefined) zDomain = Z;
zDomain = new d3.InternSet(zDomain);
// Omit any data not present in the z-domain.
const I = d3.range(X.length).filter(i => zDomain.has(Z[i]));
// Compute a nested array of series where each series is [[y1, y2], [y1, y2],
// [y1, y2], …] representing the y-extent of each stacked rect. In addition,
// each tuple has an i (index) property so that we can refer back to the
// original data point (data[i]). This code assumes that there is only one
// data point for a given unique x- and z-value.
const series = d3.stack()
.keys(zDomain)
.value(([x, I], z) => Y[I.get(z)])
.order(order)
.offset(offset)
(d3.rollup(I, ([i]) => i, i => X[i], i => Z[i]))
.map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));
// Compute the default y-domain. Note: diverging stacks can be negative.
if (yDomain === undefined) yDomain = d3.extent(series.flat(2));
// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const color = d3.scaleOrdinal(zDomain, colors);
const xAxis = d3.axisBottom(xScale).ticks(width / 80, xFormat).tickSizeOuter(0);
const yAxis = d3.axisLeft(yScale).ticks(height / 50, yFormat);
const area = d3.area()
.x(({i}) => xScale(X[i]))
.y0(([y1]) => yScale(y1))
.y1(([, y2]) => yScale(y2));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.selectAll("path")
.data(series)
.join("path")
.attr("fill", ([{i}]) => color(Z[i]))
.attr("d", area)
.append("title")
.text(([{i}]) => Z[i]);
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.call(g => g.select(".domain").remove());
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line")
.filter(d => d === 0 || d === 1)
.clone()
.attr("x2", width - marginLeft - marginRight))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
return Object.assign(svg.node(), {scales: {color}});
}
}
</script>
<h2>Cuphead</h2>
<svg width="500" height="500" id="visCuphead" class="overflow"></svg>
</div>
<div class="col-1"></div>
</div>
<div class="row reverseOnMobile">
<div class="col-1"></div>
<div class="col-4 overflow lastOnMobile">
<script type="module" id="apex">
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
const jsonData = d3.json("http://localhost:8888");
jsonData.then(function (data){
let dataset = data.reports;
processData(dataset);
});
function processData(data) {
console.log(data);
let parsedApexData = [];
let indexWorking = 0;
let indexBroken = 1;
let prevDate = 14;
parsedApexData[indexWorking] = new Object();
parsedApexData[indexWorking].date = new Date("2020-11-04");
parsedApexData[indexWorking].title = "Working";
parsedApexData[indexWorking].number = 0;
parsedApexData[indexBroken] = new Object();
parsedApexData[indexBroken].date = new Date("2020-11-04");
parsedApexData[indexBroken].title = "Borked";
parsedApexData[indexBroken].number = 0;
for(let i = 0; i < data.length; i++) {
let currentReport = data[i];
let currentDate = new Date(data[i].timestamp * 1000);
let dateString = currentDate.getFullYear().toString() + "-" + currentDate.getMonth().toString() + "-" + currentDate.getDate().toString();
if(data[i].game_title != "Apex Legends")
{continue;} else {
console.log("Apex!");
if(currentDate.getDate() != prevDate) {
prevDate = currentDate.getDate();
indexBroken = indexBroken + 2;
indexWorking = indexWorking + 2;
parsedApexData[indexWorking] = new Object();
parsedApexData[indexWorking].date = currentDate;
parsedApexData[indexWorking].title = "Working";
parsedApexData[indexWorking].number = 0;
parsedApexData[indexBroken] = new Object();
parsedApexData[indexBroken].date = currentDate;
parsedApexData[indexBroken].title = "Borked";
parsedApexData[indexBroken].number = 0;
}
if(data[i].verdict == 0) {
parsedApexData[indexBroken].number++;
}
if(data[i].verdict == 1) {
parsedApexData[indexWorking].number++;
}
}
}
console.log(parsedApexData);
document.getElementById("visApex").appendChild(StackedAreaChart(parsedApexData, {
x: d => d.date,
y: d => d.number,
z: d => d.title,
yLabel: "Percentage of Reports--Apex Legends",
width: 500,
height: 250
}
));
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/normalized-stacked-area-chart
function StackedAreaChart(data, {
x = ([x]) => x, // given d in data, returns the (ordinal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
z = () => 1, // given d in data, returns the (categorical) z-value
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleUtc, // type of x-scale
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // type of y-scale
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
zDomain, // array of z-values
offset = d3.stackOffsetExpand, // stack offset method
order = d3.stackOrderNone, // stack order method
yLabel, // a label for the y-axis
xFormat, // a format specifier string for the x-axis
yFormat = "%", // a format specifier string for the y-axis
colors = d3.schemeDark2, // an array of colors for the (z) categories
} = {}) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const Z = d3.map(data, z);
// Compute default x- and z-domains, and unique the z-domain.
if (xDomain === undefined) xDomain = d3.extent(X);
if (zDomain === undefined) zDomain = Z;
zDomain = new d3.InternSet(zDomain);
// Omit any data not present in the z-domain.
const I = d3.range(X.length).filter(i => zDomain.has(Z[i]));
// Compute a nested array of series where each series is [[y1, y2], [y1, y2],
// [y1, y2], …] representing the y-extent of each stacked rect. In addition,
// each tuple has an i (index) property so that we can refer back to the
// original data point (data[i]). This code assumes that there is only one
// data point for a given unique x- and z-value.
const series = d3.stack()
.keys(zDomain)
.value(([x, I], z) => Y[I.get(z)])
.order(order)
.offset(offset)
(d3.rollup(I, ([i]) => i, i => X[i], i => Z[i]))
.map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));
// Compute the default y-domain. Note: diverging stacks can be negative.
if (yDomain === undefined) yDomain = d3.extent(series.flat(2));
// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const color = d3.scaleOrdinal(zDomain, colors);
const xAxis = d3.axisBottom(xScale).ticks(width / 80, xFormat).tickSizeOuter(0);
const yAxis = d3.axisLeft(yScale).ticks(height / 50, yFormat);
const area = d3.area()
.x(({i}) => xScale(X[i]))
.y0(([y1]) => yScale(y1))
.y1(([, y2]) => yScale(y2));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.selectAll("path")
.data(series)
.join("path")
.attr("fill", ([{i}]) => color(Z[i]))
.attr("d", area)
.append("title")
.text(([{i}]) => Z[i]);
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.call(g => g.select(".domain").remove());
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line")
.filter(d => d === 0 || d === 1)
.clone()
.attr("x2", width - marginLeft - marginRight))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
return Object.assign(svg.node(), {scales: {color}});
}
}
</script>
<h2>Apex Legends</h2>
<svg width="500" height="500" id="visApex" class="overflow"></svg>
</div>
<div class="col-1"></div>
<div class="col-5 roundCorners card firstOnMobile">
<h2>Anti-Cheat</h2>
<p>Some games utilize anti-cheat modules that are designed to interface directly with the Windows kernel.</p>
<p>As a result, these games would normally be entirely incompatible with Linux.</p>
<p>However, Valve has been working with the developers of two common kernel-level anticheat modules (EasyAntiCheat and BattlEye) to allow games using these to run under Proton.</p>
<p>Now, as long as the developer permits it, these games will run perfectly well on Linux through Proton.</p>
<p>An example of this can be seen here, with Apex Legends.</p>
</div>
<div class="col-1"></div>
</div>
<div class="spacer"></div>
<div class="row">
<div class="col-2"></div>
<div class="col-8 roundCorners card">
<h2>Conclusion</h2>
<p>Even from this small sample of data, I would conclude that the state of gaming on Linux has periods of instability, but is overall continuing to improve.</p>
<p>With even large-scale multiplayer games such as Apex Legends taking action to be more Linux-friendly, I believe the future of Linux as a gaming platform is brighter than ever.</p>
</div>
<div class="col-2"></div>
</div>
<div class="spacer"></div>
<div class="row">
<div class="col-2"></div>
<div class="col-8 roundCorners card">
<h2>DATA COLLECTION</h2>
<p>All the data I used was originally collected by <a href="https://www.protondb.com">ProtonDB</a>, a crowdsourced database of user reports of game compatibility with Linux.</p>
<p>The data I used is the complete set of ProtonDB user reports from October 2019 to October 2022, for a selection of ten different games:</p>
<ul>
<li>Age of Empires II: Definitive Edition</li>
<li>Apex Legends</li>
<li>Cuphead</li>
<li>Cyberpunk 2077</li>
<li>Ori and the Blind Forest: Definitive Edition</li>
<li>Outer Wilds</li>
<li>Risk of Rain 2</li>
<li>Subnautica</li>
<li>The Elder Scrolls V: Skyrim</li>
<li>VRChat</li>
</ul>
<p>Data from ProtonDB is made available under the Open Database License: <a href="http://opendatacommons.org/licenses/odbl/1.0/">Link</a>. Any rights in individual contents of the database are licensed under the Database Contents License: <a href="http://opendatacommons.org/licenses/dbcl/1.0/">Link</a>. </p>
</div>
<div class="col-2"></div>
</div>
<div class="spacer"></div>
<div class="row">
<div class="col-4"></div>
<div class="col-4 roundCorners card">
<label>Show Only Working?</label>
<input id='working' type='checkbox' />
<br>
<label>Game Title</label>
<input id='gameTitle' type='text' />
<br>
<label>Linux Distro</label>
<input id='os' type='text' />
<br>
<label>CPU Model</label>
<input id='cpu' type='text' />
<br>
<label>GPU Model</label>
<input id='gpu' type='text' />
<br>
<label>Sort By Time Submitted</label>
<select id='sortTime'>
<option value=''></option>
<option value='ASC'>Ascending</option>
<option value='DESC'>Descending</option>
</select>
<br>
<button id='search'>View Reports</button>
</div>
<div class="col-4"></div>
<script>
const isEmpty = (obj) => Object.keys(obj).length === 0;
document.getElementById('search').addEventListener('click', (event) => {
const dataUrl = 'http://localhost:8888';
let ajaxParameters = {};
if (document.querySelector('#working:checked')) {
ajaxParameters.working = 1;
}
if (document.getElementById('gameTitle').value.length !== 0) {
ajaxParameters.gameTitle = document.getElementById('gameTitle').value;
}
if (document.getElementById('os').value.length !== 0) {
ajaxParameters.os = document.getElementById('os').value;
}
if (document.getElementById('cpu').value.length !== 0) {
ajaxParameters.cpu = document.getElementById('cpu').value;
}
if (document.getElementById('gpu').value.length !== 0) {
ajaxParameters.gpu = document.getElementById('gpu').value;
}
if (document.getElementById('sortTime').value.length !== 0) {
ajaxParameters.sortTime = document.getElementById('sortTime').value;
}
function niceOutput(a) {
if(a == 1) {
return "Yes";
} else {
return "No";
}
}
const xhttp = new XMLHttpRequest();
xhttp.onload = function() {
let displayTable = '<table>' +
'<thead>' +
'<tr>' +
'<th>Game Title</th>' +
'<th>Submitted</th>' +
'<th>Working?</th>' +
'<th>Distro</th>' +
'<th>CPU</th>' +
'<th>GPU</th>' +
'<th>Notes</th>' +
'</tr>' +
'</thead>' +
'<tbody>';
if (typeof this.responseText !== 'undefined' && this.responseText.length > 0) {
let ajaxResult = JSON.parse(this.responseText);
if (typeof ajaxResult.reports !== 'undefined') {
for (let x = 0; x < ajaxResult.reports.length; x++) {
displayTable += '<tr>' +
'<td>' + ajaxResult.reports[x].game_title + '</td>' +
'<td>' + new Date(ajaxResult.reports[x].timestamp * 1000).toLocaleDateString() + '</td>' +
'<td>' + niceOutput(ajaxResult.reports[x].verdict) + '</td>' +
'<td>' + ajaxResult.reports[x].systemInfo_os + '</td>' +
'<td>' + ajaxResult.reports[x].systemInfo_cpu + '</td>' +
'<td>' + ajaxResult.reports[x].systemInfo_gpu + '</td>' +
'<td>' + ajaxResult.reports[x].notes_summary + '</td>' +
'</tr>';
}
}
}
displayTable += '</tbody></table>';
document.getElementById('tableOutput').innerHTML = displayTable;
}
xhttp.open("GET", dataUrl + (!isEmpty(ajaxParameters) ? '?' + new URLSearchParams(ajaxParameters) : ''));
xhttp.send();
});
</script>
</div>
<div class="row">
<div class="col-1"></div>
<div class="col-10 overflow" id="tableOutput"></div>
<div class="col-1"></div>
</div>
<div id="footer-spacer"></div>
<div id="footer">
<div class="row">
<div class="col-6"></div>
<div class="col-6">
<img src="assets/img/logos/lunarpenguin.png" alt="Lunar Penguin Logo" id="footer-logo">
<span id="footer-text">Page created by <a href="https://lunarpenguin.net">Kaj Forney</a>, 2022.</span>
</div>
</div>
</div>
</body>
</html>