mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-02-09 15:28:49 -06:00
349 lines
14 KiB
HTML
349 lines
14 KiB
HTML
{{define "scripts"}}
|
|
<script defer src="{{$.Ctx.CDN}}/portal/js/d3.v7.min.js" type="text/javascript" charset="utf-8" crossorigin="anonymous"></script>
|
|
<script defer src="{{$.Ctx.CDN}}/widget/js/privatecaptcha.js" type="text/javascript" charset="utf-8" crossorigin="anonymous"></script>
|
|
{{template "default-scripts.html" .}}
|
|
|
|
<script>
|
|
// Minimum bar height threshold for rendering (avoids extremely thin bars)
|
|
const BAR_MIN_HEIGHT_THRESHOLD = 1e-6;
|
|
|
|
function onDifficultyChange(rangeElement) {
|
|
const endpoint = '{{.Params.CaptchaEndpoint}}/' + rangeElement.value
|
|
demoWidget.onDifficultyChange(endpoint);
|
|
}
|
|
|
|
function onCaptchaReset() {
|
|
demoWidget.onCaptchaReset();
|
|
}
|
|
|
|
function chartComponent() {
|
|
// Declare 'chart' with 'let' to prevent it from being reactive in Alpine.js.
|
|
let chart;
|
|
|
|
const yTicksCount = 7;
|
|
const maxBarWidth = 7;
|
|
|
|
const backgroundColor = '#e4e4e7';
|
|
const requestedColor = '#188B8B'; // pcteal-600
|
|
const verifiedColor = '#F45D5D'; //pcred-300
|
|
const grayColor = "#6b7280";
|
|
|
|
const weekdayFormat = d3.timeFormat("%a");
|
|
const monthlyFormat = d3.timeFormat("%b");
|
|
|
|
const monthlyTicks = (date, i) => {
|
|
const day = date.getDate();
|
|
const month = date.getMonth();
|
|
if ((day === 1) && (month === 0)) {
|
|
return date.getFullYear();
|
|
} else {
|
|
return monthlyFormat(date);
|
|
}
|
|
};
|
|
|
|
const hourlyTicks = function(date, i) {
|
|
const hour = date.getHours();
|
|
if (hour === 0) {
|
|
return weekdayFormat(date);
|
|
} else {
|
|
return hour;
|
|
}
|
|
};
|
|
|
|
const yTickFormat = function(d) {
|
|
if (d >= 1000000000) {
|
|
return (d / 1000000000) + 'B'; // For values in billions
|
|
} else if (d >= 1000000) {
|
|
return (d / 1000000) + 'M'; // For values in millions
|
|
} else if (d >= 1000) {
|
|
return (d / 1000) + 'K'; // For values in thousands
|
|
}
|
|
return d; // For values less than 1000
|
|
};
|
|
|
|
const periodLength = {
|
|
'24h': 1,
|
|
'7d': 7,
|
|
'30d': 30,
|
|
'1y': 365
|
|
}
|
|
|
|
const oddTickFilter = function(d, i) { return !(i % 2); };
|
|
const evenTickFilter = function(d, i) { return (i % 2); };
|
|
|
|
const tickFilter = {
|
|
'24h': evenTickFilter,
|
|
'7d': evenTickFilter,
|
|
'30d': evenTickFilter,
|
|
'1y': oddTickFilter
|
|
}
|
|
|
|
const tickFunction = {
|
|
'24h': hourlyTicks,
|
|
'7d': hourlyTicks,
|
|
'30d': function(date, i) {
|
|
const day = date.getDate();
|
|
if (day === 1) {
|
|
return monthlyFormat(date);
|
|
} else {
|
|
return day;
|
|
}
|
|
},
|
|
'1y': monthlyTicks
|
|
};
|
|
|
|
const drawNoData = (element, xTickFunction, periodLengthDays) => {
|
|
const margin = {top: 20, right: 30, bottom: 60, left: 30};
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
let width = rect.width - margin.left - margin.right;
|
|
let height = rect.height - margin.top - margin.bottom;
|
|
|
|
let d3Selection = d3.select(element);
|
|
d3Selection.selectAll('svg').remove();
|
|
|
|
let svg = d3Selection
|
|
.append('svg')
|
|
.attr('width', width + margin.left + margin.right)
|
|
.attr('height', height + margin.top + margin.bottom);
|
|
|
|
let chartElement = svg.append('g')
|
|
.attr('class', 'charts')
|
|
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
|
|
|
// Create x scale
|
|
let x = d3.scaleTime().range([0, width]);
|
|
|
|
x.domain([d3.timeDay.offset(new Date(), -periodLengthDays), new Date()]);
|
|
|
|
// Create y scale
|
|
let y = d3.scaleLinear().range([height, 0]);
|
|
|
|
// Create x axis
|
|
chartElement.append('g')
|
|
.attr('transform', 'translate(0,' + height + ')')
|
|
.call(d3.axisBottom(x))
|
|
.style("color", backgroundColor)
|
|
.style("stroke-width", 2)
|
|
.selectAll("text")
|
|
.style("text-anchor", "end")
|
|
.style("color", "#000")
|
|
.attr("dx", "-.8em")
|
|
.attr("dy", "-.55em")
|
|
.attr("transform", "rotate(-90)" );
|
|
|
|
// Create y axis with horizontal gridlines
|
|
chartElement.append('g')
|
|
.call(d3.axisLeft(y).ticks(yTicksCount).tickSize(-width).tickFormat(''))
|
|
.style("color", backgroundColor)
|
|
.selectAll(".domain").remove();
|
|
|
|
chartElement.append("g")
|
|
.attr("transform", "translate(" + (width / 2 - 80) + "," + (height / 2 + 5) + ")")
|
|
.append("text")
|
|
.text("No data available")
|
|
.style("font-size", "20px")
|
|
.style("fill", grayColor);
|
|
}
|
|
|
|
const setBarAttributes = (bars, x, y, height, color, sign) => {
|
|
const barSpacing = 2;
|
|
bars.enter().append("rect")
|
|
.attr("class", "bar")
|
|
.attr("x", function(d) {
|
|
let barWidth = Math.min(x.bandwidth(), maxBarWidth) + sign*barSpacing/2;
|
|
// Adjust the x position to center the bar over the tick
|
|
return x(d.x) + (x.bandwidth() + sign*barWidth) / 2;
|
|
})
|
|
.attr("width", function() { return Math.min(x.bandwidth(), maxBarWidth) - barSpacing/2; })
|
|
.attr("y", function(d) { return y(d.y); })
|
|
.attr("height", function(d) { return d.y > BAR_MIN_HEIGHT_THRESHOLD ? height - y(d.y) : 0; })
|
|
.attr("rx", 3)
|
|
.attr("ry", 3)
|
|
.attr("fill", color)
|
|
.attr("opacity", 1)
|
|
.on("mouseover", function() { d3.select(this).attr("opacity", 0.8); })
|
|
.on("mouseout", function() { d3.select(this).attr("opacity", 1); })
|
|
.append("title").text(function(d) { return d.y; });
|
|
};
|
|
|
|
const setLegend = (legend, text, color) => {
|
|
// Add the legend color guide
|
|
legend.append("circle")
|
|
.attr("cx", -16)
|
|
.attr("cy", 0)
|
|
.attr("r", 6)
|
|
.style("fill", color);
|
|
|
|
// Add the legend text
|
|
legend.append("text")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("dy", ".35em")
|
|
.text(text)
|
|
.attr("class", "textselected")
|
|
.style("text-anchor", "start")
|
|
.style("font-size", "14px");
|
|
};
|
|
|
|
const setChartData = (element, data, xTickFormat, xTickFilter) => {
|
|
const requested = data.requested;
|
|
const verified = data.verified;
|
|
// Convert unix timestamp to JavaScript Date object
|
|
requested.forEach(d => { d.x = new Date(d.x * 1000); });
|
|
verified.forEach(d => { d.x = new Date(d.x * 1000); });
|
|
|
|
const legendHeight = 50;
|
|
const margin = {top: 20, right: 30, bottom: 30, left: 30};
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
const width = rect.width - margin.left - margin.right;
|
|
const height = rect.height - legendHeight - margin.top - margin.bottom;
|
|
|
|
let d3Selection = d3.select(element);
|
|
d3Selection.selectAll('svg').remove();
|
|
|
|
let svg = d3Selection
|
|
.append('svg')
|
|
.attr('width', width + margin.left + margin.right)
|
|
.attr('height', height + legendHeight + margin.top + margin.bottom);
|
|
|
|
let chartElement = svg.append('g')
|
|
.attr('class', 'charts')
|
|
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
|
|
|
let x = d3.scaleBand().rangeRound([0, width]).padding(0.1);
|
|
let y = d3.scaleLinear().range([height, 0]);
|
|
|
|
x.domain(requested.map(function(d) { return d.x; }));
|
|
// we will always have more or equal requested to verified?
|
|
const requestedMax = d3.max(requested, function(d) { return d.y; });
|
|
const verifiedMax = d3.max(verified, function(d) { return d.y; });
|
|
y.domain([0, Math.max(requestedMax, verifiedMax) * 1.2]);
|
|
|
|
// Filter the domain of the X scale to include only every other value
|
|
let xTickValues = x.domain().filter(xTickFilter);
|
|
|
|
let xAxis = d3.axisBottom(x)
|
|
.tickValues(xTickValues)
|
|
.tickFormat(xTickFormat);
|
|
let yAxis = d3.axisLeft(y).ticks(yTicksCount).tickFormat(yTickFormat).tickPadding(5);
|
|
|
|
// Add the grid lines
|
|
let yGrid = chartElement.append("g")
|
|
.attr("class", "grid")
|
|
.call(yAxis.tickSize(-width))
|
|
.style("color", backgroundColor);
|
|
|
|
yGrid.selectAll("text").style("color", grayColor);
|
|
yGrid.selectAll(".domain").remove();
|
|
|
|
// Append the rectangles for the bar chart
|
|
let barsRequested = chartElement.selectAll("bar-requested").data(requested);
|
|
setBarAttributes(barsRequested, x, y, height, requestedColor, -1);
|
|
|
|
let barsVerified = chartElement.selectAll("bar-verified").data(verified);
|
|
setBarAttributes(barsVerified, x, y, height, verifiedColor, 1);
|
|
|
|
// Add the x-axis
|
|
chartElement.append("g")
|
|
.attr("class", "x axis")
|
|
.attr("transform", "translate(0," + height + ")")
|
|
.call(xAxis)
|
|
.style("color", backgroundColor)
|
|
.style("stroke-width", 2)
|
|
.selectAll("text")
|
|
.style("text-anchor", "end")
|
|
.style("color", "#000")
|
|
.attr("dx", "-.8em")
|
|
.attr("dy", "-.55em")
|
|
.attr("transform", "rotate(-90)" );
|
|
|
|
const legendSpace = width/3;
|
|
const legendItemSize = 100;
|
|
const xAxisHeight = 30;
|
|
|
|
let legendParent = chartElement.append("g")
|
|
.attr("class", "legendParent")
|
|
.attr("transform", "translate(" + (width / 2 - legendItemSize) + "," + (xAxisHeight + height + legendHeight/2) + ")");
|
|
|
|
let legend1 = legendParent.append("g")
|
|
.attr("class", "legend-requested");
|
|
setLegend(legend1, 'Requested', requestedColor);
|
|
|
|
let legend2 = legendParent.append("g")
|
|
.attr("class", "legend-verified")
|
|
.attr("transform", "translate(" + (legendSpace - legendItemSize) + ",0)");
|
|
setLegend(legend2, 'Verified', verifiedColor);
|
|
};
|
|
|
|
return {
|
|
// https://d3js.org/d3-time-format#locale_format
|
|
isLoading: false,
|
|
period: '24h',
|
|
challengesRequested: 0,
|
|
challengesVerified: 0,
|
|
csrRate: 0.0,
|
|
async init() {
|
|
this.updateChart('24h');
|
|
},
|
|
async fetchChartData(period, maxRetries = 3, baseDelay = 1000) {
|
|
const allowedPeriods = ['24h', '7d', '30d', '1y'];
|
|
const fallbackPeriod = '24h';
|
|
const normalizedPeriod = allowedPeriods.includes(period) ? period : fallbackPeriod;
|
|
const encodedPeriod = encodeURIComponent(normalizedPeriod);
|
|
|
|
this.isLoading = true;
|
|
try {
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
const response = await fetch('{{ partsURL $.Const.OrgEndpoint $.Params.Property.OrgID $.Const.PropertyEndpoint $.Params.Property.ID $.Const.Stats }}/' + encodedPeriod);
|
|
if (response.ok) {
|
|
return await response.json();
|
|
}
|
|
|
|
if (response.status === 429 || response.status === 503) {
|
|
const retryDelay = baseDelay * Math.pow(2, attempt - 1);
|
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
continue;
|
|
}
|
|
|
|
const errorText = await response.text();
|
|
throw new Error(`Request failed (${response.status}): ${errorText || response.statusText}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching chart data:', error);
|
|
return null;
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
async updateChart() {
|
|
const data = await this.fetchChartData(this.period);
|
|
|
|
if (data && data.verified && data.requested &&
|
|
((data.verified.length > 0) || (data.requested.length > 0))) {
|
|
setChartData(this.$refs.chart, data, tickFunction[this.period], tickFilter[this.period]);
|
|
|
|
const requestedSum = data.requested.reduce((sum, item) => sum + item.y, 0);
|
|
const verifiedSum = data.verified.reduce((sum, item) => sum + item.y, 0);
|
|
const rate = requestedSum === 0 ? 0 : verifiedSum / requestedSum;
|
|
const formatter = new Intl.NumberFormat('en', {
|
|
notation: 'compact',
|
|
compactDisplay: 'short',
|
|
});
|
|
this.challengesRequested = formatter.format(requestedSum);
|
|
this.challengesVerified = formatter.format(verifiedSum);
|
|
this.csrRate = requestedSum === 0 ? "N/A" : `${(rate * 100).toFixed(2)}%`;
|
|
} else {
|
|
drawNoData(this.$refs.chart, tickFunction[this.period], periodLength[this.period]);
|
|
|
|
this.challengesRequested = 0;
|
|
this.challengesVerified = 0;
|
|
this.csrRate = "N/A";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
{{end}}
|