Quellcode durchsuchen

Merge pull request #833 from BKPepe/feat/modern-result-image

feat: add a modern design for the result image
sstidl vor 2 Wochen
Ursprung
Commit
22b89a76c5
4 geänderte Dateien mit 558 neuen und 11 gelöschten Zeilen
  1. 6 0
      DESIGN_SWITCH.md
  2. 4 1
      frontend/javascript/index.js
  3. 1 1
      index-classic.html
  4. 547 9
      results/index.php

+ 6 - 0
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

+ 4 - 1
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;

+ 1 - 1
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;

+ 547 - 9
results/index.php

@@ -5,6 +5,27 @@ require_once 'telemetry_db.php';
 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);
+}