Sat, 28 Jun 2025 11:32:08 +0200
add popups with commit summaries - resolves #644
/* Copyright 2025 Mike Becker. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ #include "html.h" #include <ranges> #include <cstdio> #include <cassert> using namespace std::chrono; namespace html { static constexpr const char* weekdays[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; static unsigned indentation; static const char *tabs = " "; static void indent(int change = 0) { indentation += change; assert(indentation <= max_indentation); fwrite(tabs, 4, indentation, stdout); } static std::string encode(const std::string &data) { std::string buffer; buffer.reserve(data.size()+16); for (const char &pos: data) { switch (pos) { case '&': buffer.append("&"); break; case '\"': buffer.append("""); break; case '\'': buffer.append("'"); break; case '<': buffer.append("<"); break; case '>': buffer.append(">"); break; case '#': buffer.append("#"); break; default: buffer.append(&pos, 1); break; } } return buffer; } } void html::open(bool fragment, unsigned char fragment_indent) { if (fragment) { indent(fragment_indent); puts("<div class=\"heatmap-content\">"); indentation++; } else { puts(R"(<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> table.heatmap { table-layout: fixed; border-collapse: collapse; font-family: sans-serif; } table.heatmap td, table.heatmap th { text-align: center; border: solid 1px lightgray; height: 1.5em; } table.heatmap td { border: solid 1px lightgray; width: 1.5em; height: 1.5em; } table.heatmap td:hover, table.heatmap td.popup-open { filter: hue-rotate(90deg); } table.heatmap td.out-of-range { background-color: gray; } table.heatmap td.zero-commits { background-color: white; } table.heatmap td.one-commit { background-color: #80E7A0; } table.heatmap td.up-to-5-commits { background-color: #30D350; } table.heatmap td.up-to-10-commits { background-color: #00BF00; } table.heatmap td.up-to-20-commits { background-color: #00A300; } table.heatmap td.commit-spam { background-color: #008000; } /* Popup styles */ .commit-popup { position: absolute; background-color: #fff; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); padding: .2rem; z-index: 1000; width: 40ch; font-family: sans-serif; font-size: smaller; display: none; } .commit-popup h3 { margin-top: 0; border-bottom: 1px solid #eee; padding-bottom: 5px; } .commit-popup ul { margin: 0; padding-left: 20px; } .commit-popup li { margin-bottom: 5px; } .commit-popup .close-btn { position: absolute; top: 5px; right: 8px; cursor: pointer; font-weight: bold; } </style> <script> document.addEventListener('DOMContentLoaded', function() { // Create popup element const popup = document.createElement('div'); popup.className = 'commit-popup'; document.body.appendChild(popup); // Add click event listeners to all commit cells const cells = document.querySelectorAll('table.heatmap td:not(.out-of-range, .zero-commits)'); cells.forEach(cell => { cell.addEventListener('click', function(e) { const date = this.dataset.date; const summaries = JSON.parse(this.dataset.summaries); // Create popup content let content = `<span class="close-btn">×</span> <h3>${date}: ${summaries.length} commit${summaries.length !== '1' ? 's' : ''}</h3>`; content += '<ul>'; summaries.forEach(summary => { content += `<li>${summary}</li>`; }); content += '</ul>'; popup.innerHTML = content; // Position popup near the cell const rect = this.getBoundingClientRect(); popup.style.left = rect.left + window.scrollX + 'px'; popup.style.top = (rect.bottom + window.scrollY + 5) + 'px'; popup.style.display = 'block'; // Highlight the cell to which the popup belongs document.querySelectorAll('table.heatmap td.popup-open').forEach(old_cell => { old_cell.classList.toggle("popup-open"); }); cell.classList.toggle("popup-open"); // Add close button handler const closeBtn = popup.querySelector('.close-btn'); if (closeBtn) { closeBtn.addEventListener('click', function() { popup.style.display = 'none'; cell.classList.toggle("popup-open"); }); } e.stopPropagation(); }); }); // Close popup when clicking elsewhere document.addEventListener('click', function(e) { if (!popup.contains(e.target)) { popup.style.display = 'none'; document.querySelectorAll('table.heatmap td.popup-open').forEach(cell => { cell.classList.toggle("popup-open"); }); } }); }); </script> </head> <body> <div class="heatmap-content">)"); indentation = 3; } } void html::close(bool fragment) { if (fragment) { indent(-1); puts("</div>"); } else { puts(" </div>\n </body>\n</html>"); } } void html::chart_begin(const std::string& repo, const std::string& author) { indent(); printf("<div class=\"chart\" data-repo=\"%s\" data-author=\"%s\">\n", encode(repo).c_str(), encode(author).c_str()); indentation++; } void html::chart_end() { indent(-1); puts("</div>"); } void html::heading_repo(const std::string& repo) { indent(); printf("<h1 data-repo=\"%1$s\">%1$s</h1>\n", encode(repo).c_str()); } void html::heading_author(const std::string& author, unsigned int total_commits) { indent(); printf("<h2 title=\"Total commits: %u\">%s</h2>\n", total_commits, encode(author).c_str()); } void html::table_begin(year y, const std::array<unsigned int, 12> &commits_per_month) { static constexpr const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; // compute the column spans, first unsigned colspans[12] = {}; { unsigned total_cols = 0; sys_days day{year_month_day{y, January, 1d}}; for (unsigned col = 0; col < 12; ++col) { while (total_cols < html::columns && year_month_day{day}.month() <= month{col + 1}) { ++total_cols; ++colspans[col]; day += days{7}; } } } // now render the table heading indent(); puts("<table class=\"heatmap\">"); indent(1); puts("<tr>"); indent(1); puts("<th></th>"); for (unsigned i = 0 ; i < 12 ; i++) { indent(); printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\">%s</th>\n", commits_per_month[i], colspans[i], months[i]); } indent(-1); puts("</tr>"); } void html::table_end() { indent(-1); puts("</table>"); } void html::row_begin(unsigned int row) { indent(); puts("<tr>"); indent(1); printf("<th scope=\"row\">%s</th>\n", weekdays[row]); } void html::row_end() { indent(-1); puts("</tr>"); } void html::cell_out_of_range() { indent(); puts("<td class=\"out-of-range\"></td>"); } void html::cell(year_month_day ymd, const fm::commits &commits) { const char *color_class; if (commits.count() == 0) { color_class = "zero-commits"; } else if (commits.count() == 1) { color_class = "one-commit"; } else if (commits.count() <= 5) { color_class = "up-to-5-commits"; } else if (commits.count() <= 10) { color_class = "up-to-10-commits"; } else if (commits.count() <= 20) { color_class = "up-to-20-commits"; } else { color_class = "commit-spam"; } // Format date for display char date_str[32]; sprintf(date_str, "%s, %d-%02u-%02u", weekdays[weekday(ymd).iso_encoding() - 1], static_cast<int>(ymd.year()), static_cast<unsigned>(ymd.month()), static_cast<unsigned>(ymd.day())); // Build a JSON array of commit summaries // We have to iterate in reverse order to sort the summaries chronologically std::string summaries_json = "["; for (const auto &summary : commits.summaries | std::views::reverse) { // Escape quotes in JSON size_t pos = summary.find('\"'); if (pos == std::string::npos) { summaries_json += "\"" + summary + "\","; } else { std::string escaped = summary; do { escaped.replace(pos, 1, "\\\""); pos += 2; } while ((pos = escaped.find('\"', pos)) != std::string::npos); summaries_json += "\"" + escaped + "\","; } } summaries_json.pop_back(); summaries_json += "]"; indent(); printf("<td class=\"%s\" title=\"%s: %u %s\" data-date=\"%s\" data-summaries=\"%s\"></td>\n", color_class, date_str, commits.count(), commits.count() == 1 ? "commit" : "commits", date_str, encode(summaries_json).c_str() ); }