This is the template project that's checked out and configured when you run the bando-up command from ljsthw-bandolier. This is where the code really lives.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
bandolier-template/admin/pages/Stats.svelte

343 lines
9.4 KiB

<script>
import { onMount } from 'svelte';
import api from "$/client/api.js";
import Source from "$/client/components/Source.svelte";
import Spinner from "$/client/components/Spinner.svelte";
import Icon from "$/client/components/Icon.svelte";
import IsVisible from "$/client/components/IsVisible.svelte";
import { defer, random_numbers } from "$/client/helpers.js";
let chart_load_promise = defer();
let stats = {};
let by_chains = [];
let original_data = [];
let search_text = "";
let search_error = "";
let dates = [];
let combine_results = false;
let combined_chains = [];
const date_key = (date) => {
// construct a consistent date format
const d = new Date(date);
// month is zero based
return `${d.getMonth()+1}-${d.getDate()}`
}
const new_result = () => {
// the idea is to initialize every chain's map with dates set to zero
const init_keys = dates.map(d => [date_key(d), 0]);
// then when it's analyzed later we'll have a full list
return {
hits_by_date: Object.fromEntries(init_keys),
sum: 0,
id: random_numbers(1)[0]
};
}
const transform_by_chains = (data, min=1) => {
const result = {};
// sort keys for dates
dates = Object.keys(data).sort();
for(let date of dates) {
const chains = data[date];
for(let [chain, chain_stats] of Object.entries(chains)) {
const result_chain = result[chain] || new_result();
// convert the date to get the DOW?
result_chain.hits_by_date[date_key(date)] = chain_stats.count;
result_chain.sum += chain_stats.count;
result[chain] = result_chain;
}
}
// now remove any stats that are too thin to care about
const filtered = [];
for(let chain in result) {
// keep anything with register in it, but only if we aren't combining results
if(combine_results || chain.includes("register") || result[chain].sum > min) {
filtered.push([chain, result[chain]]);
}
}
return filtered.sort((a, b) => b[1].sum - a[1].sum);
}
const calc_stats = (data) => {
let sum = 0;
let sumsq = 0;
const count = data.length;
let min = 0;
let max = 0;
for(let sample of data) {
sum += sample;
sumsq += sample * sample;
min = sample < min ? sample : min;
max = sample > max ? sample : max;
}
// sqrt( (self.sumsq - ( self.sum * self.sum / self.count )) / (self.count - 1) )
// normally you include count - 1 for accurate stats, but that results in divide by 0 so screw it
let stddev = Math.sqrt((sumsq - (sum * sum / count)) / (count));
let mean = sum / count;
let upper = mean + (2 * stddev);
let lower = mean - (2 * stddev);
return {sum, sumsq, count, mean, upper, lower, min, max, stddev};
}
const config_chart = () => {
Chart.defaults.font.size = 16;
}
const create_chart = (title, data) => {
const { hits_by_date, id } = data;
const labels = Object.keys(hits_by_date);
const counts = Object.values(hits_by_date);
const ctx = document.getElementById(`chart-${id}`);
// sometimes it scrolls by too fast or is registered but not visible
if(!ctx) return;
// should do this dynamically on the fly instead
const info = calc_stats(counts);
const chart_max = Math.max(info.max, info.upper);
// the only problem with the data is it's sparse and missing dates
try {
const chart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data: counts,
fill: false,
color: 'hsl(0, 0%, 10%)',
borderColor: 'hsl(0, 0%, 20%)',
backgroundColor: 'hsl(0, 0%, 30%)',
tension: 0.1,
pointRadius: counts.length > 40 ? 0 : 2,
borderWidth: 1,
}]
},
options: {
scales: {
y: {
beginAtZero: true,
max: chart_max * 1.1,
min: 0,
}
},
plugins: {
title: {
display: true,
text: `${Math.round(info.mean)} +/- ${Math.round(info.upper)}`,
align: 'end',
position: 'bottom',
},
legend: {
display: false
},
annotation: {
annotations: {
mean: {
type: 'line',
yMin: info.mean,
yMax: info.mean,
borderColor: 'hsl(100, 50%, 50%)',
borderWidth: 1,
},
top: {
type: 'line',
yMin: info.upper,
yMax: info.upper,
borderColor: 'hsl(0, 50%, 60%)',
borderWidth: 1,
},
bottom: {
type: 'line',
yMin: info.lower,
yMax: info.lower,
borderColor: 'hsl(0, 50%, 60%)',
borderWidth: 1,
},
mid_top: {
type: 'line',
yMin: info.mean + info.stddev,
yMax: info.mean + info.stddev,
borderColor: 'hsl(50, 50%, 60%)',
borderWidth: 1,
},
mid_bottom: {
type: 'line',
yMin: info.mean - info.stddev,
yMax: info.mean - info.stddev,
borderColor: 'hsl(50, 50%, 60%)',
borderWidth: 1,
},
}
}
},
}
});
} catch(error) {
// sometimes you get an error about a chart needing to be destroyed,
// most likely because the HTML node is hidden by Svelte
// but I'm not sure how to do that, and it doesn't seem to
// cause problems so just log it
console.error(error.message);
}
}
const calculate_combination = (result) => {
const all_chains = [];
const first = new_result();
for(let [urls, stats] of result) {
all_chains.push([stats.sum, urls]);
for(let key in first.hits_by_date) {
first.hits_by_date[key] += stats.hits_by_date[key];
}
}
combined_chains = all_chains;
return [["", first]];
}
const search = () => {
try {
chart_load_promise = defer();
let result = [];
if(search_text.trim() === "") {
result = original_data;
} else {
const pattern = new RegExp(search_text);
result = original_data.filter(data => {
return data[0].match(pattern);
});
}
if(combine_results) {
by_chains = calculate_combination(result);
} else {
// easy just assign it to get svelte going
by_chains = result;
}
// reset the error
search_error = "";
} catch(error) {
search_error = error.message;
} finally {
chart_load_promise.resolve();
}
}
const toggle_combined = () => {
combine_results = !combine_results;
search();
}
const reset_search = () => {
chart_load_promise = defer();
// reset the results
by_chains = original_data;
// clear the errors
search_error = "";
chart_load_promise.resolve();
}
$: if(search_text === "") {
reset_search();
} else {
search();
}
onMount(async () => {
const [status, data] = await api.get("/reports/chains.json");
if(status === 200) {
stats = data;
// to enable search, keep an original, and by_chains for results
original_data = transform_by_chains(stats.result);
by_chains = original_data;
} else {
console.error("Invalid response", status, data);
}
});
</script>
<style>
#chart-container {
min-height: 200px;
}
search-bar {
display: flex;
align-items: center;
}
search-bar combine-button {
filter: opacity(60%);
padding: 0.5rem;
}
search-bar combine-button.toggled {
filter: opacity(100%);
}
</style>
<Source src="/js/chart.min.js" on:load={ config_chart }/>
<Source src="/js/chartjs-plugin-annotation.min.js" on:load={ () => chart_load_promise.resolve() } />
{#if stats.domain}
<h1>{ stats.domain }</h1>
<search-bar>
<input type="text" placeholder="Search..." bind:value={ search_text } on:enter={ search } />
<combine-button on:click={ toggle_combined } class:toggled={ combine_results}>
<Icon name="shuffle" tooltip="Shuffle" />
</combine-button>
</search-bar>
<div>{ by_chains.length } results{#if combine_results}<b>, combined</b>{/if}</div>
{/if}
{#await chart_load_promise}
<Spinner />
{:then}
<hr />
{#if search_error}
<callout class="error">{ search_error }</callout>
{/if}
{#each by_chains as data, i}
<IsVisible on:visible={ () => setTimeout(() => create_chart(data[0], data[1]), 200) }>
<div id="chart-container" slot="visible">
{#if combine_results}
<canvas id="chart-{data[1].id}" width="400" height="100"></canvas>
<table>
<tr><th>sum</th><th>chain</th></tr>
{#each combined_chains as [sum, url]}
<tr><td>{ sum }</td><td style="text-align: left;">{url}</td></tr>
{/each}
</table>
{:else}
<div><b>{data[0]}</b></div>
<canvas id="chart-{data[1].id}" width="400" height="100"></canvas>
{/if}
</div>
<div id="chart-container" slot="hidden"><p>&nbsp;</p></div>
</IsVisible>
{/each}
{/await}