cool graph

This commit is contained in:
Alex Bates 2021-01-07 12:53:47 +00:00
parent 8f76f0a1b5
commit ec241dcd38
No known key found for this signature in database
GPG Key ID: 7531C5E1D6B1CA9A
12 changed files with 659 additions and 4 deletions

5
.postcssrc.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
"postcss-preset-env": true,
},
}

View File

@ -3,8 +3,16 @@
"start": "parcel src/index.html",
"build": "parcel build src/index.html"
},
"dependencies": {},
"browserslist": "> 1% and last 2 years",
"dependencies": {
"clsx": "^1.1.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"recharts": "^2.0.0",
"use-element-dimensions": "^2.1.3"
},
"devDependencies": {
"parcel-bundler": "^1.12.4"
"parcel-bundler": "^1.12.4",
"postcss-preset-env": "^6.7.0"
}
}

162
src/ProgressPane.jsx Normal file
View File

@ -0,0 +1,162 @@
import React, { useState, useEffect } from "react"
import { createPortal } from "react-dom"
import { Area, XAxis, YAxis, AreaChart, CartesianGrid, Tooltip } from "recharts"
import useDimensions from "use-element-dimensions"
const csvVersions = {
"1": {
timestamp: parseInt,
commit: s => s,
totalFuncs: parseInt,
nonMatchingFuncs: parseInt,
matchingFuncs: parseInt,
totalBytes: b => parseInt(b),
nonMatchingBytes: b => parseInt(b),
matchingBytes: b => parseInt(b),
},
}
async function fetchData() {
const csv = await fetch("https://papermar.io/reports/progress.csv")
.then(response => response.text())
return csv
.split("\n")
.filter(row => row.length)
.map(row => {
const [version, ...data] = row.split(",")
const structure = csvVersions[version]
const obj = {}
for (const [key, transform] of Object.entries(structure)) {
obj[key] = transform(data.shift())
}
obj.percentBytes = Math.round((obj.matchingBytes / obj.totalBytes) * 100)
return obj
})
}
export default function ProgressPane({ captionPortal }) {
const [data, setData] = useState(null)
useEffect(() => {
fetchData()
.then(data => setData(data))
}, [])
// TODO: cute spin animation when data loads
return <div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
{data && <DataView data={data} captionPortal={captionPortal}/>}
{!data && "Loading..."}
</div>
}
const MONTH = 2678400
function DataView({ data, captionPortal }) {
const [chartDimensions, chartRef] = useDimensions()
const latest = data[data.length - 1]
const oldest = data[0]
let monthDates = []
for (let i = new Date(1583020800000); i < new Date(latest.timestamp); i.setMonth(i.getMonth() + 1)) {
if (i > new Date(oldest.timestamp)) {
monthDates.push(i)
}
}
console.log(monthDates)
const [selectedEntry, setSelectedEntry] = useState(null)
function renderTooltip(tip) {
const entry = data.find(row => row.timestamp === tip.label)
setSelectedEntry(entry)
return <span/>
}
return <>
<table width="250" className="outline-invert">
<tbody>
<tr>
<td>Matched</td>
<td className="thin align-right">{Math.round((latest.matchingBytes / latest.totalBytes) * 10000) / 100}%</td>
</tr>
</tbody>
</table>
<div className="shadow-box flex-grow">
<div className="shadow-box-inner" style={{ paddingRight: ".7em", paddingTop: ".7em", "--text-outline": "transparent" }}>
<div className="progress-chart" ref={chartRef}>
{chartDimensions.width > 0 && <AreaChart width={chartDimensions.width} height={chartDimensions.height} data={data}>
<XAxis dataKey="timestamp" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={monthDates} tickFormatter={formatDate}/>
<YAxis type="number" unit="%" domain={[0, 100]} tickCount={11}/>
<CartesianGrid stroke="#eee" horizontalPoints={monthDates}/>
<Area
type="linear"
dataKey="percentBytes"
unit="%"
stroke="#e3ac34" strokeWidth={2}
fill="#edc97e"
dot={true}
isAnimationActive={false}
/>
<Tooltip content={renderTooltip}/>
</AreaChart>}
</div>
</div>
<button className="shadow-box-title yellow">
{selectedEntry ? formatDate(selectedEntry.timestamp, {
dateStyle: "long",
timeStyle: "short",
}) : ""}
</button>
</div>
{selectedEntry && captionPortal.current && createPortal(<EntryInfo entry={selectedEntry}/>, captionPortal.current)}
</>
}
function formatDate(timestamp, options={}) {
const date = new Date(timestamp * 1000)
return new Intl.DateTimeFormat([], options).format(date)
}
function EntryInfo({ entry }) {
/*const [commitMessage, setCommitMessage] = useState(null)
useEffect(async () => {
fetch(`https://api.github.com/repos/ethteck/papermario/commits/${entry.commit}`)
.then(resp => resp.json())
.then(resp => {
setCommitMessage(resp.commit.message.split("\n")[0])
})
}, [entry.commit])*/
return <div>
<a href={`https://github.com/ethteck/papermario/commit/${entry.commit}`}>
{entry.commit.substr(0, 8)}
</a>
<table>
<tbody>
<tr>
<td width="200">Matched</td>
<td className="thin align-right">
{Math.round((entry.matchingBytes / entry.totalBytes) * 10000) / 100}%
({entry.matchingFuncs}/{entry.totalFuncs} functions)
</td>
</tr>
</tbody>
</table>
</div>
}

BIN
src/bg/caption-checker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

BIN
src/bg/red-checker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

BIN
src/bg/yellow-checker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

214
src/index.css Normal file
View File

@ -0,0 +1,214 @@
@font-face {
font-family: "Paper Mario Dialog Redesigned";
src: url("pmdialog2.woff2") format("woff2");
}
html {
--dark: #313131;
--light: #d6d6ce;
font-size: 28px;
font-family: "Paper Mario Dialog Redesigned", sans-serif;
font-weight: 700;
line-height: 1.2;
color: var(--light);
--text-outline: var(--dark);
background: #090942; /* TODO: dynamic star bg */
overflow: hidden;
}
* {
box-sizing: border-box;
/* https://owumaro.github.io/text-stroke-generator/ */
text-shadow: var(--text-outline) 3px 0px 0px, var(--text-outline) 2.83487px 0.981584px 0px, var(--text-outline) 2.35766px 1.85511px 0px, var(--text-outline) 1.62091px 2.52441px 0px, var(--text-outline) 0.705713px 2.91581px 0px, var(--text-outline) -0.287171px 2.98622px 0px, var(--text-outline) -1.24844px 2.72789px 0px, var(--text-outline) -2.07227px 2.16926px 0px, var(--text-outline) -2.66798px 1.37182px 0px, var(--text-outline) -2.96998px 0.42336px 0px, var(--text-outline) -2.94502px -0.571704px 0px, var(--text-outline) -2.59586px -1.50383px 0px, var(--text-outline) -1.96093px -2.27041px 0px, var(--text-outline) -1.11013px -2.78704px 0px, var(--text-outline) -0.137119px -2.99686px 0px, var(--text-outline) 0.850987px -2.87677px 0px, var(--text-outline) 1.74541px -2.43999px 0px, var(--text-outline) 2.44769px -1.73459px 0px, var(--text-outline) 2.88051px -0.838247px 0px;
}
a:any-link {
color: #3796ff;
--text-outline: #d3e5f9;
text-decoration: none;
}
button {
all: unset;
border-radius: 100em;
padding: 0 .3em .2em .3em;
cursor: pointer;
filter: brightness(1.0);
will-change: filter;
transition: filter 200ms;
}
button.red {
background: linear-gradient(#f04e54, #f04e54 10%, #b4313e 25%, #b4313e 90%, #7f1729 95%);
}
button.yellow {
background: linear-gradient(#f0c74e, #f0c74e 10%, #b48b31 25%, #b48b31 90%, #7f5617 95%);
}
button.inactive {
filter: brightness(0.6);
}
button:hover {
filter: brightness(1.0);
}
.tab {
margin-right: .25em;
}
#container {
/* TODO: use flex on body */
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
width: 1400px;
max-width: calc(100vw - 10vh);
height: 1200px;
max-height: calc(100vh - 10vw);
}
nav {
margin-bottom: -.6em;
z-index: 1;
}
main {
flex: 1;
width: 100%;
background-repeat: repeat;
background-size: 32px;
background-position: center;
image-rendering: pixelated;
border-radius: 1rem;
padding: 1em;
overflow-y: auto;
display: flex;
flex-direction: column;
transition: 600ms transform ease-in-out;
}
main.red {
background-color: #e2b6b3;
background-image: url(bg/red-checker.png);
}
main.yellow {
background-color: #e2d8b3;
background-image: url(bg/yellow-checker.png);
}
main > * {
image-rendering: initial;
}
.shadow-box {
background: #ffffff66;
padding: 1em;
margin: .5em;
margin-bottom: 1em;
border-radius: 16px;
box-shadow: .5em .5em 3px -.2em #00000044;
border-top: 4px solid #ffffff33;
padding-top: calc(1em - 2px);
border-left: 2px solid #ffffff33;
border-right: 2px solid #00000055;
border-bottom: 2px solid #00000055;
position: relative;
}
.shadow-box-inner {
border-radius: 12px;
box-shadow: inset .3em .3em 3px #00000033;
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
background: white;
}
button.shadow-box-title {
position: absolute;
left: 50%;
bottom: -1em;
transform: translateX(-50%);
width: 60%;
text-align: center;
border-radius: 1em;
padding-top: .2em;
height: 1.2em;
}
.caption {
background-image: url(bg/caption-checker.png);
background-repeat: repeat;
background-size: 32px;
background-position: center;
image-rendering: pixelated;
border-radius: 1rem;
width: 85%;
height: 3rem;
padding: 8px 16px;
margin-top: -14px;
z-index: 1;
}
.progress-chart {
flex: 1;
font-size: 12px;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.outline-invert {
color: var(--dark);
--text-outline: var(--light);
}
.thin {
font-weight: 400;
text-shadow: var(--text-outline) 2px 0px 0px, var(--text-outline) 1.75517px 0.958851px 0px, var(--text-outline) 1.0806px 1.68294px 0px, var(--text-outline) 0.141474px 1.99499px 0px, var(--text-outline) -0.832294px 1.81859px 0px, var(--text-outline) -1.60229px 1.19694px 0px, var(--text-outline) -1.97998px 0.28224px 0px, var(--text-outline) -1.87291px -0.701566px 0px, var(--text-outline) -1.30729px -1.5136px 0px, var(--text-outline) -0.421592px -1.95506px 0px, var(--text-outline) 0.567324px -1.91785px 0px, var(--text-outline) 1.41734px -1.41108px 0px, var(--text-outline) 1.92034px -0.558831px 0px;
}
.flex-row { display: flex; flex-direction: row }
.flex-grow { flex: 1; height: 0 }
.flex-spacer { width: 1em; height: 1em }
.align-right { text-align: right }

View File

@ -1,11 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta charset="utf-8"/>
<title>Paper Mario Reverse Engineering</title>
<link rel="stylesheet" href="index.css"/>
<script async defer src="main.jsx"></script>
</head>
<body>
Hello world!
<div id="container">
<!--
<nav>
<button>Info</button>
<button>Progress</button>
<button>Map</button>
<button>Party</button>
<button>Spirits</button>
<button>Map</button>
</nav>
<main id="main">
<table class="outline-invert" width="400">
<tr>
<td>Overall</td>
<td class="thin align-right" id="matched-rom-percent"></td>
</tr>
<tr>
<td>Functions</td>
<td class="thin align-right" id="functions-ratio"></td>
</tr>
<tr>
<td>Play Time</td>
<td class="thin align-right" id="play-time"></td>
</tr>
</table>
<div class="shadow-box flex-grow" id="progress-chart-container">
<canvas class="shadow-box-inner" id="progress-chart"></canvas>
<div class="shadow-box-title" id="progress-chart-tooltip-title"></div>
</div>
<div id="progress-chart-tooltip-description"></div>
</main>
-->
</div>
</body>
</html>

14
src/index.js Normal file
View File

@ -0,0 +1,14 @@
import moment from "moment"
import("./progress.js").then(async ({ fetchData, functionsChart }) => {
const data = await fetchData()
functionsChart(data, document.getElementById("progress-chart"))
const first = data[0]
const latest = data[data.length - 1]
document.getElementById("matched-rom-percent").innerText = Math.round((latest.matchingBytes / latest.totalBytes) * 10000) / 100 + "% matched" // TODO: include data
document.getElementById("functions-ratio").innerText = latest.matchingFuncs + "/" + latest.totalFuncs
document.getElementById("play-time").innerText = moment(latest.timestamp).from(first.timestamp, true)
}).catch(console.error)

94
src/main.jsx Normal file
View File

@ -0,0 +1,94 @@
import React, { useState, useRef, useEffect } from "react"
import ReactDOM from "react-dom"
import clsx from "clsx"
import ProgressPane from "./ProgressPane"
const tabs = [
{
slug: "/",
name: "Info",
color: "red",
pane: () => <div>I am the info page</div>,
},
{
slug: "/progress",
name: "Progress",
color: "yellow",
pane: (props) => <ProgressPane {...props}/>
},
]
let routedTabIndex = tabs.findIndex(tab => tab.slug === document.location.pathname)
if (routedTabIndex == -1) routedTabIndex = 0
function App() {
const [paneIndex, setPaneIndex] = useState(routedTabIndex)
const [tabIndex, setTabIndex] = useState(routedTabIndex)
const [rotation, setRotation] = useState(0)
const [flip, setFlip] = useState(false)
const pane = useRef()
let lockTabs = false // not state
function switchToTab(index) {
if (index === paneIndex || index === tabIndex) return
console.info("switching to tab", index)
setRotation(rotation - 180)
setTabIndex(index)
history.pushState(null, tabs[index].name, tabs[index].slug)
setTimeout(() => {
setPaneIndex(index)
setFlip(!flip)
}, 300) // half the animation time
}
useEffect(() => {
function listener() {
switchToTab(tabs.findIndex(tab => tab.slug === document.location.pathname))
}
window.addEventListener("popstate", listener)
return () => window.removeEventListener("popstate", listener)
}, [rotation, flip, tabIndex, paneIndex])
const captionPortal = useRef()
return <>
<nav>
{tabs.map((tab, index) => {
return <button
key={tab.name}
className={clsx("tab", tab.color, { "inactive": index !== tabIndex })}
onClick={() => {
if (lockTabs) return
lockTabs = true
switchToTab(index)
}}
>
{tab.name}
</button>
})}
</nav>
<main id="main" ref={pane} className={clsx(tabs[paneIndex].color)} style={{
transform: `perspective(4000px) rotateX(${rotation}deg)`,
}}>
<div style={{
display: "flex",
flex: 1,
transform: `rotateX(${flip ? '180deg' : '0deg'})`,
overflow: "hidden",
}}>
{tabs[paneIndex].pane({ captionPortal })}
</div>
</main>
<div class="caption outline-invert" ref={captionPortal}></div>
</>
}
ReactDOM.render(<App/>, document.getElementById("container"))

BIN
src/pmdialog2.woff2 Normal file

Binary file not shown.

121
src/progress.js Normal file
View File

@ -0,0 +1,121 @@
import Chart from "chart.js"
const csvVersions = {
"1": {
timestamp: s => new Date(parseInt(s) * 1000),
commit: s => s,
totalFuncs: parseInt,
nonMatchingFuncs: parseInt,
matchingFuncs: parseInt,
totalBytes: parseInt,
nonMatchingBytes: parseInt,
matchingBytes: parseInt,
},
}
export async function fetchData() {
const csv = await fetch("https://papermar.io/reports/progress.csv")
.then(response => response.text())
return csv
.split("\n")
.filter(row => row.length)
.map(row => {
const [version, ...data] = row.split(",")
const structure = csvVersions[version]
const obj = {}
for (const [key, transform] of Object.entries(structure)) {
obj[key] = transform(data.shift())
}
return obj
})
}
export async function functionsChart(data, ctx) {
return new Chart(ctx, {
type: "line",
data: {
datasets: [
{
label: "Matching Functions",
borderColor: "transparent",
backgroundColor: "#7f5617",
pointBackgroundColor: "transparent",
pointBorderColor: "black",
data: data.map(row => {
return {
x: row.timestamp,
y: row.matchingFuncs,
}
}),
lineTension: 0,
},
{
label: "Split Functions",
borderColor: "transparent",
backgroundColor: "#b48b31",
pointBackgroundColor: "transparent",
pointBorderColor: "black",
data: data.map(row => {
return {
x: row.timestamp,
y: row.totalFuncs,
}
}),
lineTension: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
xAxes: [
{
type: "time",
//distribution: "series",
ticks: {
max: Date.now(),
fontFamily: "Paper Mario Dialog Redesigned",
},
time: {
isoWeekday: true,
unit: "month",
},
gridLines: {
drawBorder: true,
}
},
],
yAxes: [
{
label: "Functions",
ticks: {
fontFamily: "Paper Mario Dialog Redesigned",
}
}
]
},
tooltips: {
enabled: false,
intersect: false,
custom(tooltipModel) {
const title = document.getElementById("progress-chart-tooltip-title")
const desc = document.getElementById("rogress-chart-tooltip-description")
if (!tooltipModel.dataPoints) {
title.innerText = ""
desc.innerText = ""
}
const row = data[tooltipModel.dataPoints[0].index]
console.log(row)
title.innerText = row.commit
},
},
},
})
}