gimm260-data-visualization/index.html

1216 lines
57 KiB
HTML
Raw Permalink Normal View History

2022-12-12 18:23:22 -07:00
<!doctype html>
2022-10-25 10:09:29 -06:00
<html lang="en">
<head>
<meta charset="UTF-8">
2022-12-12 18:23:22 -07:00
<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">
2022-12-13 01:08:09 -07:00
<title>Kaj Forney -- GIMM 260 Data Visualization Narrative</title>
2022-12-12 18:23:22 -07:00
2022-12-18 13:09:27 -07:00
<link rel="stylesheet" href="assets/css/main.css">
2022-12-12 18:23:22 -07:00
<script src="https://kit.fontawesome.com/55072ce5fd.js" crossorigin="anonymous"></script>
2022-10-25 10:09:29 -06:00
</head>
2022-12-12 18:23:22 -07:00
<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>
2022-12-18 13:09:27 -07:00
<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>
2022-12-12 18:23:22 -07:00
</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>
2022-12-18 13:09:27 -07:00
<div class="row reverseOnMobile">
2022-12-12 18:23:22 -07:00
<div class="col-2"></div>
2022-12-18 13:09:27 -07:00
<div class="col-3 bigIcon lastOnMobile">
2022-12-12 18:23:22 -07:00
<i class="fa-solid fa-wine-glass wobble"></i>
</div>
<div class="col-1"></div>
2022-12-18 13:09:27 -07:00
<div class="col-5 roundCorners card firstOnMobile">
2022-12-12 18:23:22 -07:00
<h2>WINE</h2>
2022-12-18 13:09:27 -07:00
<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>
2022-12-12 18:23:22 -07:00
</div>
</div>
2022-12-18 13:09:27 -07:00
<div class="spacer"></div>
2022-12-12 18:23:22 -07:00
<div class="row">
<div class="col-1"></div>
<div class="col-5 roundCorners card">
<h2>Proton</h2>
2022-12-18 13:09:27 -07:00
<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>
2022-12-12 18:23:22 -07:00
</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>
2022-12-18 13:09:27 -07:00
<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>
2022-12-12 18:23:22 -07:00
</div>
<div class="col-1"></div>
<div class="col-5 roundCorners card">
2022-12-18 13:09:27 -07:00
<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>
2022-12-12 18:23:22 -07:00
</div>
<div class="col-1"></div>
</div>
<div class="spacer"></div>
2022-12-18 13:09:27 -07:00
<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>
2022-12-12 18:23:22 -07:00
2022-12-13 01:08:09 -07:00
<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>
2022-12-12 18:23:22 -07:00
<div id="footer-spacer"></div>
<div id="footer">
<div class="row">
<div class="col-6"></div>
<div class="col-6">
2022-12-18 13:09:27 -07:00
<img src="assets/img/logos/lunarpenguin.png" alt="Lunar Penguin Logo" id="footer-logo">
2022-12-12 18:23:22 -07:00
<span id="footer-text">Page created by <a href="https://lunarpenguin.net">Kaj Forney</a>, 2022.</span>
</div>
</div>
</div>
2022-10-25 10:09:29 -06:00
</body>
2022-12-12 18:23:22 -07:00
</html>