Files
PrivateCaptcha/web/layouts/settings-usage/scripts.html
2026-01-20 18:12:18 +02:00

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>