diff --git a/scripts/js/charts.js b/scripts/js/charts.js index 375989b2..a8d0c5fe 100644 --- a/scripts/js/charts.js +++ b/scripts/js/charts.js @@ -9,8 +9,7 @@ "use strict"; -// eslint-disable-next-line no-unused-vars -const THEME_COLORS = [ +globalThis.THEME_COLORS = [ "#f56954", "#3c8dbc", "#00a65a", @@ -29,24 +28,24 @@ const THEME_COLORS = [ "#d2d6de", ]; -// eslint-disable-next-line no-unused-vars -const htmlLegendPlugin = { +globalThis.htmlLegendPlugin = { id: "htmlLegend", afterUpdate(chart, args, options) { - const ul = getOrCreateLegendList(chart, options.containerID); - // Use the built-in legendItems generator const items = chart.options.plugins.legend.labels.generateLabels(chart); // Exit early if the legend has the same items as last time - if ( + const isLegendUnchanged = options.lastLegendItems && items.length === options.lastLegendItems.length && - items.every((item, i) => item.text === options.lastLegendItems[i].text) && - items.every((item, i) => item.hidden === options.lastLegendItems[i].hidden) - ) { - return; - } + items.every( + (item, i) => + item.text === options.lastLegendItems[i].text && + item.hidden === options.lastLegendItems[i].hidden + ); + + if (isLegendUnchanged) return; + // else: Update the HTML legend if it is different from last time or if it // did not exist @@ -54,6 +53,8 @@ const htmlLegendPlugin = { // need to update the legend options.lastLegendItems = items; + const ul = getOrCreateLegendList(options.containerID); + // Remove old legend items while (ul.firstChild) { ul.firstChild.remove(); @@ -61,89 +62,58 @@ const htmlLegendPlugin = { for (const item of items) { const li = document.createElement("li"); - li.style.alignItems = "center"; - li.style.cursor = "pointer"; - li.style.display = "flex"; - li.style.flexDirection = "row"; // Color checkbox (toggle visibility) const boxSpan = document.createElement("span"); boxSpan.title = "Toggle visibility"; boxSpan.style.color = item.fillStyle; - boxSpan.style.display = "inline-block"; - boxSpan.style.margin = "0 10px"; - boxSpan.innerHTML = item.hidden - ? '' - : ''; + boxSpan.innerHTML = ``; boxSpan.addEventListener("click", () => { const { type } = chart.config; + const isPieOrDoughnut = type === "pie" || type === "doughnut"; - if (type === "pie" || type === "doughnut") { + if (isPieOrDoughnut) { // Pie and doughnut charts only have a single dataset and visibility is per item chart.toggleDataVisibility(item.index); } else { - chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex)); + const isVisible = chart.isDatasetVisible(item.datasetIndex); + chart.setDatasetVisibility(item.datasetIndex, !isVisible); } chart.update(); }); - const textLink = document.createElement("p"); - if ( - chart.canvas.id === "queryTypePieChart" || - chart.canvas.id === "forwardDestinationPieChart" - ) { - // Text (link to query log page) - textLink.title = "List " + item.text + " queries"; + const link = document.createElement("a"); + const isQueryTypeChart = chart.canvas.id === "queryTypePieChart"; + const isForwardDestinationChart = chart.canvas.id === "forwardDestinationPieChart"; - textLink.addEventListener("click", () => { - if (chart.canvas.id === "queryTypePieChart") { - globalThis.location.href = "queries?type=" + item.text; - } else if (chart.canvas.id === "forwardDestinationPieChart") { - // Encode the forward destination as it may contain an "#" character - const upstream = encodeURIComponent(upstreams[item.text]); - globalThis.location.href = "queries?upstream=" + upstream; - } - }); + if (isQueryTypeChart || isForwardDestinationChart) { + // Text (link to query log page) + link.title = `List ${item.text} queries`; + + if (isQueryTypeChart) { + link.href = `queries?type=${item.text}`; + } else if (isForwardDestinationChart) { + // Encode the forward destination as it may contain an "#" character + link.href = `queries?upstream=${encodeURIComponent(upstreams[item.text])}`; + } } - textLink.style.margin = 0; - textLink.style.padding = 0; - textLink.style.textDecoration = item.hidden ? "line-through" : ""; - textLink.className = "legend-label-text"; - textLink.textContent = item.text; + link.style.textDecoration = item.hidden ? "line-through" : ""; + link.className = "legend-label-text"; + link.textContent = item.text; - li.append(boxSpan, textLink); + li.append(boxSpan, link); ul.append(li); } }, }; -// eslint-disable-next-line no-unused-vars -const customTooltips = function (context) { +globalThis.customTooltips = context => { const tooltip = context.tooltip; - let tooltipEl = document.getElementById(this.chart.canvas.id + "-customTooltip"); - if (!tooltipEl) { - // Create Tooltip Element once per chart - tooltipEl = document.createElement("div"); - tooltipEl.id = this.chart.canvas.id + "-customTooltip"; - tooltipEl.classList.add("chartjs-tooltip"); - tooltipEl.innerHTML = "
"; - // avoid browser's font-zoom since we know that 's - // font-size was set to 14px by bootstrap's css - const fontZoom = Number.parseFloat($("body").css("font-size")) / 14; - // set styles and font - tooltipEl.style.padding = tooltip.options.padding + "px " + tooltip.options.padding + "px"; - tooltipEl.style.borderRadius = tooltip.options.cornerRadius + "px"; - tooltipEl.style.font = tooltip.options.bodyFont.string; - tooltipEl.style.fontFamily = tooltip.options.bodyFont.family; - tooltipEl.style.fontSize = tooltip.options.bodyFont.size / fontZoom + "px"; - tooltipEl.style.fontStyle = tooltip.options.bodyFont.style; - // append Tooltip next to canvas-containing box - tooltipEl.ancestor = this.chart.canvas.closest(".box[id]").parentNode; - tooltipEl.ancestor.append(tooltipEl); - } + const canvasId = context.chart.canvas.id; + const tooltipEl = getOrCreateTooltipElement(canvasId, tooltip.options, context); // Hide if no tooltip if (tooltip.opacity === 0) { @@ -152,169 +122,217 @@ const customTooltips = function (context) { } // Set caret position - tooltipEl.classList.remove("left", "right", "center", "top", "bottom"); - tooltipEl.classList.add(tooltip.xAlign, tooltip.yAlign); + setTooltipCaretPosition(tooltipEl, tooltip); - // Set Text + // Set tooltip content if (tooltip.body) { - const titleLines = tooltip.title || []; - const bodyLines = tooltip.body.map(bodyItem => bodyItem.lines); - let innerHtml = ""; - - for (const title of titleLines) { - innerHtml += "" + title + ""; - } - - innerHtml += ""; - let printed = 0; - - const devicePixel = (1 / window.devicePixelRatio).toFixed(1); - for (const [i, body] of bodyLines.entries()) { - const labelColors = tooltip.labelColors[i]; - let style = "background-color: " + labelColors.backgroundColor; - style += "; outline: 1px solid " + labelColors.backgroundColor; - style += "; border: " + devicePixel + "px solid #fff"; - const span = ""; - - const num = body[0].split(": "); - // do not display entries with value of 0 (in bar chart), - // but pass through entries with "0.0% (in pie charts) - if (num[1] !== "0") { - innerHtml += "" + span + body + ""; - printed++; - } - } - - if (printed < 1) { - innerHtml += "No activity recorded"; - } - - innerHtml += ""; - - const tableRoot = tooltipEl.querySelector("table"); - tableRoot.innerHTML = innerHtml; + setTooltipContent(tooltipEl, tooltip); } - const canvasPos = this.chart.canvas.getBoundingClientRect(); + // Position tooltip + positionTooltip(tooltipEl, tooltip, context); + + // Make tooltip visible + tooltipEl.style.opacity = 1; +}; + +function getOrCreateTooltipElement(canvasId, options, context) { + let tooltipEl = document.getElementById(`${canvasId}-customTooltip`); + if (tooltipEl) return tooltipEl; + + // Create Tooltip Element once per chart + tooltipEl = document.createElement("div"); + tooltipEl.id = `${canvasId}-customTooltip`; + tooltipEl.className = "chartjs-tooltip"; + tooltipEl.innerHTML = '
'; + + // Avoid browser's font-zoom since we know that 's + // font-size was set to 14px by Bootstrap's CSS + const fontZoom = Number.parseFloat(getComputedStyle(document.body).fontSize) / 14; + + // Set styles and font + tooltipEl.style.cssText = ` + padding: ${options.padding}px ${options.padding}px; + border-radius: ${options.cornerRadius}px; + font: ${options.bodyFont.string}; + font-family: ${options.bodyFont.family}; + font-size: ${options.bodyFont.size / fontZoom}px; + font-style: ${options.bodyFont.style}; + `; + + // Append Tooltip next to canvas-containing box + tooltipEl.ancestor = context.chart.canvas.closest(".box[id]").parentNode; + tooltipEl.ancestor.append(tooltipEl); + + return tooltipEl; +} + +function setTooltipCaretPosition(tooltipEl, tooltip) { + tooltipEl.classList.remove("left", "right", "center", "top", "bottom"); + tooltipEl.classList.add(tooltip.xAlign, tooltip.yAlign); +} + +function setTooltipContent(tooltipEl, tooltip) { + const bodyLines = tooltip.body.map(bodyItem => bodyItem.lines); + if (bodyLines.length === 0) return; + + const titleLines = tooltip.title || []; + let tooltipHtml = ""; + + for (const title of titleLines) { + tooltipHtml += `${title}`; + } + + tooltipHtml += ""; + + const devicePixel = (1 / window.devicePixelRatio).toFixed(1); + let printed = 0; + + for (const [i, body] of bodyLines.entries()) { + const labelColors = tooltip.labelColors[i]; + const style = + `background-color: ${labelColors.backgroundColor}; ` + + `outline: 1px solid ${labelColors.backgroundColor}; ` + + `border: ${devicePixel}px solid #fff`; + const span = ``; + + const num = body[0].split(": "); + // Do not display entries with value of 0 in bar chart, + // but pass through entries with "0.0%" (in pie charts) + if (num[1] !== "0") { + tooltipHtml += `${span}${body}`; + printed++; + } + } + + if (printed < 1) { + tooltipHtml += "No activity recorded"; + } + + tooltipHtml += ""; + + const tableRoot = tooltipEl.querySelector("table"); + tableRoot.innerHTML = tooltipHtml; +} + +function positionTooltip(tooltipEl, tooltip, context) { + if (tooltip.opacity === 0 || tooltipEl.style.opacity === 0) return; + + const canvasPos = context.chart.canvas.getBoundingClientRect(); const boxPos = tooltipEl.ancestor.getBoundingClientRect(); const offsetX = canvasPos.left - boxPos.left; const offsetY = canvasPos.top - boxPos.top; const tooltipWidth = tooltipEl.offsetWidth; const tooltipHeight = tooltipEl.offsetHeight; - const caretX = tooltip.caretX; - const caretY = tooltip.caretY; - const caretPadding = tooltip.options.caretPadding; - let tooltipX; - let tooltipY; - let arrowX; + const { caretX, caretY } = tooltip; + const { caretPadding } = tooltip.options; const arrowMinIndent = 2 * tooltip.options.cornerRadius; const arrowSize = 5; + let tooltipX = offsetX + caretX; + let arrowX; + // Compute X position - if ($(document).width() > 2 * tooltip.width || tooltip.xAlign !== "center") { - // If the viewport is wide enough, let the tooltip follow the caret position - tooltipX = offsetX + caretX; - if (tooltip.yAlign === "top" || tooltip.yAlign === "bottom") { - switch (tooltip.xAlign) { - case "center": { - // set a minimal X position to 5px to prevent - // the tooltip to stick out left of the viewport - const minX = 5; - if (2 * tooltipX < tooltipWidth + minX) { - arrowX = tooltipX - minX; - tooltipX = minX; - } else { - tooltipX -= tooltipWidth / 2; - } - - break; - } - - case "left": - tooltipX -= arrowMinIndent; - arrowX = arrowMinIndent; - break; - case "right": - tooltipX -= tooltipWidth - arrowMinIndent; - arrowX = tooltipWidth - arrowMinIndent; - break; - default: - break; + if (tooltip.yAlign === "top" || tooltip.yAlign === "bottom") { + switch (tooltip.xAlign) { + case "center": { + // Set a minimal X position to 5px to prevent + // the tooltip to stick out left of the viewport + const minX = 5; + tooltipX = Math.max(minX, tooltipX - tooltipWidth / 2); + arrowX = tooltip.caretX - (tooltipX - offsetX); + break; } - } else if (tooltip.yAlign === "center") { - switch (tooltip.xAlign) { - case "left": - tooltipX += caretPadding; - break; - case "right": - tooltipX -= tooltipWidth - caretPadding; - break; - case "center": - tooltipX -= tooltipWidth / 2; - break; - default: - break; + + case "left": { + tooltipX -= arrowMinIndent; + arrowX = arrowMinIndent; + break; } + + case "right": { + tooltipX -= tooltipWidth - arrowMinIndent; + arrowX = tooltipWidth - arrowMinIndent; + break; + } + // No default } - } else { - // compute the tooltip's center inside ancestor element + } else if (tooltip.yAlign === "center") { + switch (tooltip.xAlign) { + case "left": { + tooltipX += caretPadding; + break; + } + + case "right": { + tooltipX -= tooltipWidth - caretPadding; + break; + } + + case "center": { + tooltipX -= tooltipWidth / 2; + break; + } + // No default + } + } + + // Adjust X position if tooltip is centered inside ancestor + if (document.documentElement.clientWidth <= 2 * tooltip.width && tooltip.xAlign === "center") { tooltipX = (tooltipEl.ancestor.offsetWidth - tooltipWidth) / 2; - // move the tooltip if the arrow would stick out to the left - if (offsetX + caretX - arrowMinIndent < tooltipX) { - tooltipX = offsetX + caretX - arrowMinIndent; - } - - // move the tooltip if the arrow would stick out to the right - if (offsetX + caretX - tooltipWidth + arrowMinIndent > tooltipX) { - tooltipX = offsetX + caretX - tooltipWidth + arrowMinIndent; - } - + tooltipX = Math.max(tooltipX, offsetX + caretX - arrowMinIndent); // Prevent left overflow + tooltipX = Math.min(tooltipX, offsetX + caretX - tooltipWidth + arrowMinIndent); // Prevent right overflow arrowX = offsetX + caretX - tooltipX; } + let tooltipY = offsetY + caretY; + // Compute Y position switch (tooltip.yAlign) { - case "top": - tooltipY = offsetY + caretY + arrowSize + caretPadding; + case "top": { + tooltipY += arrowSize + caretPadding; break; - case "center": - tooltipY = offsetY + caretY - tooltipHeight / 2; - if (tooltip.xAlign === "left") { - tooltipX += arrowSize; - } else if (tooltip.xAlign === "right") { - tooltipX -= arrowSize; - } + } + case "center": { + tooltipY -= tooltipHeight / 2; + if (tooltip.xAlign === "left") tooltipX += arrowSize; + if (tooltip.xAlign === "right") tooltipX -= arrowSize; break; - case "bottom": - tooltipY = offsetY + caretY - tooltipHeight - arrowSize - caretPadding; - break; - default: + } + + case "bottom": { + tooltipY -= tooltipHeight + arrowSize + caretPadding; break; + } + // No default } // Position tooltip and display - tooltipEl.style.top = tooltipY.toFixed(1) + "px"; - tooltipEl.style.left = tooltipX.toFixed(1) + "px"; - if (arrowX === undefined) { - tooltipEl.querySelector(".arrow").style.left = ""; - } else { + tooltipEl.style.top = `${tooltipY.toFixed(1)}px`; + tooltipEl.style.left = `${tooltipX.toFixed(1)}px`; + + // Set arrow position + const arrowEl = tooltipEl.querySelector(".arrow"); + let arrowLeftPosition = ""; + + if (arrowX !== undefined) { // Calculate percentage X value depending on the tooltip's // width to avoid hanging arrow out on tooltip width changes const arrowXpercent = ((100 / tooltipWidth) * arrowX).toFixed(1); - tooltipEl.querySelector(".arrow").style.left = arrowXpercent + "%"; + arrowLeftPosition = `${arrowXpercent}%`; } - tooltipEl.style.opacity = 1; -}; + arrowEl.style.left = arrowLeftPosition; +} + +globalThis.doughnutTooltip = tooltipLabel => { + if (tooltipLabel.parsed === 0) return ""; -// eslint-disable-next-line no-unused-vars -function doughnutTooltip(tooltipLabel) { - let percentageTotalShown = tooltipLabel.chart._metasets[0].total.toFixed(1); // tooltipLabel.chart._metasets[0].total returns the total percentage of the shown slices // to compensate rounding errors we round to one decimal - - const label = " " + tooltipLabel.label; + let percentageTotalShown = tooltipLabel.chart._metasets[0].total.toFixed(1); + const label = ` ${tooltipLabel.label}`; let itemPercentage; // if we only show < 1% percent of all, show each item with two decimals @@ -327,41 +345,32 @@ function doughnutTooltip(tooltipLabel) { tooltipLabel.parsed.toFixed(1) === "0.0" ? "< 0.1" : tooltipLabel.parsed.toFixed(1); } - // even if no doughnut slice is hidden, sometimes percentageTotalShown is slightly less then 100 + // even if no doughnut slice is hidden, sometimes percentageTotalShown is slightly less than 100 // we therefore use 99.9 to decide if slices are hidden (we only show with 0.1 precision) if (percentageTotalShown > 99.9) { // All items shown - return label + ": " + itemPercentage + "%"; + return `${label}: ${itemPercentage}%`; } // set percentageTotalShown again without rounding to account // for cases where the total shown percentage would be <0.1% of all percentageTotalShown = tooltipLabel.chart._metasets[0].total; + const percentageOfShownItems = ((tooltipLabel.parsed * 100) / percentageTotalShown).toFixed(1); + return ( - label + - ":
• " + - itemPercentage + - "% of all data
• " + - ((tooltipLabel.parsed * 100) / percentageTotalShown).toFixed(1) + - "% of shown items" + `${label}:
• ${itemPercentage}% of all data
` + + `• ${percentageOfShownItems}% of shown items` ); -} +}; // chartjs plugin used by the custom doughnut legend -const getOrCreateLegendList = (chart, id) => { +function getOrCreateLegendList(id) { const legendContainer = document.getElementById(id); let listContainer = legendContainer.querySelector("ul"); + if (listContainer) return listContainer; - if (!listContainer) { - listContainer = document.createElement("ul"); - listContainer.style.display = "flex"; - listContainer.style.flexDirection = "column"; - listContainer.style.flexWrap = "wrap"; - listContainer.style.margin = 0; - listContainer.style.padding = 0; - - legendContainer.append(listContainer); - } + listContainer = document.createElement("ul"); + legendContainer.append(listContainer); return listContainer; -}; +} diff --git a/style/pi-hole.css b/style/pi-hole.css index 45399213..49e4847d 100644 --- a/style/pi-hole.css +++ b/style/pi-hole.css @@ -261,15 +261,27 @@ td.lookatme { align-self: center; } +.chart-legend ul { + display: flex; + flex-direction: column; + flex-wrap: wrap; + margin: 0; + padding: 0; +} + .chart-legend li { - cursor: pointer; position: relative; line-height: 1; margin: 0 0 8px; + align-items: center; + display: flex; + flex-direction: row; } .chart-legend li span { + cursor: pointer; display: inline-block; + margin: 0 10px; } .colorBoxWrapper { @@ -281,11 +293,14 @@ td.lookatme { } .chart-legend li .legend-label-text { + color: inherit; + margin: 0; + padding: 0; line-height: 1; word-break: break-word; } -.chart-legend li .legend-label-text:hover { +.chart-legend li a.legend-label-text:hover { text-decoration: underline; }