feat: revamp stats, style, and add nav
This commit is contained in:
parent
be39ec07ca
commit
7378ed1445
|
@ -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 {
|
||||||
|
|
|
@ -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();
|
||||||
|
})();
|
File diff suppressed because one or more lines are too long
|
@ -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 {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -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 {
|
||||||
|
|
|
@ -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',
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")));
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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() %}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue