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;
}