Skip to content

UI Rework and graph changed suggestion #319

@nicolas-goyon

Description

@nicolas-goyon

Hello there,

Context

I've been working a bit on a project using the github-action-benchmark and i've done a couple of changes.

I've wanted to share them to you in case if you wanted a quick help with that.
It's maybe not 100% functionable, I haven't tested it with other languages and results.
The UI isn't perfect too, but it could be a boilerplate for improvements.

Key points

  1. TailwindCSS and Flowbyte to handle UI elements
  2. ApexCharts for charts
  3. I've added a config data object to handle benchmark range to defines upper/lower limits that will appear in the graphs

Showcase

Image

JS

Here is a loadChart.js that handle creating all charts.

/*
* Refactored benchmark chart renderer
* ----------------------------------
*
* What this file does
* - Extracts series from window.BENCHMARK_DATA
* - Builds a responsive card (title + chart container) per benchmark -> Expect Tailwind
* - Renders a line chart via ApexCharts with optional expected-value annotations
*
* Expected external globals
* - window.BENCHMARK_DATA: { entries: { Benchmark: Array<{ date: string, benches: Array<{ name: string, value: number }> }> } }
* - ApexCharts: provided by <script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
* - TailwindCSS : provided by <script src="https://cdn.tailwindcss.com"></script>
* - Flowbite : provided by <script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script> & <link href="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.css" rel="stylesheet" />
*/


/**
* Optional benchmark ranges used for annotations and y‑axis padding.
* You can tailor these per benchmark method without touching the code.
*/

BENCHMARK_RANGES = {
    SpeedBenchmarks: {
        Default_MeshToString:
        {
            mean_ms_min: 100.0,
            mean_ms_max: 250.0
        },
        Occluder: {
            mean_ms_max: 50.0,
        },
        Optimize: {
            mean_ms_max: 50.0,
        },
        Occluder_MeshToString: {
            mean_ms_max: 50.0,
        },
        Optimize_MeshToString: {
            mean_ms_max: 50.0,
        }
    }
}

/** Visual defaults */
const COLORS = {
    expectedBand: { border: "#000", fill: "#FEB019" },
    minMaxLine: "#00E396",
};




/** ------------------------------------
* Small utilities
* -------------------------------------*/


/**
* Return the config object for ApexCharts y‑axis annotations based on provided range.
* If both min and max are provided => shaded band. If only one => labeled line.
*/
function buildYAnnotations(min, max) {
    const ann = [];
    if (typeof min === "number" && typeof max === "number") {
        ann.push({
            y: min,
            y2: max,
            borderColor: COLORS.expectedBand.border,
            fillColor: COLORS.expectedBand.fill,
            opacity: 0.2,
            label: {
                borderColor: "#333",
                style: { fontSize: "10px", color: "#333", background: COLORS.expectedBand.fill },
                text: "Expected range",
            },
        });
    } else if (typeof min === "number") {
        ann.push({
            y: min,
            borderColor: COLORS.minMaxLine,
            label: {
                borderColor: COLORS.minMaxLine,
                style: { color: "#fff", background: COLORS.minMaxLine },
                text: "Min value",
            },
        });
    } else if (typeof max === "number") {
        ann.push({
            y: max,
            borderColor: COLORS.minMaxLine,
            borderWidth: 4,
            strokeDashArray: 10,
            label: {
                borderColor: COLORS.minMaxLine,
                style: { color: "#fff", background: COLORS.minMaxLine, fontSize: "12px" },
                text: "Max value",
            },
        });
    }
    return ann;
}



/**
* Compute y‑axis min/max with optional padding around raw values and explicit range.
* @param {number[]} values – data series values
* @param {{min?: number, max?: number}} explicit – explicit range (optional)
* @param {boolean} addMargin – whether to pad the axis around min/max
*/
function computeYAxisBounds(values, explicit = {}, addMargin = true) {
    const rawMin = Math.min(...values);
    const rawMax = Math.max(...values);


    let min = typeof explicit.min === "number" ? Math.min(explicit.min, rawMin) : rawMin;
    let max = typeof explicit.max === "number" ? Math.max(explicit.max, rawMax) : rawMax;


    if (addMargin) {
        const span = Math.max(1e-9, max - min); // avoid zero‑span axis
        const pad = span * 0.2; // 20% headroom/tailroom
        min = min - pad;
        max = max + pad;
    }


    return { min, max };
}



/**
* Lookup benchmark range by composite id "Suite.Method".
*/
function lookupRange(compositeId) {
    const [suite, ...rest] = String(compositeId).split(".");
    const method = rest.join(".");
    const entry = BENCHMARK_RANGES?.[suite]?.[method];
    if (!entry) return { min: undefined, max: undefined };
    return { min: entry.mean_ms_min, max: entry.mean_ms_max };
}



/** ------------------------------------
* DOM helpers
* -------------------------------------*/


/**
* Create a simple card wrapper with a title and an inner div that will host the chart.
* Returns the created chart container element.
*/
function createChartCard(parent, title, containerId) {
    const card = document.createElement("div");
    card.className = "w-full bg-white rounded-lg shadow-sm dark:bg-gray-800 p-4 md:p-6";


    const header = document.createElement("div");
    header.className = "flex justify-between";


    const titleEl = document.createElement("h2");
    titleEl.className = "text-2xl font-bold text-gray-900 dark:text-white pb-2";
    titleEl.textContent = title;


    header.appendChild(titleEl);
    card.appendChild(header);


    const container = document.createElement("div");
    container.id = containerId;
    card.appendChild(container);


    parent.appendChild(card);
    return container;
}




/** ------------------------------------
* Chart creation
* -------------------------------------*/


/**
* Render a line chart in the given containerId using ApexCharts.
*/
function renderLineChart({ containerId, values, categories, yRange, addMargin = true }) {
    if (!document.getElementById(containerId)) return;
    if (typeof ApexCharts === "undefined") return;


    const annotations = buildYAnnotations(yRange?.min, yRange?.max);
    const yBounds = computeYAxisBounds(values, yRange, addMargin);


    /** @type {import('apexcharts').ApexOptions} */
    const options = {
        series: [{ data: values }],
        chart: {
            height: "auto",
            type: "line",
            id: `chart-${containerId}`,
            zoom: { enabled: false },
            toolbar: { show: false },
        },
        annotations: { yaxis: annotations },
        dataLabels: { enabled: false },
        stroke: { curve: "straight" },
        labels: categories,
        xaxis: { type: "datetime" },
        yaxis: { decimalsInFloat: 0, min: yBounds.min, max: yBounds.max },
    };


    const chart = new ApexCharts(document.getElementById(containerId), options);
    chart.render();
}

/** ------------------------------------
* Data extraction
* -------------------------------------*/


/**
* Convert BENCHMARK_DATA into a map keyed by benchmark name with arrays of {values, dates}.
* BENCHMARK_DATA number values are assumed to be presented in nanoseconds and will be converted to milliseconds.
*/
function extractSeriesFromBenchmarkData(benchmarkData) {
    const out = {};
    const entries = benchmarkData?.entries?.Benchmark ?? [];


    for (const commit of entries) {
        const date = commit.date; // assumed ISO string
        for (const bench of commit.benches ?? []) {
            const id = bench.name;
            if (!out[id]) out[id] = { values: [], dates: [] };
            out[id].values.push(bench.value / 1_000_000); // ns -> ms
            out[id].dates.push(date);
        }
    }


    return out;
}


/** ------------------------------------
* Bootstrapping
* -------------------------------------*/


function initBenchmarkCharts() {
    try {
        const root = document.getElementById("BenchmarkResults");
        if (!root) return;
        if (!window.BENCHMARK_DATA) return;


        const seriesById = extractSeriesFromBenchmarkData(window.BENCHMARK_DATA);


        for (const [benchmarkId, series] of Object.entries(seriesById)) {
            // Build card frame and container
            createChartCard(root, benchmarkId, benchmarkId);


            // Lookup optional expected range to annotate & pad y‑axis
            const range = lookupRange(benchmarkId);


            // Render the chart
            renderLineChart({
                containerId: benchmarkId,
                values: series.values,
                categories: series.dates,
                yRange: range,
                addMargin: true,
            });
        }
    } catch (err) {
        // Non‑fatal: keep the page usable even if one chart fails
        console.error("Failed to initialize benchmark charts:", err);
    }
}


// Kick things off once the DOM is ready
if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initBenchmarkCharts);
} else {
    initBenchmarkCharts();
}

HTML

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Benchmarks</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.css" rel="stylesheet" />
  <script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/apexcharts@3.46.0/dist/apexcharts.min.js"></script>

  <script src="data.js"></script>
  <script src="loadChart.js"   ></script>


</head>

<body class="bg-white dark:bg-gray-900">

  <nav class="bg-white dark:bg-gray-900 fixed w-full z-20 top-0 start-0 border-b border-gray-200 dark:border-gray-600">
    <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
      <a href="https://flowbite.com/" class="flex items-center space-x-3 rtl:space-x-reverse">
        <img src="https://flowbite.com/docs/images/logo.svg" class="h-8" alt="Flowbite Logo">
        <span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Flowbite</span>
      </a>
      <div class="flex md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
        <button type="button"
          class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
          Get started
        </button>
      </div>
      <div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-sticky">
        <ul
          class="flex flex-col p-4 md:p-0 mt-4 font-medium border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
          <li>
            <a href="#"
              class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500"
              aria-current="page">Home</a>
          </li>
          <li>
            <a href="#"
              class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700">About</a>
          </li>
          <li>
            <a href="#"
              class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700">Services</a>
          </li>
          <li>
            <a href="#"
              class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700">Contact</a>
          </li>
        </ul>
      </div>
    </div>
  </nav>


  <main class="mx-auto space-y-12 py-32 px-32">


    <section>
      <h2 class="text-5xl font-extrabold dark:text-white">Overview</h2>
      <p class="my-4 mb-3 text-gray-500 dark:text-gray-400">
        Overview of your project, feel free to fill this area.
      </p>
    </section>

    <div id="BenchmarkResults" class="grid grid-cols-1 md:grid-cols-3 gap-8">

    </div>

  </main>
  <footer class="text-center py-4 bg-gray-200">
    <p>Name of your project</p>
  </footer>
</body>

</html>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions