mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-02-09 07:19:08 -06:00
439 lines
19 KiB
HTML
439 lines
19 KiB
HTML
<script type="text/javascript">
|
|
if (typeof ChartComponent === 'undefined') {
|
|
class ChartComponent {
|
|
constructor(usageLimit) {
|
|
this.usageLimit = usageLimit;
|
|
this.isLoading = false;
|
|
this.chart = null;
|
|
|
|
// Constants
|
|
this.yTicksCount = 7;
|
|
this.maxBarWidth = 7;
|
|
this.legendItemWidth = 160;
|
|
this.legendItemHeight = 20;
|
|
this.legendPadding = 20;
|
|
this.xAxisLabelHeight = 45;
|
|
this.yAxisScaleFactor = 1.2;
|
|
this.legendTextMaxLength = 18; // Max characters before truncation.
|
|
this.legendTextWidth = 7; // Fallback character width in pixels when canvas is unavailable.
|
|
this.legendItemPadding = 26; // Marker and text padding in pixels.
|
|
this.legendItemGap = 16; // Horizontal gap between legend items in pixels.
|
|
this.legendLeftMargin = 16; // Left offset from the y-axis in pixels.
|
|
this.legendEllipsis = '…';
|
|
this.legendFont = '14px sans-serif';
|
|
// Reuse a canvas context to measure legend text widths.
|
|
this.legendMeasureContext = document.createElement('canvas').getContext('2d');
|
|
if (this.legendMeasureContext) {
|
|
this.legendMeasureContext.font = this.legendFont;
|
|
}
|
|
|
|
this.backgroundColor = '#e4e4e7';
|
|
this.limitColor = '#F45D5D'; // pcred-300
|
|
this.grayColor = "#6b7280";
|
|
this.seriesColors = [
|
|
'#188B8B',
|
|
'#3B82F6',
|
|
'#F59E0B',
|
|
'#10B981',
|
|
'#8B5CF6',
|
|
'#F97316',
|
|
'#EC4899',
|
|
'#14B8A6',
|
|
'#6366F1',
|
|
'#E11D48'
|
|
];
|
|
|
|
this.monthlyFormat = d3.timeFormat("%b");
|
|
}
|
|
|
|
monthlyTicks(date, i) {
|
|
const day = date.getDate();
|
|
const month = date.getMonth();
|
|
if (day === 1 && month === 0) {
|
|
return date.getFullYear();
|
|
} else {
|
|
return this.monthlyFormat(date);
|
|
}
|
|
}
|
|
|
|
yTickFormat(d) {
|
|
if (d >= 1_000_000_000) return (d / 1_000_000_000) + 'B';
|
|
if (d >= 1_000_000) return (d / 1_000_000) + 'M';
|
|
if (d >= 1_000) return (d / 1_000) + 'K';
|
|
return d;
|
|
}
|
|
|
|
drawNoData(element, xTickFunction, periodLengthDays) {
|
|
const margin = { top: 20, right: 30, bottom: 60, left: 30 };
|
|
const rect = element.getBoundingClientRect();
|
|
const width = rect.width - margin.left - margin.right;
|
|
const height = rect.height - margin.top - margin.bottom;
|
|
|
|
const d3Selection = d3.select(element);
|
|
d3Selection.selectAll('svg').remove();
|
|
|
|
const svg = d3Selection.append('svg')
|
|
.attr('width', width + margin.left + margin.right)
|
|
.attr('height', height + margin.top + margin.bottom);
|
|
|
|
const chartElement = svg.append('g')
|
|
.attr('class', 'charts')
|
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
|
|
|
const x = d3.scaleTime().range([0, width]);
|
|
x.domain([d3.timeDay.offset(new Date(), -periodLengthDays), new Date()]);
|
|
|
|
const xTickValues = x.domain().filter((_, i) => !(i % 2));
|
|
|
|
const xAxis = d3.axisBottom(x)
|
|
.tickValues(xTickValues)
|
|
.tickFormat(xTickFunction);
|
|
|
|
const y = d3.scaleLinear().range([height, 0]);
|
|
|
|
chartElement.append('g')
|
|
.attr('transform', `translate(0,${height})`)
|
|
.call(xAxis)
|
|
.style("color", this.backgroundColor)
|
|
.style("stroke-width", 2)
|
|
.selectAll("text")
|
|
.style("text-anchor", "end")
|
|
.style("color", "#000")
|
|
.attr("dx", "-.8em")
|
|
.attr("dy", "-.55em")
|
|
.attr("transform", "rotate(-90)");
|
|
|
|
chartElement.append('g')
|
|
.call(d3.axisLeft(y).ticks(this.yTicksCount).tickSize(-width).tickFormat(''))
|
|
.style("color", this.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", this.grayColor);
|
|
}
|
|
|
|
stackValue(segment) {
|
|
return segment[1] - segment[0];
|
|
}
|
|
|
|
setStackedBars(layer, x, y, height, color) {
|
|
const barWidth = Math.min(x.bandwidth(), this.maxBarWidth);
|
|
const barOffset = (x.bandwidth() - barWidth) / 2;
|
|
layer.selectAll("rect")
|
|
.data(d => d)
|
|
.enter().append("rect")
|
|
.attr("class", "bar")
|
|
.attr("x", d => x(d.data.x) + barOffset)
|
|
.attr("width", barWidth)
|
|
.attr("y", d => y(d[1]))
|
|
.attr("height", d => (this.stackValue(d) > 1e-6 ? y(d[0]) - y(d[1]) : 0))
|
|
.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(d => this.stackValue(d));
|
|
}
|
|
|
|
setLegend(legend, text, color) {
|
|
legend.append("circle")
|
|
.attr("cx", -16)
|
|
.attr("cy", 0)
|
|
.attr("r", 6)
|
|
.style("fill", color);
|
|
|
|
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");
|
|
}
|
|
|
|
// Prepare legend layout so labels fit into rows with truncation where needed.
|
|
buildLegendLayout(series, width) {
|
|
const legendItemHeight = this.legendItemHeight;
|
|
const legendMaxItemWidth = this.legendItemWidth;
|
|
const legendMeasureContext = this.legendMeasureContext;
|
|
const truncationLength = Math.max(0, this.legendTextMaxLength - this.legendEllipsis.length);
|
|
|
|
const legendItems = series.map(item => {
|
|
const displayName = item.name.length > this.legendTextMaxLength
|
|
? `${item.name.slice(0, truncationLength)}${this.legendEllipsis}`
|
|
: item.name;
|
|
const textWidth = legendMeasureContext
|
|
? legendMeasureContext.measureText(displayName).width
|
|
: (displayName.length * this.legendTextWidth);
|
|
const estimatedWidth = Math.min(
|
|
legendMaxItemWidth,
|
|
this.legendItemPadding + textWidth
|
|
);
|
|
return { ...item, displayName, width: estimatedWidth };
|
|
});
|
|
|
|
// Lay out legend items row-by-row so short labels take less space.
|
|
const legendRows = [];
|
|
let rowWidth = 0;
|
|
let rowItems = [];
|
|
legendItems.forEach(item => {
|
|
// Only add a gap when the row already has items.
|
|
const itemGap = rowItems.length > 0 ? this.legendItemGap : 0;
|
|
if (rowWidth + itemGap + item.width > width && rowWidth > 0) {
|
|
legendRows.push(rowItems);
|
|
rowItems = [];
|
|
rowWidth = 0;
|
|
}
|
|
rowItems.push(item);
|
|
rowWidth += itemGap + item.width;
|
|
});
|
|
if (rowItems.length > 0) {
|
|
legendRows.push(rowItems);
|
|
}
|
|
|
|
// Center single-item rows and show full names without truncation.
|
|
const legendLayout = [];
|
|
legendRows.forEach((row, rowIndex) => {
|
|
if (row.length === 1) {
|
|
const item = row[0];
|
|
const fullTextWidth = legendMeasureContext
|
|
? legendMeasureContext.measureText(item.name).width
|
|
: (item.name.length * this.legendTextWidth);
|
|
const itemWidth = this.legendItemPadding + fullTextWidth;
|
|
legendLayout.push({
|
|
index: item.index,
|
|
name: item.name,
|
|
displayName: item.name,
|
|
width: itemWidth,
|
|
x: (width - itemWidth) / 2,
|
|
y: rowIndex * legendItemHeight
|
|
});
|
|
return;
|
|
}
|
|
|
|
let legendOffsetX = 0;
|
|
row.forEach(item => {
|
|
legendLayout.push({
|
|
index: item.index,
|
|
name: item.name,
|
|
displayName: item.displayName,
|
|
width: item.width,
|
|
x: legendOffsetX,
|
|
y: rowIndex * legendItemHeight
|
|
});
|
|
legendOffsetX += item.width + this.legendItemGap;
|
|
});
|
|
});
|
|
|
|
const legendHeight = legendRows.length * legendItemHeight + this.legendPadding + this.xAxisLabelHeight;
|
|
|
|
return { legendLayout, legendHeight };
|
|
}
|
|
|
|
// Render the legend items below the chart using the calculated layout.
|
|
renderLegend(chartElement, legendLayout, height, color) {
|
|
const legendStartX = this.legendLeftMargin;
|
|
const legendParent = chartElement.append("g")
|
|
.attr("class", "legendParent")
|
|
.attr("transform", `translate(${legendStartX},${height + this.xAxisLabelHeight})`);
|
|
|
|
const legends = legendParent.selectAll(".legend-item")
|
|
.data(legendLayout)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", "legend-item")
|
|
.attr("transform", d => `translate(${d.x},${d.y})`);
|
|
|
|
legends.each((d, i, nodes) => {
|
|
this.setLegend(d3.select(nodes[i]), d.displayName, color(d.index.toString()));
|
|
});
|
|
}
|
|
|
|
setChartData(element, data, xTickFormat) {
|
|
const points = data.data;
|
|
const series = data.series;
|
|
points.forEach(d => { d.x = new Date(d.x * 1000); });
|
|
|
|
const margin = { top: 20, right: 30, bottom: 30, left: 30 };
|
|
const rect = element.getBoundingClientRect();
|
|
const width = rect.width - margin.left - margin.right;
|
|
|
|
// Reserve space for the legend rows and x-axis labels.
|
|
const { legendLayout, legendHeight } = this.buildLegendLayout(series, width);
|
|
|
|
const height = rect.height - legendHeight - margin.top - margin.bottom;
|
|
|
|
const d3Selection = d3.select(element);
|
|
d3Selection.selectAll('svg').remove();
|
|
|
|
const svg = d3Selection.append('svg')
|
|
.attr('width', width + margin.left + margin.right)
|
|
.attr('height', height + legendHeight + margin.top + margin.bottom);
|
|
|
|
const chartElement = svg.append('g')
|
|
.attr('class', 'charts')
|
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
|
|
|
const seriesKeys = series.map(item => item.index.toString());
|
|
const grouped = d3.group(points, d => d.x.getTime());
|
|
const chartData = Array.from(grouped, ([timestamp, values]) => {
|
|
const row = { x: new Date(Number(timestamp)) };
|
|
seriesKeys.forEach(key => { row[key] = 0; });
|
|
values.forEach(value => { row[value.s.toString()] = value.y; });
|
|
return row;
|
|
}).sort((a, b) => a.x - b.x);
|
|
|
|
const totals = chartData.map(row => seriesKeys.reduce((sum, key) => sum + row[key], 0));
|
|
const maxTotal = d3.max(totals) || 0;
|
|
|
|
const x = d3.scaleBand().rangeRound([0, width]).padding(0.1);
|
|
const y = d3.scaleLinear().range([height, 0]);
|
|
|
|
x.domain(chartData.map(d => d.x));
|
|
const limitValue = this.usageLimit || 0;
|
|
const closeToLimit = limitValue > 0 && totals.some(total => (1.1 * total) >= limitValue);
|
|
const yMax = closeToLimit ? Math.max(maxTotal, limitValue) : maxTotal;
|
|
y.domain([0, yMax * this.yAxisScaleFactor]);
|
|
|
|
const xTickValues = x.domain();
|
|
|
|
const xAxis = d3.axisBottom(x)
|
|
.tickValues(xTickValues)
|
|
.tickFormat(xTickFormat);
|
|
|
|
const yAxis = d3.axisLeft(y)
|
|
.ticks(this.yTicksCount)
|
|
.tickFormat(this.yTickFormat)
|
|
.tickPadding(5);
|
|
|
|
const yGrid = chartElement.append("g")
|
|
.attr("class", "grid")
|
|
.call(yAxis.tickSize(-width))
|
|
.style("color", this.backgroundColor);
|
|
|
|
yGrid.selectAll("text").style("color", this.grayColor);
|
|
yGrid.selectAll(".domain").remove();
|
|
|
|
if (closeToLimit) {
|
|
svg.append('line')
|
|
.attr('x1', 0)
|
|
.attr('x2', width)
|
|
.attr('y1', y(limitValue))
|
|
.attr('y2', y(limitValue))
|
|
.attr("transform", `translate(${margin.left},${margin.top})`)
|
|
.attr('stroke', this.limitColor)
|
|
.attr('stroke-width', 1)
|
|
.attr('stroke-dasharray', '5,5');
|
|
}
|
|
|
|
const palette = this.seriesColors.concat(d3.schemeSet3 || []);
|
|
const colorRange = seriesKeys.map((_, index) => palette[index % palette.length]);
|
|
const color = d3.scaleOrdinal()
|
|
.domain(seriesKeys)
|
|
.range(colorRange);
|
|
|
|
const stack = d3.stack().keys(seriesKeys);
|
|
const stackedData = stack(chartData);
|
|
|
|
const layers = chartElement.selectAll(".layer")
|
|
.data(stackedData)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", "layer")
|
|
.attr("fill", d => color(d.key));
|
|
|
|
layers.each((d, i, nodes) => {
|
|
this.setStackedBars(d3.select(nodes[i]), x, y, height, color(d.key));
|
|
});
|
|
|
|
chartElement.append("g")
|
|
.attr("class", "x axis")
|
|
.attr("transform", `translate(0,${height})`)
|
|
.call(xAxis)
|
|
.style("color", this.backgroundColor)
|
|
.style("stroke-width", 2)
|
|
.selectAll("text")
|
|
.style("text-anchor", "end")
|
|
.style("color", "#000")
|
|
.attr("dx", "-.8em")
|
|
.attr("dy", "-.55em")
|
|
.attr("transform", "rotate(-90)");
|
|
|
|
// Render legend after the axes so it sits under the chart.
|
|
this.renderLegend(chartElement, legendLayout, height, color);
|
|
}
|
|
|
|
async fetchChartData(spinnerElement, maxRetries = 3, baseDelay = 1000) {
|
|
if (spinnerElement) { spinnerElement.style.display = 'flex'; }
|
|
try {
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
const response = await fetch('{{ partsURL $.Const.UserEndpoint $.Const.Stats | safeJS }}');
|
|
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 {
|
|
if (spinnerElement) { spinnerElement.style.display = 'none'; }
|
|
}
|
|
}
|
|
|
|
async updateChart(chartElement, spinnerElement) {
|
|
const response = await this.fetchChartData(spinnerElement);
|
|
const hasData = response &&
|
|
Array.isArray(response.data) && response.data.length > 0 &&
|
|
Array.isArray(response.series) && response.series.length > 0;
|
|
if (hasData) {
|
|
this.setChartData(chartElement, response, this.monthlyTicks.bind(this));
|
|
|
|
const totalElement = document.getElementById("totalRequests");
|
|
if (totalElement) {
|
|
const sum = response.data.reduce((sum, item) => sum + item.y, 0);
|
|
const formatter = new Intl.NumberFormat('en', {
|
|
notation: 'compact',
|
|
compactDisplay: 'short',
|
|
});
|
|
totalElement.innerHTML = formatter.format(sum);
|
|
}
|
|
} else {
|
|
this.drawNoData(chartElement, this.monthlyTicks.bind(this), 365);
|
|
const totalElement = document.getElementById("totalRequests");
|
|
if (totalElement) {
|
|
totalElement.innerHTML = "N/A";
|
|
}
|
|
}
|
|
}
|
|
|
|
async init(chartElement, spinnerElement) {
|
|
await this.updateChart(chartElement, spinnerElement);
|
|
}
|
|
}
|
|
|
|
window.ChartComponent = ChartComponent;
|
|
}
|
|
|
|
(function(){
|
|
loadScript("{{$.Ctx.CDN}}/portal/js/d3.v7.min.js",
|
|
function() {
|
|
const chart = new ChartComponent({{ if $.Params.Limit }}{{$.Params.Limit}}{{else}}null{{end}}); // Pass usageLimit
|
|
chart.init(document.querySelector('#usage-chart'), document.querySelector('#usage-spinner')); // Chart container element
|
|
});
|
|
})()
|
|
|
|
</script>
|