feat: revamp stats, style, and add nav

This commit is contained in:
Anna 2022-05-08 17:41:03 -04:00
parent be39ec07ca
commit 7378ed1445
15 changed files with 475 additions and 148 deletions

View File

@ -33,8 +33,8 @@ body {
margin: 0;
font-family: sans-serif;
background-color: var(--background);
color: var(--text);
/* background-color: var(--background); */
/* color: var(--text); */
}
.js body {

13
server/assets/common.js Normal file
View File

@ -0,0 +1,13 @@
(function () {
function setUpLanguage() {
let language = document.getElementById('language');
for (let elem of language.querySelectorAll('[data-value]')) {
elem.addEventListener('click', () => {
document.cookie = `lang=${encodeURIComponent(elem.dataset.value)};path=/;max-age=31536000;samesite=lax`;
window.location.reload();
});
}
}
setUpLanguage();
})();

2
server/assets/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -28,11 +28,15 @@
margin-top: 1em;
}
#container > .settings {
#container > .settings > .controls {
display: flex;
justify-content: space-between;
}
#container > .settings > .controls > .search {
margin-right: 1em;
}
#listings > .no-listings {
margin-top: 1em;
}
@ -45,11 +49,12 @@
margin: 0 -1em;
padding: 1em;
background-color: var(--row-background);
/* background-color: var(--row-background); */
}
#listings > .listing:nth-child(2n) {
background-color: var(--row-background-alternate);
background-color: var(--muted-border-color);
/* background-color: var(--row-background-alternate); */
}
#listings > .listing .description {

View File

@ -64,15 +64,14 @@
let language = document.getElementById('language');
if (state.lang === null) {
state.lang = language.dataset.accept;
let cookie = document.cookie
.split(';')
.find(row => row.trim().startsWith('lang='));
if (cookie !== undefined) {
state.lang = decodeURIComponent(cookie.split('=')[1]);
}
}
language.value = state.lang;
let cookie = document.cookie
.split(';')
.find(row => row.trim().startsWith('lang='));
if (cookie !== undefined) {
state.lang = decodeURIComponent(cookie.split('=')[1]);
}
}
function setUpList() {
@ -156,19 +155,9 @@
});
}
function setUpLanguage() {
let language = document.getElementById('language');
language.addEventListener('change', () => {
state.lang = language.value;
document.cookie = `lang=${encodeURIComponent(language.value)};path=/;max-age=31536000;samesite=lax`;
window.location.reload();
});
}
addJsClass();
saveLoadState();
reflectState();
setUpLanguage();
state.list = setUpList();
setUpDataCentreFilter();
setUpCategoryFilter();

5
server/assets/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
body {
margin: 1em;
margin-top: 1em;
}
.total {
@ -10,10 +10,15 @@ body {
}
.chart {
height: 50vh;
max-height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.chart canvas {
.chart svg {
max-width: 100%;
max-height: 100%;
}
@ -35,11 +40,12 @@ body {
}
.chart-containers .container table tr {
background-color: var(--row-background);
/* background-color: var(--row-background); */
}
.chart-containers .container table tr:nth-child(2n) {
background-color: var(--row-background-alternate);
/* background-color: var(--row-background-alternate); */
background-color: var(--muted-border-color);
}
.chart-containers .container table tr td {

View File

@ -85,42 +85,252 @@
return newData;
}
function makeChart(tableId, chartId, chartType, combine = false) {
function extractData(tableId, extractor = null) {
if (extractor === null) {
extractor = (cols) => {
return {
label: cols[0].innerHTML,
value: Number(cols[1].innerHTML),
};
};
}
let table = document.getElementById(tableId);
let data = [];
for (let row of table.querySelectorAll('tbody > tr')) {
let cols = row.querySelectorAll('td');
data.push({
x: cols[0].innerText,
y: Number(cols[1].innerText),
});
data.push(extractor(cols));
}
if (combine) {
data = combineTopN(data, 15);
}
new Chart(
document.getElementById(chartId),
{
type: chartType,
data: {
datasets: [{
data: data.map(entry => entry.y),
backgroundColor: colours,
}],
labels: data.map(entry => entry.x),
},
options: {
borderWidth: chartType === 'doughnut' ? 0 : 2,
...options,
},
},
);
return data;
}
makeChart('duties', 'dutiesChart', 'pie', true);
makeChart('hosts', 'hostsChart', 'doughnut');
makeChart('hours', 'hoursChart', 'bar');
makeChart('days', 'daysChart', 'bar');
function wrap(text) {
text.each(function () {
var text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
x = text.attr("x"),
y = text.attr("y"),
dy = 0, //parseFloat(text.attr("dy")),
tspan = text.text(null)
.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > text.attr('width') - 6) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", ++lineNumber * lineHeight + dy + "em")
.text(word);
}
}
});
}
function makeTreeMap(data, graphId, opts = {}) {
let drawLabels = opts.drawLabels === undefined ? true : opts.drawLabels;
let grouped = opts.grouped === undefined ? false : opts.grouped;
let elem = document.getElementById(graphId);
const [width, height] = [elem.offsetWidth, elem.offsetHeight];
let svg = d3.select(`#${graphId}`)
.append('svg')
.attr('viewBox', `0 0 ${width} ${height}`);
if (grouped) {
d3.treemap()
.size([width, height])
.paddingInner(2)
.paddingTop(4)
.paddingRight(4)
.paddingLeft(4)
.paddingBottom(4)
(data);
} else {
d3.treemap()
.size([width, height])
.paddingInner(2)
(data);
}
let a = 0;
let group = svg.selectAll('g')
.data(data.leaves())
.enter()
.append('g');
let title = (d) => `${d.data.label} (${(d.data.value / d.parent.value * 100).toFixed(2)}% - ${d.data.value.toLocaleString()})`;
group
.append('title')
.text(title);
group
.append('rect')
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.style('fill', d => {
let colour = colours[a];
a += 1;
a %= colours.length;
return colour;
});
if (drawLabels) {
svg.selectAll('text')
.data(data.leaves().filter(d => d.data.value / d.parent.value >= .05 ))
.enter()
.append('text')
.attr('x', d => d.x0 + 5)
.attr('y', d => d.y0 + 20)
.attr('width', d => d.x1 - d.x0)
.text(title)
.attr('font-size', '1em')
.attr('fill', 'white')
.call(wrap);
}
if (grouped) {
svg.selectAll('borders')
.data(data.descendants().filter(d => d.depth === 1))
.enter()
.append('rect')
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.attr('fill', 'none')
.attr('stroke', '#374956');
svg.selectAll('titles')
.data(data.descendants().filter(d => d.depth === 1))
.enter()
.append('text')
.attr('x', d => d.x1 - 2)
.attr('y', d => d.y1 - 2)
.attr('text-anchor', 'end')
.style('transform', d => {
if ((d.x1 - d.x0) < (d.y1 - d.y0)) {
return 'rotate(90deg)';
}
return null;
})
.style('transform-box', 'fill-box')
.style('transform-origin', '95%')
.text(d => d.data[0])
.attr("font-size", d => {
if (d === data) {
return "1em";
}
let width = d.x1 - d.x0, height = d.y1 - d.y0;
return Math.max(Math.min(width/5, height/2, Math.sqrt((width*width + height*height))/10), 9)
})
.attr('fill', 'white');
}
}
function makeBarPlot(data, graphId) {
let elem = document.getElementById(graphId);
const [marginLeft, marginRight, marginTop, marginBottom] = [100, 0, 16, 50];
const [width, height] = [elem.offsetWidth - marginLeft - marginRight, elem.offsetHeight - marginTop - marginBottom];
let svg = d3.select(`#${graphId}`)
.append('svg')
.attr('viewBox', `0 0 ${width + marginLeft + marginRight} ${height + marginTop + marginBottom}`)
.append('g')
.attr('transform', `translate(${marginLeft}, ${marginTop})`);
let x = d3.scaleBand()
.range([0, width])
.domain(data.map(d => d.label))
.padding(0.2);
svg.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
.attr('font-size', '1em')
.selectAll('text')
.style('text-anchor', 'middle')
.attr('font-size', '1em');
let y = d3.scaleLinear()
.domain([0, data.map(d => d.value).reduce((max, a) => Math.max(max, a))])
.range([height, 0]);
svg.append('g')
.call(d3.axisLeft(y))
.attr('font-size', '1em')
.selectAll('text')
.attr('font-size', '1em');
let sum = data.map(d => d.value).reduce((total, a) => total + a);
let colourIdx = 0;
let group = svg.selectAll('mybar')
.data(data)
.enter()
.append('g');
group.append('title')
.text(d => `${d.value} (${(d.value / sum * 100).toFixed(2)}%)`);
group.append('rect')
.attr('x', d => x(d.label))
.attr('y', d => y(d.value))
.attr('width', x.bandwidth())
.attr('height', d => height - y(d.value))
.attr('fill', d => {
let colour = colours[colourIdx];
colourIdx += 1;
colourIdx %= colours.length;
return colour;
});
}
makeTreeMap(
d3.hierarchy({
children: extractData('duties'),
}).sum(d => d.value),
'dutiesChart',
);
makeTreeMap(
d3.hierarchy(
d3.group(
extractData(
'hosts',
(cols) => {
return {
label: cols[1].innerHTML,
world: cols[0].innerHTML,
value: Number(cols[2].innerHTML),
};
},
),
d => d.world,
)
).sum(d => d.value),
'hostsChart',
{
drawLabels: false,
grouped: true,
},
);
makeBarPlot(
extractData('hours'),
'hoursChart',
);
makeBarPlot(
extractData('days'),
'daysChart',
);
})();

View File

@ -41,6 +41,15 @@ impl Language {
}
}
pub fn name(&self) -> &'static str {
match self {
Self::English => "english",
Self::Japanese => "日本語",
Self::German => "deutsch",
Self::French => "français",
}
}
pub fn from_codes(val: Option<&str>) -> Self {
let val = match val {
Some(v) => v,

View File

@ -14,27 +14,27 @@ pub struct CachedStatistics {
#[derive(Debug, Clone, Deserialize)]
pub struct Aliases {
#[serde(deserialize_with = "alias_de")]
pub aliases: HashMap<u32, Vec<Alias>>,
pub aliases: HashMap<u32, Alias>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Statistics {
pub count: Vec<Count>,
#[serde(default)]
pub aliases: HashMap<u32, Vec<Alias>>,
pub aliases: HashMap<u32, Alias>,
pub duties: Vec<DutyInfo>,
pub hosts: Vec<HostInfo>,
pub hours: Vec<HourInfo>,
pub days: Vec<DayInfo>,
}
fn alias_de<'de, D>(de: D) -> std::result::Result<HashMap<u32, Vec<Alias>>, D::Error>
where D: Deserializer<'de>
fn alias_de<'de, D>(de: D) -> std::result::Result<HashMap<u32, Alias>, D::Error>
where D: Deserializer<'de>
{
let aliases: Vec<AliasInfo> = Deserialize::deserialize(de)?;
let map = aliases
.into_iter()
.map(|info| (info.content_id_lower, info.aliases))
.map(|info| (info.content_id, info.alias))
.collect();
Ok(map)
}
@ -49,25 +49,17 @@ impl Statistics {
}
pub fn player_name(&self, cid: &u32) -> Cow<str> {
let aliases = match self.aliases.get(cid) {
let alias = match self.aliases.get(cid) {
Some(a) => a,
None => return "<unknown>".into(),
};
if aliases.is_empty() {
return "<unknown>".into();
}
let world = match crate::ffxiv::WORLDS.get(&aliases[0].home_world) {
let world = match crate::ffxiv::WORLDS.get(&alias.home_world) {
Some(world) => world.name(),
None => "<unknown>",
};
format!("{} @ {}", aliases[0].name.text(), world).into()
}
pub fn num_host_listings(&self) -> usize {
self.hosts.iter().map(|info| info.count).sum()
format!("{} @ {}", alias.name.text(), world).into()
}
}
@ -79,8 +71,8 @@ pub struct Count {
#[derive(Debug, Clone, Deserialize)]
pub struct AliasInfo {
#[serde(rename = "_id")]
pub content_id_lower: u32,
pub aliases: Vec<Alias>,
pub content_id: u32,
pub alias: Alias,
}
#[derive(Debug, Clone, Deserialize)]
@ -114,7 +106,28 @@ impl DutyInfo {
#[derive(Debug, Clone, Deserialize)]
pub struct HostInfo {
#[serde(rename = "_id")]
pub content_id_lower: u32,
pub created_world: u32,
pub count: usize,
pub content_ids: Vec<HostInfoInfo>,
}
impl HostInfo {
pub fn num_other(&self) -> usize {
let top15: usize = self.content_ids.iter().map(|info| info.count).sum();
self.count - top15
}
pub fn world_name(&self) -> &'static str {
match crate::ffxiv::WORLDS.get(&self.created_world) {
Some(world) => world.name(),
None => "<unknown>",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostInfoInfo {
pub content_id: u32,
pub count: usize,
}

View File

@ -149,6 +149,9 @@ fn assets() -> BoxedFilter<(impl Reply, )> {
.or(listings_js())
.or(stats_css())
.or(stats_js())
.or(d3())
.or(pico())
.or(common_js())
)
.boxed()
}
@ -202,6 +205,27 @@ fn stats_js() -> BoxedFilter<(impl Reply, )> {
.boxed()
}
fn d3() -> BoxedFilter<(impl Reply, )> {
warp::path("d3.js")
.and(warp::path::end())
.and(warp::fs::file("./assets/d3.v7.min.js"))
.boxed()
}
fn pico() -> BoxedFilter<(impl Reply, )> {
warp::path("pico.css")
.and(warp::path::end())
.and(warp::fs::file("./assets/pico.min.css"))
.boxed()
}
fn common_js() -> BoxedFilter<(impl Reply, )> {
warp::path("common.js")
.and(warp::path::end())
.and(warp::fs::file("./assets/common.js"))
.boxed()
}
fn index() -> BoxedFilter<(impl Reply, )> {
let route = warp::path::end()
.map(|| warp::redirect(Uri::from_static("/listings")));

View File

@ -37,20 +37,42 @@ lazy_static::lazy_static! {
"hosts": [
{
"$group": {
"_id": "$listing.content_id_lower",
"count": {
"$sum": 1
"_id": {
"world": "$listing.created_world",
"content_id": "$listing.content_id_lower",
},
"count": { "$sum": 1 },
}
},
{
"$sort": {
"count": -1
"count": -1,
}
},
{
"$limit": 15
}
"$group": {
"_id": "$_id.world",
"count": {
"$sum": "$count",
},
"content_ids": {
"$push": {
"content_id": "$_id.content_id",
"count": "$count",
}
}
}
},
{
"$addFields": {
"content_ids": {
"$slice": ["$content_ids", 0, 15],
},
}
},
{
"$sort": { "count": -1 }
},
],
"hours": [
{
@ -94,11 +116,16 @@ lazy_static::lazy_static! {
doc! {
"$facet": {
"aliases": [
{
"$sort": {
"created_at": -1,
}
},
{
"$group": {
"_id": "$listing.content_id_lower",
"aliases": {
"$addToSet": {
"alias": {
"$first": {
"name": "$listing.name",
"home_world": "$listing.home_world",
},
@ -141,7 +168,7 @@ async fn get_stats_internal(state: &State, docs: impl IntoIterator<Item = Docume
let doc = doc.ok_or_else(|| anyhow::anyhow!("missing document"))?;
let mut stats: Statistics = mongodb::bson::from_document(doc)?;
let ids: Vec<u32> = stats.hosts.iter().map(|host| host.content_id_lower).collect();
let ids: Vec<u32> = stats.hosts.iter().flat_map(|host| host.content_ids.iter().map(|entry| entry.content_id)).collect();
let mut aliases_query: Vec<Document> = ALIASES_QUERY.iter().cloned().collect();
aliases_query.insert(0, doc! {
"$match": {

View File

@ -1,11 +1,40 @@
<!doctype html>
<html lang="{{ lang.code() }}" class="no-js">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/assets/minireset.css"/>
{%- block head %}{% endblock -%}
</head>
<body>{% block body %}{% endblock %}</body>
<html lang="{{ lang.code() }}" class="no-js" data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/assets/minireset.css"/>
<link rel="stylesheet" href="/assets/pico.css"/>
<script defer src="/assets/common.js"></script>
{%- block head %}{% endblock -%}
</head>
<body>
<nav class="container">
<ul>
<li><strong><a href="/" class="contrast">xivpf</a></strong></li>
</ul>
<ul>
<li role="list" dir="rtl">
<a href="javascript:void(0)" aria-haspopup="listbox">stats</a>
<ul role="listbox">
<li><a href="/stats">all time</a></li>
<li><a href="/stats/7days">7 days</a></li>
</ul>
</li>
<li role="list" dir="rtl">
<a href="javascript:void(0)" aria-haspopup="listbox">{{ lang.name() }}</a>
<ul role="listbox" aria-haspopup="listbox" id="language" data-accept="{{ lang.code() }}">
<li><a href="javascript:void(0)" data-value="en">english</a></li>
<li><a href="javascript:void(0)" data-value="ja">日本語</a></li>
<li><a href="javascript:void(0)" data-value="de">deutsch</a></li>
<li><a href="javascript:void(0)" data-value="fr">français</a></li>
</ul>
</li>
</ul>
</nav>
<div class="container">
{% block body %}{% endblock %}
</div>
</body>
</html>

View File

@ -1,7 +1,7 @@
{% extends "_frame.html" %}
{% block title -%}
Remote Party Finder
xivpf - listings
{%- endblock %}
{% block head %}
@ -14,37 +14,39 @@ Remote Party Finder
{% block body %}
<div id="container">
<div class="requires-js settings">
<div class="left">
<input type="search" class="search" placeholder="Search"/>
<div class="controls">
<input type="search" class="search" placeholder="search"/>
<select id="data-centre-filter">
<option value="All">All</option>
<optgroup label="North America">
<option value="Aether">Aether</option>
<option value="Crystal">Crystal</option>
<option value="Primal">Primal</option>
<option value="All">all</option>
<optgroup label="north america">
<option value="Aether">aether</option>
<option value="Crystal">crystal</option>
<option value="Primal">primal</option>
</optgroup>
<optgroup label="Europe">
<option value="Chaos">Chaos</option>
<option value="Light">Light</option>
<optgroup label="europe">
<option value="Chaos">chaos</option>
<option value="Light">light</option>
</optgroup>
<optgroup label="Japan">
<option value="Elemental">Elemental</option>
<option value="Gaia">Gaia</option>
<option value="Mana">Mana</option>
<optgroup label="japan">
<option value="Elemental">elemental</option>
<option value="Gaia">gaia</option>
<option value="Mana">mana</option>
</optgroup>
<optgroup label="Oceania">
<option value="Materia">Materia</option>
<optgroup label="oceania">
<option value="Materia">materia</option>
</optgroup>
</select>
</div>
<div>
<details class="filter-controls">
<summary>Advanced</summary>
<summary>advanced</summary>
<div>
<div class="control">
<label>
Categories
<select multiple id="category-filter">
{%- for category in PartyFinderCategory::ALL %}
<option value="{{ category.as_str() }}">{{ category.name().text(lang) }}</option>
<option value="{{ category.as_str() }}">{{ category.name().text(lang) }}</option>
{%- endfor %}
</select>
</label>
@ -52,14 +54,6 @@ Remote Party Finder
</div>
</details>
</div>
<div class="right">
<select id="language" data-accept="{{ lang.code() }}">
<option value="en">English</option>
<option value="ja">日本語</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
</select>
</div>
</div>
<div id="listings" class="list">
{%- if containers.is_empty() %}
@ -68,10 +62,10 @@ Remote Party Finder
{%- for container in containers %}
{%- let listing = container.listing.borrow() %}
<div
class="listing"
data-id="{{ listing.id }}"
data-centre="{{ listing.data_centre_name().unwrap_or_default() }}"
data-pf-category="{{ listing.html_pf_category() }}">
class="listing"
data-id="{{ listing.id }}"
data-centre="{{ listing.data_centre_name().unwrap_or_default() }}"
data-pf-category="{{ listing.html_pf_category() }}">
<div class="left">
{%- let duty_class %}
{%- if listing.is_cross_world() %}

View File

@ -1,13 +1,13 @@
{% extends "_frame.html" %}
{% block title -%}
Remote Party Finder
xivpf - stats
{%- endblock %}
{% block head %}
<link rel="stylesheet" href="/assets/common.css"/>
<link rel="stylesheet" href="/assets/stats.css"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
<script defer src="/assets/d3.js"></script>
<script defer src="/assets/stats.js"></script>
{% endblock %}
@ -19,8 +19,7 @@ Remote Party Finder
<div class="chart-containers">
<div class="container">
<h1>Top categories</h1>
<div class="chart">
<canvas id="dutiesChart"></canvas>
<div id="dutiesChart" class="chart">
</div>
<details>
<summary>Details</summary>
@ -45,29 +44,33 @@ Remote Party Finder
<div class="container">
<h1>Top hosts</h1>
<div class="chart">
<canvas id="hostsChart"></canvas>
<div id="hostsChart" class="chart">
</div>
<details>
<summary>Details</summary>
<table id="hosts">
<thead>
<tr>
<th>Name</th>
<th>Count</th>
</tr>
<tr>
<th>World (created)</th>
<th>Name</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{%- for info in stats.hosts %}
<tr>
<td>{{ stats.player_name(info.content_id_lower) }}</td>
<td>{{ info.count }}</td>
</tr>
{%- endfor %}
<tr>
<td>Other</td>
<td>{{ stats.num_listings() - stats.num_host_listings() }}</td>
</tr>
{%- for info in stats.hosts %}
{%- for entry in info.content_ids %}
<tr>
<td>{{ info.world_name() }}</td>
<td>{{ stats.player_name(entry.content_id) }}</td>
<td>{{ entry.count }}</td>
</tr>
{%- endfor %}
<tr>
<td>{{ info.world_name() }}</td>
<td>Other</td>
<td>{{ info.num_other() }}
</tr>
{%- endfor %}
</tbody>
</table>
</details>
@ -75,8 +78,7 @@ Remote Party Finder
<div class="container">
<h1>Top hours (UTC)</h1>
<div class="chart">
<canvas id="hoursChart"></canvas>
<div id="hoursChart" class="chart">
</div>
<details>
<summary>Details</summary>
@ -101,8 +103,7 @@ Remote Party Finder
<div class="container">
<h1>Top days (UTC)</h1>
<div class="chart">
<canvas id="daysChart"></canvas>
<div id="daysChart" class="chart">
</div>
<details>
<summary>Details</summary>