speedtest/frontend/javascript/index.js
Josef Schlehofer 3d1118bc50
feat: add a modern design for the result image
The shared result image had one look, and it no longer matched the modern
frontend.

The renderer can now draw either design. The image follows the same
useNewDesign setting as the frontend, so a deployment has one switch rather
than two that can disagree, and classic remains the fallback.

- An explicit style query parameter overrides it, which is how the modern
  frontend asks for the design it matches.

- The modern design reuses the frontend palette and shows the client, address
  family and timestamp already present in the result data.
2026-08-15 12:29:33 +02:00

476 lines
15 KiB
JavaScript

/**
* Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
* Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
*
* See https://github.com/librespeed/speedtest/issues/585
*/
// States the UI can be in
const INITIALIZING = 0;
const READY = 1;
const RUNNING = 2;
const FINISHED = 3;
// Keep some global state here
const testState = {
state: INITIALIZING,
speedtest: null,
servers: [],
selectedServerDirty: false,
testData: null,
testDataDirty: false,
telemetryEnabled: false,
};
// Bootstrap the application when the DOM is ready
window.addEventListener("DOMContentLoaded", async () => {
createSpeedtest();
hookUpButtons();
startRenderingLoop();
applySettingsJSON();
applyServerListJSON();
});
/**
* Create a new Speedtest and hook it into the global state
*/
function createSpeedtest() {
testState.speedtest = new Speedtest();
testState.speedtest.onupdate = (data) => {
testState.testData = data;
testState.testDataDirty = true;
};
testState.speedtest.onend = (aborted) =>
(testState.state = aborted ? READY : FINISHED);
}
/**
* Make all the buttons respond to the right clicks
*/
function hookUpButtons() {
document
.querySelector("#start-button")
.addEventListener("click", startButtonClickHandler);
document
.querySelector("#choose-privacy")
.addEventListener("click", () =>
document.querySelector("#privacy").showModal()
);
document
.querySelector("#share-results")
.addEventListener("click", () =>
document.querySelector("#share").showModal()
);
document
.querySelector("#copy-link")
.addEventListener("click", copyLinkButtonClickHandler);
document
.querySelectorAll(".close-dialog, #close-privacy")
.forEach((element) => {
element.addEventListener("click", () =>
document.querySelectorAll("dialog").forEach((modal) => modal.close())
);
});
}
/**
* Event listener for clicks on the main start button
*/
function startButtonClickHandler() {
switch (testState.state) {
case READY:
case FINISHED:
testState.speedtest.start();
testState.state = RUNNING;
return;
case RUNNING:
testState.speedtest.abort();
// testState.state is updated by `onend` handler of speedtest
return;
default:
return;
}
}
/**
* Event listener for clicks on the "Copy link" button in the modal
*/
async function copyLinkButtonClickHandler() {
const link = document.querySelector("img#results").src;
await navigator.clipboard.writeText(link);
const button = document.querySelector("#copy-link");
button.classList.add("active");
button.textContent = "Copied!";
setTimeout(() => {
button.classList.remove("active");
button.textContent = "Copy link";
}, 3000);
}
/**
* Load settings from settings.json on the server and apply them
*/
async function applySettingsJSON() {
try {
const response = await fetch("settings.json");
const settings = await response.json();
if (!settings || typeof settings !== "object") {
return console.error("Settings are empty or malformed");
}
for (let setting in settings) {
testState.speedtest.setParameter(setting, settings[setting]);
if (
setting == "telemetry_level" &&
settings[setting] &&
settings[setting] != "off" &&
settings[setting] != "disabled" &&
settings[setting] != "false"
) {
testState.telemetryEnabled = true;
document.querySelector("#privacy-warning").classList.remove("hidden");
}
}
} catch (error) {
console.error("Failed to fetch settings:", error);
}
}
/**
* Load server list from the configured source and populate the dropdown
*/
async function applyServerListJSON() {
try {
const serverSource =
typeof globalThis.SPEEDTEST_SERVERS !== "undefined"
? globalThis.SPEEDTEST_SERVERS
: "server-list.json";
const servers = Array.isArray(serverSource)
? serverSource
: await fetch(serverSource).then((response) => response.json());
if (!servers || !Array.isArray(servers) || servers.length === 0) {
return console.error("Server list is empty or malformed");
}
testState.servers = servers;
// If there's only one server, just show it. No reachability checks needed.
if (servers.length === 1) {
populateDropdown(servers);
return;
}
// For multiple servers: first run the built-in selection (which pings servers
// and annotates them with pingT). Only then populate the dropdown so that
// dead servers don't appear.
testState.speedtest.addTestPoints(servers);
testState.speedtest.selectServer((bestServer) => {
const aliveServers = testState.servers.filter((s) => {
// Keep servers that responded to ping (pingT !== -1).
if (s.pingT !== -1) return true;
// Also keep protocol-relative servers ("//...") as a defensive fallback.
// LibreSpeed normalizes them to the page protocol before pinging, so they
// are normally treated like any other server and get a real pingT value.
return typeof s.server === "string" && s.server.startsWith("//");
});
// Prefer to show only reachable servers, but if none are reachable,
// fall back to the full list so users can still pick a server manually.
if (aliveServers.length > 0) {
testState.servers = aliveServers;
}
populateDropdown(testState.servers);
if (bestServer) {
selectServer(bestServer);
} else {
alert(
"Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network."
);
}
});
} catch (error) {
console.error("Failed to load server list:", error);
}
}
/**
* Add all the servers to the server selection dropdown and make it actually
* work.
* @param {Array} servers - an array of server objects
*/
function populateDropdown(servers) {
const serverSelector = document.querySelector("div.server-selector");
const serverList = serverSelector.querySelector("ul.servers");
// Reset previous state (populateDropdown can be called multiple times)
serverSelector.classList.remove("single-server");
serverSelector.classList.remove("active");
serverList.classList.remove("active");
serverList.innerHTML = "";
// If we have only a single server, just show it
if (servers.length === 1) {
serverSelector.classList.add("single-server");
selectServer(servers[0]);
return;
}
serverSelector.classList.add("active");
// Make the dropdown open and close (hook only once)
if (serverSelector.dataset.hooked !== "1") {
serverSelector.dataset.hooked = "1";
serverSelector.addEventListener("click", () => {
serverList.classList.toggle("active");
});
document.addEventListener("click", (e) => {
if (e.target.closest("div.server-selector") !== serverSelector)
serverList.classList.remove("active");
});
}
// Sort servers by country, then by city within the same country.
// Name formats: "City, Country", "City, Country (qualifier)", "City, Country, Provider", "Country"
const parseServerName = (name) => {
const parts = (name || "").split(",").map((s) => s.trim());
let country, city;
if (parts.length >= 3) {
// "City, Country, Provider" — use second part as country
country = parts[1];
city = parts[0];
} else if (parts.length === 2) {
country = parts[1];
city = parts[0];
} else {
country = parts[0];
city = "";
}
// Strip parenthetical qualifiers for sorting: "Germany (1) (Hetzner)" → "Germany"
country = country.replace(/\s*\([^)]*\)\s*/g, "").trim();
return { country, city };
};
const sorted = [...servers].sort((a, b) => {
const pa = parseServerName(a.name);
const pb = parseServerName(b.name);
return pa.country.localeCompare(pb.country) || pa.city.localeCompare(pb.city);
});
// Populate the list to choose from
sorted.forEach((server) => {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = "#";
link.innerHTML = `${server.name}${
server.sponsorName ? ` <span>(${server.sponsorName})</span>` : ""
}`;
link.addEventListener("click", () => selectServer(server));
item.appendChild(link);
serverList.appendChild(item);
});
}
/**
* Set the given server as the selected server for the speedtest
* @param {Object} server - a server object
*/
function selectServer(server) {
testState.speedtest.setSelectedServer(server);
testState.selectedServerDirty = true;
testState.state = READY;
}
/**
* Start the requestAnimationFrame UI rendering loop
*/
function startRenderingLoop() {
// Do these queries once to speed up the rendering itself
const serverSelector = document.querySelector("div.server-selector");
const selectedServer = serverSelector.querySelector("#selected-server");
const sponsor = serverSelector.querySelector("#sponsor");
const startButton = document.querySelector("#start-button");
const privacyWarning = document.querySelector("#privacy-warning");
const gauges = document.querySelectorAll("#download-gauge, #upload-gauge");
const downloadProgress = document.querySelector("#download-gauge .progress");
const uploadProgress = document.querySelector("#upload-gauge .progress");
const downloadGauge = document.querySelector("#download-gauge .speed");
const uploadGauge = document.querySelector("#upload-gauge .speed");
const downloadText = document.querySelector("#download-gauge span");
const uploadText = document.querySelector("#upload-gauge span");
const pingAndJitter = document.querySelectorAll(".ping, .jitter");
const ping = document.querySelector("#ping");
const jitter = document.querySelector("#jitter");
const shareResults = document.querySelector("#share-results");
const copyLink = document.querySelector("#copy-link");
const resultsImage = document.querySelector("#results");
const buttonTexts = {
[INITIALIZING]: "Loading...",
[READY]: "Let's start",
[RUNNING]: "Abort",
[FINISHED]: "Restart",
};
// Show copy link button only if navigator.clipboard is available
copyLink.classList.toggle("hidden", !navigator.clipboard);
function renderUI() {
// Make the main button reflect the current state
startButton.textContent = buttonTexts[testState.state];
startButton.classList.toggle("disabled", testState.state === INITIALIZING);
startButton.classList.toggle("active", testState.state === RUNNING);
// Disable the server selector while test is running
serverSelector.classList.toggle("disabled", testState.state === RUNNING);
// Show selected server
if (testState.selectedServerDirty) {
const server = testState.speedtest.getSelectedServer();
selectedServer.textContent = server.name;
if (server.sponsorName) {
if (server.sponsorURL) {
sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
} else {
sponsor.textContent = `Sponsor: ${server.sponsorName}`;
}
} else {
sponsor.innerHTML = "&nbsp;";
}
testState.selectedServerDirty = false;
}
// Activate the gauges when test running or finished
gauges.forEach((e) =>
e.classList.toggle(
"enabled",
testState.state === RUNNING || testState.state === FINISHED
)
);
// Show ping and jitter if data is available
pingAndJitter.forEach((e) =>
e.classList.toggle(
"hidden",
!(
testState.testData &&
testState.testData.pingStatus &&
testState.testData.jitterStatus
)
)
);
// Show share button after test if server supports it
shareResults.classList.toggle(
"hidden",
!(
testState.state === FINISHED &&
testState.telemetryEnabled &&
testState.testData.testId
)
);
if (testState.testDataDirty) {
// Set gauge rotations
downloadProgress.style = `--progress-rotation: ${
testState.testData.dlProgress * 180
}deg`;
uploadProgress.style = `--progress-rotation: ${
testState.testData.ulProgress * 180
}deg`;
downloadGauge.style = `--speed-rotation: ${mbpsToRotation(
testState.testData.dlStatus,
testState.testData.testState === 1
)}deg`;
uploadGauge.style = `--speed-rotation: ${mbpsToRotation(
testState.testData.ulStatus,
testState.testData.testState === 3
)}deg`;
// Set numeric values
downloadText.textContent = numberToText(testState.testData.dlStatus);
uploadText.textContent = numberToText(testState.testData.ulStatus);
ping.textContent = numberToText(testState.testData.pingStatus);
jitter.textContent = numberToText(testState.testData.jitterStatus);
// Set user's IP and provider
if (testState.testData.clientIp) {
// Clear previous content
privacyWarning.innerHTML = '';
const connectedThrough = document.createElement('span');
connectedThrough.textContent = 'You are connected through:';
const ipAddress = document.createTextNode(testState.testData.clientIp);
privacyWarning.appendChild(connectedThrough);
privacyWarning.appendChild(document.createElement('br'));
privacyWarning.appendChild(ipAddress);
privacyWarning.classList.remove("hidden");
}
// Set image for sharing results
if (testState.testData.testId) {
resultsImage.src =
window.location.href.substring(
0,
window.location.href.lastIndexOf("/")
) +
"/results/?id=" +
testState.testData.testId +
// Ask for the design this frontend matches; the classic frontend
// links the same URL and gets the classic image without asking.
"&style=modern";
}
testState.testDataDirty = false;
}
requestAnimationFrame(renderUI);
}
renderUI();
}
/**
* Convert a speed in Mbits per second to a rotation for the gauge
* @param {string} speed Speed in Mbits
* @param {boolean} oscillate If the gauge should wiggle a bit
* @returns {number} Rotation for the gauge in degrees
*/
function mbpsToRotation(speed, oscillate) {
speed = Number(speed);
if (speed <= 0) return 0;
const minSpeed = 0;
const maxSpeed = 10000; // 10 Gbps maxes out the gauge
const minRotation = 0;
const maxRotation = 180;
// Can't do log10 of values less than one, +1 all to keep it fair
const logMinSpeed = Math.log10(minSpeed + 1);
const logMaxSpeed = Math.log10(maxSpeed + 1);
const logSpeed = Math.log10(speed + 1);
const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed);
const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1;
const rotation = power * oscillation * maxRotation;
// Make sure we stay within bounds at all times
return Math.max(Math.min(rotation, maxRotation), minRotation);
}
/**
* Convert a number to a user friendly version
* @param {string} value Speed, ping or jitter
* @returns {string} A text version with proper decimals
*/
function numberToText(value) {
if (!value) return "00";
value = Number(value);
if (value < 10) return value.toFixed(2);
if (value < 100) return value.toFixed(1);
return value.toFixed(0);
}