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.
343 lines
9.4 KiB
343 lines
9.4 KiB
2 years ago
|
<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> </p></div>
|
||
|
</IsVisible>
|
||
|
{/each}
|
||
|
{/await}
|