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; margin: 0;
font-family: sans-serif; font-family: sans-serif;
background-color: var(--background); /* background-color: var(--background); */
color: var(--text); /* color: var(--text); */
} }
.js body { .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; margin-top: 1em;
} }
#container > .settings { #container > .settings > .controls {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
#container > .settings > .controls > .search {
margin-right: 1em;
}
#listings > .no-listings { #listings > .no-listings {
margin-top: 1em; margin-top: 1em;
} }
@ -45,11 +49,12 @@
margin: 0 -1em; margin: 0 -1em;
padding: 1em; padding: 1em;
background-color: var(--row-background); /* background-color: var(--row-background); */
} }
#listings > .listing:nth-child(2n) { #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 { #listings > .listing .description {

View File

@ -64,15 +64,14 @@
let language = document.getElementById('language'); let language = document.getElementById('language');
if (state.lang === null) { if (state.lang === null) {
state.lang = language.dataset.accept; 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() { 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(); addJsClass();
saveLoadState(); saveLoadState();
reflectState(); reflectState();
setUpLanguage();
state.list = setUpList(); state.list = setUpList();
setUpDataCentreFilter(); setUpDataCentreFilter();
setUpCategoryFilter(); 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 { body {
margin: 1em; margin-top: 1em;
} }
.total { .total {
@ -10,10 +10,15 @@ body {
} }
.chart { .chart {
height: 50vh;
max-height: 50vh; max-height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
} }
.chart canvas { .chart svg {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
@ -35,11 +40,12 @@ body {
} }
.chart-containers .container table tr { .chart-containers .container table tr {
background-color: var(--row-background); /* background-color: var(--row-background); */
} }
.chart-containers .container table tr:nth-child(2n) { .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 { .chart-containers .container table tr td {

View File

@ -85,42 +85,252 @@
return newData; 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 table = document.getElementById(tableId);
let data = []; let data = [];
for (let row of table.querySelectorAll('tbody > tr')) { for (let row of table.querySelectorAll('tbody > tr')) {
let cols = row.querySelectorAll('td'); let cols = row.querySelectorAll('td');
data.push({ data.push(extractor(cols));
x: cols[0].innerText,
y: Number(cols[1].innerText),
});
} }
if (combine) { return data;
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,
},
},
);
} }
makeChart('duties', 'dutiesChart', 'pie', true); function wrap(text) {
makeChart('hosts', 'hostsChart', 'doughnut'); text.each(function () {
makeChart('hours', 'hoursChart', 'bar'); var text = d3.select(this),
makeChart('days', 'daysChart', 'bar'); 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 { pub fn from_codes(val: Option<&str>) -> Self {
let val = match val { let val = match val {
Some(v) => v, Some(v) => v,

View File

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

View File

@ -149,6 +149,9 @@ fn assets() -> BoxedFilter<(impl Reply, )> {
.or(listings_js()) .or(listings_js())
.or(stats_css()) .or(stats_css())
.or(stats_js()) .or(stats_js())
.or(d3())
.or(pico())
.or(common_js())
) )
.boxed() .boxed()
} }
@ -202,6 +205,27 @@ fn stats_js() -> BoxedFilter<(impl Reply, )> {
.boxed() .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, )> { fn index() -> BoxedFilter<(impl Reply, )> {
let route = warp::path::end() let route = warp::path::end()
.map(|| warp::redirect(Uri::from_static("/listings"))); .map(|| warp::redirect(Uri::from_static("/listings")));

View File

@ -37,20 +37,42 @@ lazy_static::lazy_static! {
"hosts": [ "hosts": [
{ {
"$group": { "$group": {
"_id": "$listing.content_id_lower", "_id": {
"count": { "world": "$listing.created_world",
"$sum": 1 "content_id": "$listing.content_id_lower",
}, },
"count": { "$sum": 1 },
} }
}, },
{ {
"$sort": { "$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": [ "hours": [
{ {
@ -94,11 +116,16 @@ lazy_static::lazy_static! {
doc! { doc! {
"$facet": { "$facet": {
"aliases": [ "aliases": [
{
"$sort": {
"created_at": -1,
}
},
{ {
"$group": { "$group": {
"_id": "$listing.content_id_lower", "_id": "$listing.content_id_lower",
"aliases": { "alias": {
"$addToSet": { "$first": {
"name": "$listing.name", "name": "$listing.name",
"home_world": "$listing.home_world", "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 doc = doc.ok_or_else(|| anyhow::anyhow!("missing document"))?;
let mut stats: Statistics = mongodb::bson::from_document(doc)?; 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(); let mut aliases_query: Vec<Document> = ALIASES_QUERY.iter().cloned().collect();
aliases_query.insert(0, doc! { aliases_query.insert(0, doc! {
"$match": { "$match": {

View File

@ -1,11 +1,40 @@
<!doctype html> <!doctype html>
<html lang="{{ lang.code() }}" class="no-js"> <html lang="{{ lang.code() }}" class="no-js" data-theme="dark">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/assets/minireset.css"/> <link rel="stylesheet" href="/assets/minireset.css"/>
{%- block head %}{% endblock -%} <link rel="stylesheet" href="/assets/pico.css"/>
</head> <script defer src="/assets/common.js"></script>
<body>{% block body %}{% endblock %}</body> {%- 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> </html>

View File

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

View File

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