From 3d1118bc503b665d1517c659dcaab3cd1450b5d0 Mon Sep 17 00:00:00 2001 From: Josef Schlehofer Date: Mon, 10 Aug 2026 02:44:57 +0200 Subject: [PATCH] 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. --- DESIGN_SWITCH.md | 6 + frontend/javascript/index.js | 5 +- index-classic.html | 2 +- results/index.php | 556 ++++++++++++++++++++++++++++++++++- 4 files changed, 558 insertions(+), 11 deletions(-) diff --git a/DESIGN_SWITCH.md b/DESIGN_SWITCH.md index 730edb589..215cb4286 100644 --- a/DESIGN_SWITCH.md +++ b/DESIGN_SWITCH.md @@ -58,6 +58,12 @@ You can override the configuration by adding a URL parameter: URL parameters take precedence over the configuration file, making them useful for testing or allowing users to choose their preferred design. +### Shared Result Images + +The `useNewDesign` setting also controls the default style of shared result +images. Result images can be explicitly overridden with `?style=modern` or +`?style=classic`. + ## Design Locations ### Non-Docker Deployments diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js index 9935bd4c3..fcb7647ba 100644 --- a/frontend/javascript/index.js +++ b/frontend/javascript/index.js @@ -419,7 +419,10 @@ function startRenderingLoop() { window.location.href.lastIndexOf("/") ) + "/results/?id=" + - testState.testData.testId; + 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; diff --git a/index-classic.html b/index-classic.html index df399ddd5..67353e59c 100644 --- a/index-classic.html +++ b/index-classic.html @@ -188,7 +188,7 @@ try { var testId = uiData.testId; if (testId != null) { - var shareURL = window.location.href.substring(0, window.location.href.lastIndexOf("/")) + "/results/?id=" + testId; + var shareURL = window.location.href.substring(0, window.location.href.lastIndexOf("/")) + "/results/?id=" + testId + "&style=classic"; I("resultsImg").src = shareURL; I("resultsURL").value = shareURL; I("testId").innerHTML = testId; diff --git a/results/index.php b/results/index.php index a2c224905..5b928003d 100755 --- a/results/index.php +++ b/results/index.php @@ -5,6 +5,27 @@ error_reporting(0); putenv('GDFONTPATH='.realpath('.')); +/** + * Everything is drawn at SS times the final size and scaled down at the end. + * GD has no antialiasing for filled shapes, so circles, rounded corners and + * arcs would otherwise come out stepped; oversampling gives all of them smooth + * edges from one mechanism. + */ +const SS = 3; + +const WIDTH = 800; +const HEIGHT = 480; + +// Palette taken from frontend/styling/colors.css, so the image matches the +// modern frontend rather than inventing a second look for the same project. +const C_CARD = [14, 7, 32]; // --background-backup-color +const C_PANEL = [23, 16, 42]; +const C_PANEL_EDGE = [42, 32, 64]; +const C_TEXT = [255, 255, 255]; // --primary-text-color +const C_TEXT_DIM = [137, 133, 145]; // --secondary-text-color +const C_DOWNLOAD = [92, 249, 253]; // --theme-green +const C_UPLOAD = [214, 59, 198]; // --theme-pink + /** * @param string $name * @@ -47,10 +68,7 @@ function format($d) * How much precision that column carries is decided by the backend, and the * three schemas shipped here disagree: MySQL declares `timestamp`, which keeps * none, PostgreSQL uses `timestamp without time zone DEFAULT now()`, which - * keeps microseconds, and MSSQL uses `datetime`, which keeps milliseconds. The - * value was drawn exactly as the database returned it, so the same result reads - * as "2026-08-10 02:04:13" on one deployment and "2026-08-10 02:04:13.957789" - * on another. + * keeps microseconds, and MSSQL uses `datetime`, which keeps milliseconds. * * The fraction is matched against the seconds field it belongs to rather than * the end of the string, so a value carrying a zone offset after it, such as @@ -65,6 +83,339 @@ function formatTimestamp($timestamp) return preg_replace('/(:\d{2})\.\d+/', '$1', (string) $timestamp); } +/** + * Splits a timestamp into a date and a time meant to be read rather than + * parsed. + * + * The whole value is returned as the date with an empty time when it cannot be + * parsed, which keeps a backend nobody here has seen legible rather than blank. + * + * @param string $timestamp + * + * @return array{0: string, 1: string} + */ +function splitTimestamp($timestamp) +{ + $timestamp = formatTimestamp($timestamp); + $t = strtotime($timestamp); + if (false === $t) { + return [$timestamp, '']; + } + + return [date('j M Y', $t), date('H:i', $t)]; +} + +/** + * Names the address family a result was measured over, without disclosing the + * address itself. + * + * Returns an empty string when the deployment redacts client addresses, since + * the placeholder it stores would otherwise be reported as IPv4. + * + * @param string $ip + * + * @return string + */ +function addressFamily($ip) +{ + if ('' === $ip || '0.0.0.0' === $ip) { + return ''; + } + + return false === strpos($ip, ':') ? 'IPv4' : 'IPv6'; +} + +/** + * @param resource|GdImage $im + * @param int[] $rgb + * @param int $alpha 0 opaque, 127 transparent + * + * @return int + */ +function col($im, $rgb, $alpha = 0) +{ + return imagecolorallocatealpha($im, $rgb[0], $rgb[1], $rgb[2], $alpha); +} + +/** + * The horizontal span of a rounded rectangle on a given row. + * + * Filling and shading both need to respect the same corners, so both ask this + * rather than each approximating the curve on its own. + * + * @return array{0: float, 1: float}|null + */ +function roundedSpan($y, $x1, $y1, $x2, $y2, $r) +{ + if ($y < $y1 || $y > $y2) { + return null; + } + + $inset = 0; + if ($y < $y1 + $r) { + $dy = $r - ($y - $y1); + $inset = $r - sqrt(max(0, $r * $r - $dy * $dy)); + } elseif ($y > $y2 - $r) { + $dy = $r - ($y2 - $y); + $inset = $r - sqrt(max(0, $r * $r - $dy * $dy)); + } + + return [$x1 + $inset, $x2 - $inset]; +} + +/** + * @param resource|GdImage $im + * + * @return void + */ +function roundedRect($im, $x1, $y1, $x2, $y2, $r, $color) +{ + for ($y = (int) $y1; $y <= (int) $y2; ++$y) { + $span = roundedSpan($y, $x1, $y1, $x2, $y2, $r); + if (null !== $span) { + imageline($im, (int) round($span[0]), $y, (int) round($span[1]), $y, $color); + } + } +} + +/** + * A panel: a one-pixel edge with a darker fill inside it. + * + * @param resource|GdImage $im + * + * @return void + */ +function panel($im, $x1, $y1, $x2, $y2, $r, $fill, $edge) +{ + roundedRect($im, $x1, $y1, $x2, $y2, $r, $edge); + roundedRect($im, $x1 + SS, $y1 + SS, $x2 - SS, $y2 - SS, $r - SS, $fill); +} + +/** + * Washes an accent colour into the bottom of a panel. + * + * This is deliberately a plain gradient and not a line chart. A curve inside a + * speed test result reads as measured data, and no per-second samples are + * stored for a shared result, so any curve drawn here would be invented. + * + * @param resource|GdImage $im + * @param int[] $rgb + * + * @return void + */ +function wash($im, $x1, $y1, $x2, $y2, $r, $rgb, $height, $maxAlpha = 88) +{ + $start = $y2 - $height; + for ($y = (int) $start; $y <= (int) $y2; ++$y) { + $span = roundedSpan($y, $x1, $y1, $x2, $y2, $r); + if (null === $span) { + continue; + } + $t = ($y - $start) / max(1, $height); + $alpha = (int) round(127 - (127 - $maxAlpha) * ($t * $t)); + imageline($im, (int) round($span[0]), $y, (int) round($span[1]), $y, col($im, $rgb, $alpha)); + } +} + +/** + * @param string $text + * + * @return float + */ +function textWidth($text, $font, $size) +{ + $bbox = imageftbbox($size, 0, $font, $text); + + return is_array($bbox) ? $bbox[2] - $bbox[0] : 0; +} + +/** + * Draws text, anchored left, centre or right on x. + * + * @param resource|GdImage $im + * + * @return float the width drawn + */ +function text($im, $x, $y, $size, $font, $color, $string, $align = 'left') +{ + $w = textWidth($string, $font, $size); + if ('center' === $align) { + $x -= $w / 2; + } elseif ('right' === $align) { + $x -= $w; + } + imagefttext($im, $size, 0, (int) round($x), (int) round($y), $color, $font, $string); + + return $w; +} + +/** + * The width trackedText() would draw. + * + * @return float + */ +function trackedWidth($string, $font, $size, $tracking) +{ + $total = -$tracking; + foreach (preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY) as $ch) { + $total += textWidth($ch, $font, $size) + $tracking; + } + + return $total; +} + +/** + * The same as text(), with the letters spaced apart. + * + * GD has no notion of tracking, so each character is placed on its own. The + * small uppercase labels are unreadable without it at this size. + * + * @param resource|GdImage $im + * + * @return float + */ +function trackedText($im, $x, $y, $size, $font, $color, $string, $tracking, $align = 'left') +{ + $chars = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY); + $total = trackedWidth($string, $font, $size, $tracking); + + if ('center' === $align) { + $x -= $total / 2; + } elseif ('right' === $align) { + $x -= $total; + } + + foreach ($chars as $ch) { + imagefttext($im, $size, 0, (int) round($x), (int) round($y), $color, $font, $ch); + $x += textWidth($ch, $font, $size) + $tracking; + } + + return $total; +} + +/** + * Fills a polygon on both supported PHP majors. + * + * imagefilledpolygon() took a point count until PHP 8.0, which deprecated it + * and made the parameter optional. The backend already relies on the null + * coalescing operator, so PHP 7 is the floor to keep working here. + * + * @param resource|GdImage $im + * @param int[] $points + * + * @return void + */ +function filledPolygon($im, $points, $color) +{ + if (PHP_VERSION_ID >= 80000) { + imagefilledpolygon($im, $points, $color); + + return; + } + + imagefilledpolygon($im, $points, count($points) / 2, $color); +} + +/** + * A ring with an arrow in it, marking which direction a panel measures. + * + * @param resource|GdImage $im + * @param int[] $rgb + * + * @return void + */ +function directionBadge($im, $cx, $cy, $r, $rgb, $pointsDown) +{ + // ring + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r), (int) (2 * $r), col($im, $rgb, 108)); + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r - 2 * SS), (int) (2 * $r - 2 * SS), col($im, C_PANEL)); + + $color = col($im, $rgb); + $shaft = $r * 0.52; + $head = $r * 0.34; + $dir = $pointsDown ? 1 : -1; + + imagesetthickness($im, (int) max(1, SS * 0.7)); + imageline($im, (int) $cx, (int) ($cy - $dir * $shaft), (int) $cx, (int) ($cy + $dir * $shaft), $color); + imagesetthickness($im, 1); + + $tipY = $cy + $dir * $shaft; + filledPolygon($im, [ + (int) $cx, (int) ($tipY + $dir * $head * 0.5), + (int) ($cx - $head), (int) ($tipY - $dir * $head * 0.55), + (int) ($cx + $head), (int) ($tipY - $dir * $head * 0.55), + ], $color); +} + +/** + * A ring with a trace in it: a spike for ping, a wave for jitter. + * + * @param resource|GdImage $im + * @param int[] $rgb + * + * @return void + */ +function traceBadge($im, $cx, $cy, $r, $rgb, $wave) +{ + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r), (int) (2 * $r), col($im, $rgb, 112)); + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r - 2 * SS), (int) (2 * $r - 2 * SS), col($im, C_PANEL)); + + $color = col($im, $rgb); + imagesetthickness($im, (int) max(1, SS * 0.8)); + + $w = $r * 1.05; + $prev = null; + for ($i = 0; $i <= 48; ++$i) { + $t = $i / 48; + $x = $cx - $w + 2 * $w * $t; + if ($wave) { + $y = $cy - sin($t * 2 * M_PI) * $r * 0.42; + } else { + // a flat line with one spike in the middle + $d = abs($t - 0.5); + $y = $cy - ($d < 0.16 ? (0.16 - $d) / 0.16 * $r * 0.62 : 0) + ($d > 0.16 && $d < 0.26 ? $r * 0.2 : 0); + } + if (null !== $prev) { + imageline($im, (int) $prev[0], (int) $prev[1], (int) $x, (int) $y, $color); + } + $prev = [$x, $y]; + } + + imagesetthickness($im, 1); +} + +/** + * Shortens text until it fits, marking it as shortened. + * + * User agents in particular are unbounded: a browser sends a far longer string + * than a command line client, and drawn as-is it would run off the panel. + * + * Characters are dropped rather than bytes. A provider name or user agent may + * well carry multibyte characters, and cutting one in half leaves a sequence + * that renders as a replacement glyph rather than shortened text. The input is + * capped first so that the measuring below is bounded no matter what is stored. + * + * @return string + */ +function fitText($text, $font, $size, $maxWidth) +{ + if (textWidth($text, $font, $size) <= $maxWidth) { + return $text; + } + + $chars = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($chars)) { + return '…'; + } + $chars = array_slice($chars, 0, 256); + + while (count($chars) > 0 && textWidth(implode('', $chars).'…', $font, $size) > $maxWidth) { + array_pop($chars); + } + + return implode('', $chars).'…'; +} + /** * @param array $speedtest * @@ -72,11 +423,13 @@ function formatTimestamp($timestamp) */ function formatSpeedtestDataForImage($speedtest) { - // format values for the image $speedtest['dl'] = format($speedtest['dl']); $speedtest['ul'] = format($speedtest['ul']); $speedtest['ping'] = format($speedtest['ping']); $speedtest['jitter'] = format($speedtest['jitter']); + $speedtest['family'] = addressFamily($speedtest['ip']); + list($speedtest['date'], $speedtest['time']) = splitTimestamp($speedtest['timestamp']); + // The classic design prints the timestamp whole rather than split in two. $speedtest['timestamp'] = formatTimestamp($speedtest['timestamp']); $ispinfo = json_decode($speedtest['ispinfo'], true)['processedString']; @@ -90,18 +443,164 @@ function formatSpeedtestDataForImage($speedtest) } else { $ispinfo = ''; } - - $speedtest['ispinfo'] = $ispinfo; + $speedtest['ispinfo'] = trim($ispinfo); return $speedtest; } /** + * Draws the result in the modern dark design, matching index-modern.html. + * + * @param array $speedtest + * + * @return void + */ +function drawModernImage($speedtest) +{ + $data = formatSpeedtestDataForImage($speedtest); + + $W = WIDTH * SS; + $H = HEIGHT * SS; + $im = imagecreatetruecolor($W, $H); + imagealphablending($im, false); + imagesavealpha($im, true); + imagefilledrectangle($im, 0, 0, $W, $H, imagecolorallocatealpha($im, 0, 0, 0, 127)); + imagealphablending($im, true); + + $FONT_BOLD = tryFont('OpenSans-Semibold'); + $FONT_LIGHT = tryFont('OpenSans-Light'); + + $card = col($im, C_CARD); + $panelFill = col($im, C_PANEL); + $panelEdge = col($im, C_PANEL_EDGE); + $white = col($im, C_TEXT); + $dim = col($im, C_TEXT_DIM); + + // the whole canvas is the card, with rounded corners over transparency so + // it sits on any page background + roundedRect($im, 0, 0, $W - 1, $H - 1, 18 * SS, $card); + + $M = 24 * SS; + + // ---- header ------------------------------------------------------- + $headerBaseline = 46 * SS; + text($im, $M, $headerBaseline, 21 * SS, $FONT_BOLD, $white, 'LibreSpeed'); + trackedText($im, $W / 2, $headerBaseline - 4 * SS, 10 * SS, $FONT_BOLD, $dim, 'INTERNET SPEED TEST', 3 * SS, 'center'); + + $family = $data['family']; + if ('' !== $family) { + $fw = textWidth($family, $FONT_BOLD, 11 * SS); + $chipW = $fw + 22 * SS; + $chipH = 26 * SS; + $chipX = $W - $M - $chipW; + $chipY = $headerBaseline - 19 * SS; + roundedRect($im, $chipX, $chipY, $chipX + $chipW, $chipY + $chipH, $chipH / 2, col($im, C_DOWNLOAD, 100)); + roundedRect($im, $chipX + SS, $chipY + SS, $chipX + $chipW - SS, $chipY + $chipH - SS, $chipH / 2 - SS, $card); + text($im, $chipX + $chipW / 2, $chipY + 18 * SS, 11 * SS, $FONT_BOLD, col($im, C_DOWNLOAD), $family, 'center'); + } + + // ---- download and upload ----------------------------------------- + $panelTop = 72 * SS; + $panelBottom = 286 * SS; + $gap = 16 * SS; + $panelW = ($W - 2 * $M - $gap) / 2; + $radius = 14 * SS; + + $columns = [ + [$M, $data['dl'], 'DOWNLOAD', C_DOWNLOAD, true], + [$M + $panelW + $gap, $data['ul'], 'UPLOAD', C_UPLOAD, false], + ]; + + foreach ($columns as list($x1, $value, $label, $rgb, $down)) { + $x2 = $x1 + $panelW; + panel($im, $x1, $panelTop, $x2, $panelBottom, $radius, $panelFill, $panelEdge); + wash($im, $x1 + SS, $panelTop + SS, $x2 - SS, $panelBottom - SS, $radius - SS, $rgb, 78 * SS); + + $badgeR = 19 * SS; + $labelW = trackedWidth($label, $FONT_BOLD, 13 * SS, 2.5 * SS); + $groupW = 2 * $badgeR + 14 * SS + $labelW; + $groupX = $x1 + ($panelW - $groupW) / 2; + + directionBadge($im, $groupX + $badgeR, $panelTop + 46 * SS, $badgeR, $rgb, $down); + trackedText($im, $groupX + 2 * $badgeR + 14 * SS, $panelTop + 52 * SS, 13 * SS, $FONT_BOLD, col($im, $rgb), $label, 2.5 * SS); + + text($im, $x1 + $panelW / 2, $panelTop + 140 * SS, 58 * SS, $FONT_LIGHT, $white, $value, 'center'); + text($im, $x1 + $panelW / 2, $panelTop + 178 * SS, 19 * SS, $FONT_LIGHT, $dim, 'Mbps', 'center'); + } + + // ---- ping and jitter ---------------------------------------------- + $pjTop = 298 * SS; + $pjBottom = 376 * SS; + panel($im, $M, $pjTop, $W - $M, $pjBottom, $radius, $panelFill, $panelEdge); + + $mid = $W / 2; + imageline($im, (int) $mid, (int) ($pjTop + 18 * SS), (int) $mid, (int) ($pjBottom - 18 * SS), $panelEdge); + + $metrics = [ + [$M, $data['ping'], 'PING', C_DOWNLOAD, false], + [$mid, $data['jitter'], 'JITTER', C_UPLOAD, true], + ]; + + foreach ($metrics as list($half, $value, $label, $rgb, $wave)) { + $badgeR = 19 * SS; + $cx = $half + 58 * SS; + $cy = ($pjTop + $pjBottom) / 2; + traceBadge($im, $cx, $cy, $badgeR, $rgb, $wave); + + $tx = $cx + $badgeR + 18 * SS; + trackedText($im, $tx, $cy - 12 * SS, 11 * SS, $FONT_BOLD, col($im, $rgb), $label, 2.5 * SS); + $vw = text($im, $tx, $cy + 25 * SS, 29 * SS, $FONT_LIGHT, $white, $value); + text($im, $tx + $vw + 7 * SS, $cy + 25 * SS, 14 * SS, $FONT_LIGHT, $dim, 'ms'); + } + + // ---- footer -------------------------------------------------------- + $fTop = 390 * SS; + $fBottom = 456 * SS; + panel($im, $M, $fTop, $W - $M, $fBottom, $radius, $panelFill, $panelEdge); + + $fx = $M + 22 * SS; + $wmW = textWidth('LibreSpeed', $FONT_BOLD, 15 * SS); + $wmX = $W - $M - 22 * SS; + + $isp = fitText($data['ispinfo'], $FONT_BOLD, 15 * SS, $wmX - $wmW - $fx - 20 * SS); + text($im, $fx, $fTop + 30 * SS, 15 * SS, $FONT_BOLD, $white, $isp); + + $stamp = trim($data['date'].('' === $data['time'] ? '' : ' '.$data['time'])); + $sw = text($im, $fx, $fTop + 56 * SS, 12 * SS, $FONT_LIGHT, $dim, $stamp); + + $client = trim((string) $data['ua']); + if ('' !== $client) { + $cx = $fx + $sw + 18 * SS; + $client = fitText($client, $FONT_LIGHT, 12 * SS, $wmX - $wmW - $cx - 20 * SS); + text($im, $cx, $fTop + 56 * SS, 12 * SS, $FONT_LIGHT, $dim, $client); + } + + text($im, $wmX, $fTop + 48 * SS, 15 * SS, $FONT_BOLD, col($im, C_TEXT_DIM), 'LibreSpeed', 'right'); + + // ---- scale down ---------------------------------------------------- + $out = imagecreatetruecolor(WIDTH, HEIGHT); + imagealphablending($out, false); + imagesavealpha($out, true); + imagefilledrectangle($out, 0, 0, WIDTH, HEIGHT, imagecolorallocatealpha($out, 0, 0, 0, 127)); + imagecopyresampled($out, $im, 0, 0, 0, 0, WIDTH, HEIGHT, $W, $H); + imagedestroy($im); + + header('Content-Type: image/png'); + imagepng($out); +} + +/** + * Draws the result in the classic light design, as index-classic.html uses. + * + * Kept as it was drawn before the modern design existed: a deployment serving + * the classic frontend would otherwise get an image that matches nothing on + * its own page. + * * @param array $speedtest * * @return void */ -function drawImage($speedtest) +function drawClassicImage($speedtest) { // format values for the image $data = formatSpeedtestDataForImage($speedtest); @@ -240,9 +739,48 @@ function drawImage($speedtest) imagepng($im); } +/** + * Which of the two designs to draw. + * + * The frontends both link the same URL, so the image cannot tell which page a + * result came from. An explicit style parameter decides, which is what lets the + * modern frontend ask for the design it matches and what makes an existing + * share link render either way on demand. + * + * Without one the image follows useNewDesign, the same switch the frontend + * already reads, so a deployment has one setting rather than two that can + * disagree. A missing or unreadable config.json means classic, which is what + * design-switch.js falls back to as well. + * + * @return string + */ +function resultImageStyle() +{ + if (isset($_GET['style'])) { + $style = strtolower(trim((string) $_GET['style'])); + if ('modern' === $style) { + return 'modern'; + } + if ('classic' === $style) { + return 'classic'; + } + } + + $config = json_decode((string) @file_get_contents(__DIR__.'/../config.json'), true); + if (is_array($config) && isset($config['useNewDesign']) && true === $config['useNewDesign']) { + return 'modern'; + } + + return 'classic'; +} + $speedtest = getSpeedtestUserById($_GET['id']); if (!is_array($speedtest)) { exit(1); } -drawImage($speedtest); +if ('modern' === resultImageStyle()) { + drawModernImage($speedtest); +} else { + drawClassicImage($speedtest); +}