1<!DOCTYPE html><html data-theme="light"><head><title>Statistics - Hackorum</title><meta content="PostgreSQL Hackers Archive" name="description" /><meta content="Hackorum" property="og:site_name" /><meta content="Statistics - Hackorum" property="og:title" /><meta content="PostgreSQL Hackers Archive" property="og:description" /><meta content="https://hackorum.dev" property="og:url" /><meta content="https://hackorum.dev/icon.png" property="og:image" /><meta content="Statistics - Hackorum" property="og:image:alt" /><meta content="website" property="og:type" /><meta content="summary" name="twitter:card" /><meta content="Statistics - Hackorum" name="twitter:title" /><meta content="PostgreSQL Hackers Archive" name="twitter:description" /><meta content="https://hackorum.dev/icon.png" name="twitter:image" /><meta content="width=device-width,initial-scale=1" name="viewport" /><meta content="yes" name="apple-mobile-web-app-capable" /><meta content="yes" name="mobile-web-app-capable" /><meta name="csrf-param" content="authenticity_token" /> 2<meta name="csrf-token" content="AVjU4FveJ92cgU2QeeqW2vgzi8I_-g1ts-lAjxYdq3lMttDAehQu8Ys8BJbOQChBG3SuyMXMQFcVPJ7Zvx8c6A" /><link href="/icon.png" rel="icon" type="image/png" /><link href="/icon.png" rel="apple-touch-icon" />
2<script>(function() { 3 var storageKey = 'hackorum-theme'; 4 var stored = window.localStorage.getItem(storageKey); 5 var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 6 var theme = stored || (prefersDark ? 'dark' : 'light'); 7 document.documentElement.dataset.theme = theme; 8})();</script>
8<link rel="stylesheet" href="/assets/application-945ddd41.css" data-turbo-track="reload" /><link rel="stylesheet" href="/assets/fontawesome/all.min-c90a9f69.css" data-turbo-track="reload" />
8<script type="importmap" data-turbo-track="reload">{ 9 "imports": { 10 "application": "/assets/application-4710c685.js", 11 "@hotwired/turbo-rails": "/assets/turbo.min-ad2c7b86.js", 12 "@hotwired/stimulus": "/assets/stimulus.min-4b1e420e.js", 13 "@hotwired/stimulus-loading": "/assets/stimulus-loading-1fc53fe7.js", 14 "controllers/activity_filter_controller": "/assets/controllers/activity_filter_controller-a0082aab.js", 15 "controllers/application": "/assets/controllers/application-3affb389.js", 16 "controllers/batch_loader_controller": "/assets/controllers/batch_loader_controller-d1b9c692.js", 17 "controllers/ci_chart_controller": "/assets/controllers/ci_chart_controller-95a7d3ee.js", 18 "controllers/clipboard_controller": "/assets/controllers/clipboard_controller-d8ffaa41.js", 19 "controllers/composer_poll_controller": "/assets/controllers/composer_poll_controller-7f386262.js", 20 "controllers/contrib_calendar_controller": "/assets/controllers/contrib_calendar_controller-827401db.js", 21 "controllers/debounced_submit_controller": "/assets/controllers/debounced_submit_controller-dfc567da.js", 22 "controllers/diff_highlight_controller": "/assets/controllers/diff_highlight_controller-f5f4ce4d.js", 23 "controllers/draft_jump_controller": "/assets/controllers/draft_jump_controller-d0b9be8d.js", 24 "controllers/flash_controller": "/assets/controllers/flash_controller-d883e7ed.js", 25 "controllers/hello_controller": "/assets/controllers/hello_controller-708796bd.js", 26 "controllers/help_outline_controller": "/assets/controllers/help_outline_controller-af17bdb3.js", 27 "controllers/hover_popover_controller": "/assets/controllers/hover_popover_controller-27ed2c5c.js", 28 "controllers": "/assets/controllers/index-ee64e1f1.js", 29 "controllers/message_collapse_controller": "/assets/controllers/message_collapse_controller-1f6dc317.js", 30 "controllers/nav_dropdown_controller": "/assets/controllers/nav_dropdown_controller-f596d556.js", 31 "controllers/nav_overflow_controller": "/assets/controllers/nav_overflow_controller-4ace0a65.js", 32 "controllers/new_topics_banner_controller": "/assets/controllers/new_topics_banner_controller-e65a3edd.js", 33 "controllers/prefetch_frame_controller": "/assets/controllers/prefetch_frame_controller-f709b29e.js", 34 "controllers/read_status_controller": "/assets/controllers/read_status_controller-148b7a8b.js", 35 "controllers/reply_composer_controller": "/assets/controllers/reply_composer_controller-40e329de.js", 36 "controllers/reply_quote_controller": "/assets/controllers/reply_quote_controller-d5b61f14.js", 37 "controllers/save_search_controller": "/assets/controllers/save_search_controller-e7d5b27a.js", 38 "controllers/scroll_to_controller": "/assets/controllers/scroll_to_controller-0d374912.js", 39 "controllers/search_focus_controller": "/assets/controllers/search_focus_controller-75cd587d.js", 40 "controllers/send_confirmation_controller": "/assets/controllers/send_confirmation_controller-27cfb47e.js", 41 "controllers/sidebar_controller": "/assets/controllers/sidebar_controller-e8ea2d62.js", 42 "controllers/swipe_nav_controller": "/assets/controllers/swipe_nav_controller-4c8f687d.js", 43 "controllers/tabs_controller": "/assets/controllers/tabs_controller-36d7b8a1.js", 44 "controllers/tabs_morph_controller": "/assets/controllers/tabs_morph_controller-28b069c3.js", 45 "controllers/theme_controller": "/assets/controllers/theme_controller-75486c88.js", 46 "controllers/thread_actions_controller": "/assets/controllers/thread_actions_controller-3c861613.js", 47 "controllers/thread_outline_controller": "/assets/controllers/thread_outline_controller-725769d4.js", 48 "controllers/topic_states_controller": "/assets/controllers/topic_states_controller-6bcafb17.js", 49 "controllers/visited_topic_controller": "/assets/controllers/visited_topic_controller-4b4a0f6f.js", 50 "controllers/visited_topics": "/assets/controllers/visited_topics-999e77ee.js" 51 } 52}</script>
52 53<link rel="modulepreload" href="/assets/application-4710c685.js"> 54<link rel="modulepreload" href="/assets/turbo.min-ad2c7b86.js"> 55<link rel="modulepreload" href="/assets/stimulus.min-4b1e420e.js"> 56<link rel="modulepreload" href="/assets/stimulus-loading-1fc53fe7.js"> 57<link rel="modulepreload" href="/assets/controllers/activity_filter_controller-a0082aab.js"> 58<link rel="modulepreload" href="/assets/controllers/application-3affb389.js"> 59<link rel="modulepreload" href="/assets/controllers/batch_loader_controller-d1b9c692.js"> 60<link rel="modulepreload" href="/assets/controllers/ci_chart_controller-95a7d3ee.js"> 61<link rel="modulepreload" href="/assets/controllers/clipboard_controller-d8ffaa41.js"> 62<link rel="modulepreload" href="/assets/controllers/composer_poll_controller-7f386262.js"> 63<link rel="modulepreload" href="/assets/controllers/contrib_calendar_controller-827401db.js"> 64<link rel="modulepreload" href="/assets/controllers/debounced_submit_controller-dfc567da.js"> 65<link rel="modulepreload" href="/assets/controllers/diff_highlight_controller-f5f4ce4d.js"> 66<link rel="modulepreload" href="/assets/controllers/draft_jump_controller-d0b9be8d.js"> 67<link rel="modulepreload" href="/assets/controllers/flash_controller-d883e7ed.js"> 68<link rel="modulepreload" href="/assets/controllers/hello_controller-708796bd.js"> 69<link rel="modulepreload" href="/assets/controllers/help_outline_controller-af17bdb3.js"> 70<link rel="modulepreload" href="/assets/controllers/hover_popover_controller-27ed2c5c.js"> 71<link rel="modulepreload" href="/assets/controllers/index-ee64e1f1.js"> 72<link rel="modulepreload" href="/assets/controllers/message_collapse_controller-1f6dc317.js"> 73<link rel="modulepreload" href="/assets/controllers/nav_dropdown_controller-f596d556.js"> 74<link rel="modulepreload" href="/assets/controllers/nav_overflow_controller-4ace0a65.js"> 75<link rel="modulepreload" href="/assets/controllers/new_topics_banner_controller-e65a3edd.js"> 76<link rel="modulepreload" href="/assets/controllers/prefetch_frame_controller-f709b29e.js"> 77<link rel="modulepreload" href="/assets/controllers/read_status_controller-148b7a8b.js"> 78<link rel="modulepreload" href="/assets/controllers/reply_composer_controller-40e329de.js"> 79<link rel="modulepreload" href="/assets/controllers/reply_quote_controller-d5b61f14.js"> 80<link rel="modulepreload" href="/assets/controllers/save_search_controller-e7d5b27a.js"> 81<link rel="modulepreload" href="/assets/controllers/scroll_to_controller-0d374912.js"> 82<link rel="modulepreload" href="/assets/controllers/search_focus_controller-75cd587d.js"> 83<link rel="modulepreload" href="/assets/controllers/send_confirmation_controller-27cfb47e.js"> 84<link rel="modulepreload" href="/assets/controllers/sidebar_controller-e8ea2d62.js"> 85<link rel="modulepreload" href="/assets/controllers/swipe_nav_controller-4c8f687d.js"> 86<link rel="modulepreload" href="/assets/controllers/tabs_controller-36d7b8a1.js"> 87<link rel="modulepreload" href="/assets/controllers/tabs_morph_controller-28b069c3.js"> 88<link rel="modulepreload" href="/assets/controllers/theme_controller-75486c88.js"> 89<link rel="modulepreload" href="/assets/controllers/thread_actions_controller-3c861613.js"> 90<link rel="modulepreload" href="/assets/controllers/thread_outline_controller-725769d4.js"> 91<link rel="modulepreload" href="/assets/controllers/topic_states_controller-6bcafb17.js"> 92<link rel="modulepreload" href="/assets/controllers/visited_topic_controller-4b4a0f6f.js"> 93<link rel="modulepreload" href="/assets/controllers/visited_topics-999e77ee.js">
94<script type="module">import "application"</script>
94<script async="" data-website-id="0c9137a7-5fc6-47d5-b3fd-b70fff7ee54e" defer="" src="https://umami.hackorum.dev/script.js"></script>
94</head><body class="has-sidebar" data-controller="sidebar swipe-nav"><nav class="main-navigation"><div class="nav-container"><div class="nav-brand"><button aria-label="Toggle sidebar" class="nav-burger" data-action="click->sidebar#toggleMobile" type="button"><i class="fa-solid fa-bars"></i></button><a class="brand-link" href="/"><img alt="Hackorum" class="brand-icon" height="24" src="/icon.png" width="24" /><span class="brand-text">Hackorum</span></a><details class="mobile-nav-dropdown"><summary aria-label="Open menu" class="mobile-nav-toggle"><span class="mobile-nav-label">Menu</span><i class="fa-solid fa-caret-down"></i></summary><div class="mobile-nav-menu" data-action="click->sidebar#closeMenuOnNavigate"><button aria-label="Toggle theme" class="nav-link theme-toggle mobile-nav-theme-control" data-action="click->theme#toggle" data-controller="theme" type="button"><i aria-hidden="true" class="fa-regular fa-moon" data-theme-target="icon"></i><span>Theme</span></button><a class="nav-link" href="/topics">Topics</a><a class="nav-link" href="/ci">CI</a><a class="nav-link" href="/topics#search">Search</a><a class="nav-link" href="/stats">Statistics</a><a class="nav-link" href="/reports">Reports</a><a class="nav-link" href="/help">Help</a><a class="nav-link" href="/session/new">Sign in</a><a class="nav-link" href="/registration/new">Register</a></div></details><span class="tagline">PostgreSQL Hackers Archive</span></div><div class="nav-menu"><div class="nav-links"><a class="nav-link" href="/topics">Topics</a><a class="nav-link" href="/ci">CI</a><a class="nav-link" href="/topics#search">Search</a><a class="nav-link" href="/reports">Reports</a></div><div class="nav-right"><div class="nav-auth"><a class="nav-link" href="/session/new">Sign in</a><a class="nav-link" href="/registration/new">Register</a><button aria-label="Toggle theme" class="nav-link theme-toggle nav-theme-control" data-action="click->theme#toggle" data-controller="theme" type="button"><i aria-hidden="true" class="fa-regular fa-moon" data-theme-target="icon"></i></button></div></div><details class="nav-overflow-dropdown" data-controller="nav-dropdown"><summary aria-label="Menu" class="nav-link nav-overflow-toggle" data-action="click->sidebar#closeMenuOnNavigate"><i class="fa-solid fa-bars"></i></summary><div class="nav-overflow-menu"><a class="nav-link" href="/stats">Statistics</a><a class="nav-link" href="/help">Help</a><a class="nav-link" href="/session/new">Sign in</a><a class="nav-link" href="/registration/new">Register</a></div></details></div></div></nav><div class="page-layout with-sidebar" data-sidebar-target="layout"><div class="layout-sidebar" data-action="click->sidebar#closeOnNavigate" data-sidebar-target="sidebar" id="layout-sidebar"><div class="stats-sidebar"><details class="stats-sidebar-section" open=""><summary class="stats-sidebar-summary">Settings</summary><div class="stats-controls"><label><span class="label-text">Granularity</span><select id="stats-granularity"><option value="day">Day</option><option value="week">Week</option><option selected="" value="month">Month</option></select></label><label><span class="label-text">Retention granularity</span><select id="stats-retention-granularity"><option value="month">Monthly</option><option selected="" value="quarter">Quarterly</option></select></label><label><span class="label-text">Retention cohort</span><select id="stats-retention-segment"><option selected="" value="all">All new users</option></select></label><option value="replied_to_others">New participants who replied to others</option><label><span class="label-text">Range</span><select id="stats-range"><option value="last_30">Last 30 days</option><option value="last_90">Last 90 days</option><option value="last_365">Last 365 days</option><option value="last_3650">Last 10 years</option><option selected="" value="after_2000">After 2000</option><option value="all_time">All time</option><option value="custom">Custom range</option></select></label><label class="stats-custom-range" hidden=""><span class="label-text">From</span><input id="stats-from" type="date" /></label><label class="stats-custom-range" hidden=""><span class="label-text">To</span><input id="stats-to" type="date" /></label><button class="button-secondary" id="stats-apply-range" type="button">Apply</button></div></details>
94<details class="stats-sidebar-section" open=""><summary class="stats-sidebar-summary">Graphs</summary><div class="stats-toc"><ul><li><a href="#stats-chart-main">Activity overview</a></li><li><a href="#stats-chart-message-breakdown">Messages</a></li><li><a href="#stats-chart-attachments">New topics by attachments/commitfest status</a></li><li><a href="#stats-chart-new-topic-attachments">New topics by user tenure and attachments</a></li><li><a href="#stats-chart-depth">Per topic activity within interval</a></li><li><a href="#stats-chart-created-depth">Topic length</a></li><li><a href="#stats-chart-longevity">Topic longevity</a></li><li><a href="#stats-chart-longevity-heatmap">Topic longevity heatmap</a></li><li><a href="#stats-chart-participant-lifetime">Participant lifetime</a></li><li><a href="#stats-chart-new-participant-rate">New participant daily message rate</a></li><li><a href="#stats-chart-participant-retention">New vs retained participants</a></li><li><a href="#stats-chart-retention-milestones">Retention milestones</a></li><li><a href="#stats-chart-retention-heatmap">Participant retention heatmap</a></li><li><a href="#stats-chart-retention-intensity-heatmap">Participant activity intensity heatmap</a></li></ul></div></details></div></div><div aria-label="Resize sidebar" aria-orientation="vertical" class="layout-sidebar-resizer" data-action="mousedown->sidebar#startResize touchstart->sidebar#startResize" data-sidebar-target="resizer" role="separator"></div><div class="layout-sidebar-overlay" data-action="click->sidebar#closeMobile" data-sidebar-target="overlay"></div><main class="container"><div class="stats-page"><h1>Statistics</h1><p class="stats-intro">A public overview of Hackorum activity over time.</p><div class="stats-charts"><details class="stats-chart-block" data-collapsible="main" open=""><summary class="stats-chart-summary"><h2>Activity overview</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input data-chart="main" data-series="participants_active" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="participants_active"></span>Active participants</label><label><input data-chart="main" data-series="participants_new" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="participants_new"></span>New participants</label><label><input data-chart="main" data-series="retained_365_participants" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="retained_365_participants"></span>Retained participants (365d+)</label><label><input data-chart="main" data-series="new_users_replied_to_others" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_users_replied_to_others"></span>New participants replying to others</label><label><input checked="" data-chart="main" data-series="topics_new" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new"></span>New topics</label><label><input data-chart="main" data-series="topics_new_by_new_users" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_by_new_users"></span>New topics by new users</label><label><input data-chart="main" data-series="topics_new_with_attachments_by_new_users" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_with_attachments_by_new_users"></span>New topics with attachments by new users</label><label><input checked="" data-chart="main" data-series="topics_active" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_active"></span>Active topics</label><label><input data-chart="main" data-series="messages_total" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="messages_total"></span>Total messages</label><label><input data-chart="main" data-series="messages_committers" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="messages_committers"></span>Messages by committers</label><label><input data-chart="main" data-series="messages_contributors" type="checkbox" />
94<span class="series-swatch" data-chart="main" data-series="messages_contributors"></span>Messages by contributors</label><label><input data-chart="main" data-series="messages_new_participants" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="messages_new_participants"></span>Messages by new participants</label><label><input data-chart="main" data-series="topics_new_with_contributor_activity" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_with_contributor_activity"></span>New topics with contributor activity</label><label><input data-chart="main" data-series="topics_new_without_contributor_activity" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_new_without_contributor_activity"></span>New topics without contributor activity</label><label><input data-chart="main" data-series="topics_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_messages_avg"></span>Active topic avg msgs</label><label><input data-chart="main" data-series="topics_messages_median" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_messages_median"></span>Active topic median msgs</label><label><input data-chart="main" data-series="topics_messages_max" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_messages_max"></span>Active topic max msgs</label><label><input data-chart="main" data-series="topics_created_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_created_messages_avg"></span>New topic avg msgs</label><label><input data-chart="main" data-series="topics_created_messages_median" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_created_messages_median"></span>New topic median msgs</label><label><input data-chart="main" data-series="topics_created_messages_max" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topics_created_messages_max"></span>New topic max msgs</label><label><input data-chart="main" data-series="topic_longevity_avg_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topic_longevity_avg_days"></span>Topic longevity avg days</label><label><input data-chart="main" data-series="topic_longevity_median_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topic_longevity_median_days"></span>Topic longevity median days</label><label><input data-chart="main" data-series="topic_longevity_max_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="topic_longevity_max_days"></span>Topic longevity max days</label><label><input data-chart="main" data-series="new_participants_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_lifetime_avg_days"></span>New participant lifetime avg days</label><label><input data-chart="main" data-series="new_participants_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_lifetime_median_days"></span>New participant lifetime median days</label><label><input data-chart="main" data-series="new_participants_lifetime_max_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_lifetime_max_days"></span>New participant lifetime max days</label><label><input data-chart="main" data-series="retained_365_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="retained_365_lifetime_avg_days"></span>Retained lifetime avg days</label><label><input data-chart="main" data-series="retained_365_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="retained_365_lifetime_median_days"></span>Retained lifetime median days</label><label><input data-chart="main" data-series="new_participants_daily_avg_messages" type="checkbox" /><span class="series-swatch" data-chart="main" data-series="new_participants_daily_avg_messages"></span>New participant daily avg msgs</label><label><input data-chart="main" data-series="retained_365_daily_avg_messages" type="checkbox" />
94<span class="series-swatch" data-chart="main" data-series="retained_365_daily_avg_messages"></span>Retained daily avg msgs</label></div></div><div id="stats-chart-main"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-main"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-main" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="message_breakdown" open=""><summary class="stats-chart-summary"><h2>Messages</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="message_breakdown" data-series="messages_committers" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_committers"></span>Committers</label><label><input checked="" data-chart="message_breakdown" data-series="messages_contributors_non_committers" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_contributors_non_committers"></span>Contributors (non-committers)</label><label><input checked="" data-chart="message_breakdown" data-series="messages_new_participants" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_new_participants"></span>New participants</label><label><input checked="" data-chart="message_breakdown" data-series="messages_regular_participants" type="checkbox" /><span class="series-swatch" data-chart="message_breakdown" data-series="messages_regular_participants"></span>Regular participants</label></div></div><div id="stats-chart-message-breakdown"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-message-breakdown"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-message-breakdown" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="attachments" open=""><summary class="stats-chart-summary"><h2>New topics by attachment/commitfest status</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="attachments" data-series="topics_new_no_attachments" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_no_attachments"></span>No attachments</label><label><input checked="" data-chart="attachments" data-series="topics_new_with_attachments_no_commitfest" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_with_attachments_no_commitfest"></span>Attachment, not commitfest</label><label><input checked="" data-chart="attachments" data-series="topics_new_commitfest_in_progress" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_commitfest_in_progress"></span>Commitfest in progress</label><label><input checked="" data-chart="attachments" data-series="topics_new_commitfest_abandoned" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_commitfest_abandoned"></span>Commitfest rejected</label><label><input checked="" data-chart="attachments" data-series="topics_new_commitfest_committed" type="checkbox" /><span class="series-swatch" data-chart="attachments" data-series="topics_new_commitfest_committed"></span>Commitfest committed</label></div></div><div id="stats-chart-attachments"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-attachments"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-attachments" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="new_topic_attachments" open=""><summary class="stats-chart-summary"><h2>New topics by user tenure and attachments</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_no_attachments" type="checkbox" />
94<span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_no_attachments"></span>New users, no attachments</label><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_with_attachments" type="checkbox" /><span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_new_users_with_attachments"></span>New users, attachments</label><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_no_attachments" type="checkbox" /><span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_no_attachments"></span>Existing users, no attachments</label><label><input checked="" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_with_attachments" type="checkbox" /><span class="series-swatch" data-chart="new_topic_attachments" data-series="topics_new_by_existing_users_with_attachments"></span>Existing users, attachments</label></div></div><div id="stats-chart-new-topic-attachments"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-new-topic-attachments"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-new-topic-attachments" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="depth" open=""><summary class="stats-chart-summary"><h2>Per topic activity within interval</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="depth" data-series="topics_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="depth" data-series="topics_messages_avg"></span>Avg messages</label><label><input checked="" data-chart="depth" data-series="topics_messages_median" type="checkbox" /><span class="series-swatch" data-chart="depth" data-series="topics_messages_median"></span>Median messages</label><label><input checked="" data-chart="depth" data-series="topics_messages_max" type="checkbox" /><span class="series-swatch" data-chart="depth" data-series="topics_messages_max"></span>Max messages</label></div></div><div id="stats-chart-depth"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-depth"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-depth" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="created_depth" open=""><summary class="stats-chart-summary"><h2>Topic length (topic created within interval, replies possibly later)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="created_depth" data-series="topics_created_messages_avg" type="checkbox" /><span class="series-swatch" data-chart="created_depth" data-series="topics_created_messages_avg"></span>Avg messages</label><label><input checked="" data-chart="created_depth" data-series="topics_created_messages_median" type="checkbox" /><span class="series-swatch" data-chart="created_depth" data-series="topics_created_messages_median"></span>Median messages</label><label><input checked="" data-chart="created_depth" data-series="topics_created_messages_max" type="checkbox" /><span class="series-swatch" data-chart="created_depth" data-series="topics_created_messages_max"></span>Max messages</label></div></div><div id="stats-chart-created-depth"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-created-depth"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-created-depth" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="longevity" open=""><summary class="stats-chart-summary"><h2>Topic longevity (topic created within interval, replies possibly later)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="longevity" data-series="topic_longevity_avg_days" type="checkbox" />
94<span class="series-swatch" data-chart="longevity" data-series="topic_longevity_avg_days"></span>Avg days</label><label><input checked="" data-chart="longevity" data-series="topic_longevity_median_days" type="checkbox" /><span class="series-swatch" data-chart="longevity" data-series="topic_longevity_median_days"></span>Median days</label><label><input checked="" data-chart="longevity" data-series="topic_longevity_max_days" type="checkbox" /><span class="series-swatch" data-chart="longevity" data-series="topic_longevity_max_days"></span>Max days</label></div></div><div id="stats-chart-longevity"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-longevity"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-longevity" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="longevity_heatmap" open=""><summary class="stats-chart-summary"><h2>Topic longevity heatmap</h2></summary><div class="stats-chart-body"><div id="stats-chart-longevity-heatmap"></div></div></details><details class="stats-chart-block" data-collapsible="participant_lifetime" open=""><summary class="stats-chart-summary"><h2>Participant lifetime (first to last message)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="participant_lifetime" data-series="new_participants_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="new_participants_lifetime_avg_days"></span>New avg days</label><label><input checked="" data-chart="participant_lifetime" data-series="new_participants_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="new_participants_lifetime_median_days"></span>New median days</label><label><input checked="" data-chart="participant_lifetime" data-series="new_participants_lifetime_max_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="new_participants_lifetime_max_days"></span>New max days</label><label><input checked="" data-chart="participant_lifetime" data-series="retained_365_lifetime_avg_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="retained_365_lifetime_avg_days"></span>Retained avg days</label><label><input checked="" data-chart="participant_lifetime" data-series="retained_365_lifetime_median_days" type="checkbox" /><span class="series-swatch" data-chart="participant_lifetime" data-series="retained_365_lifetime_median_days"></span>Retained median days</label></div></div><div id="stats-chart-participant-lifetime"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-participant-lifetime"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-participant-lifetime" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="new_participant_rate" open=""><summary class="stats-chart-summary"><h2>New participant daily message rate</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="new_participant_rate" data-series="new_participants_daily_avg_messages" type="checkbox" /><span class="series-swatch" data-chart="new_participant_rate" data-series="new_participants_daily_avg_messages"></span>New participants</label><label><input checked="" data-chart="new_participant_rate" data-series="retained_365_daily_avg_messages" type="checkbox" /><span class="series-swatch" data-chart="new_participant_rate" data-series="retained_365_daily_avg_messages"></span>Retained 365d+</label></div></div><div id="stats-chart-new-participant-rate"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-new-participant-rate"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-new-participant-rate" t
94ype="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="participant_retention" open=""><summary class="stats-chart-summary"><h2>New vs retained participants</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="participant_retention" data-series="retained_365_participants" type="checkbox" /><span class="series-swatch" data-chart="participant_retention" data-series="retained_365_participants"></span>Retained 365d+</label><label><input checked="" data-chart="participant_retention" data-series="participants_new_not_retained_365" type="checkbox" /><span class="series-swatch" data-chart="participant_retention" data-series="participants_new_not_retained_365"></span>New (not retained)</label></div></div><div id="stats-chart-participant-retention"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-participant-retention"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-participant-retention" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="retention_milestones" open=""><summary class="stats-chart-summary"><h2>Retention milestones (cumulative)</h2></summary><div class="stats-chart-body"><div class="stats-series"><div class="series-grid"><label><input checked="" data-chart="retention_milestones" data-series="retention_q1" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q1"></span>3m+</label><label><input checked="" data-chart="retention_milestones" data-series="retention_q2" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q2"></span>6m+</label><label><input checked="" data-chart="retention_milestones" data-series="retention_q4" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q4"></span>1y+</label><label><input data-chart="retention_milestones" data-series="retention_q6" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q6"></span>1.5y+</label><label><input data-chart="retention_milestones" data-series="retention_q8" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q8"></span>2y+</label><label><input data-chart="retention_milestones" data-series="retention_q10" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q10"></span>2.5y+</label><label><input data-chart="retention_milestones" data-series="retention_q12" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q12"></span>3y+</label><label><input data-chart="retention_milestones" data-series="retention_q16" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q16"></span>4y+</label><label><input data-chart="retention_milestones" data-series="retention_q20" type="checkbox" /><span class="series-swatch" data-chart="retention_milestones" data-series="retention_q20"></span>5y+</label></div></div><div id="stats-chart-retention-milestones"></div><details class="stats-editor"><summary>Customize visualization</summary><textarea id="stats-editor-retention-milestones"></textarea><div class="editor-actions"><button class="button-secondary" id="stats-editor-apply-retention-milestones" type="button">Apply custom spec</button></div></details></div></details><details class="stats-chart-block" data-collapsible="retention_heatmap" open=""><summary class="stats-chart-summary"><h2>Participant retention heatmap</h2></summary><div class="stats-chart-body"><p class="stats-heatmap-note">Y-axis: cohort start month. X-axis: months since first message.</p><div id="stats-chart-retention-heatmap"></div></div></details><details class="stats-chart-block" data-collapsible="retention_intensity" open=""><summary class="stats-chart-summary"><h2>Participant activity intensity heatmap</h2></summary><div class="stats-chart-body"><p class="stats-heatmap-note">Y-axis: cohort start month. X-axis: months since first message.</p><div id="stats-chart-retention-intensity-heatmap"></div></div></details></div><details class="stats-explainer"><summary>Metric definitions</summary><div class="stats-explainer-body"><p><strong>Active participants:</strong> People who posted at least once in the selected interval.</p><p><strong>New participants:</strong> People whose first-ever message is in the selected interval.</p><p><strong>New topics:</strong> Topics created in the selected interval.</p><p><strong>Active topics:</strong> Topics with at least one message in the selected interval.</p><p><strong>New topics with contributor activity:</strong> Topics created in the interval that have ever received a contributor reply.</p><p><strong>New topics without contributor activity:</strong> Topics created in the interval that have never received a contributor reply.</p><p><strong>Message roles:</strong> Messages grouped by contributor role and new participant status. Contributor totals exclude committers; regular participants exclude contributors and new participants.</p><p><strong>Active contributors and committers:</strong> People with contributor memberships who posted during the interval, split by contributor or committer membership.</p><p><strong>New participant lifetime:</strong> Average, median, and maximum time between the first and last message for participants whose first message is in the interval.</p><p><strong>New participant daily message rate:</strong> Average of each new participant's total messages to date divided by days since their first message, measured as of today.</p><p><strong>Retained participants (365d+):</strong> New participants who posted at least once 365 days after their first message.</p><p><strong>Retained participant lifetime:</strong> Average and median lifetime for retained participants (first to last message).</p><p><strong>Retained participant daily message rate:</strong> Average of each retained participant's total messages to date divided by days since their first message, measured as of today.</p><p><strong>Participant retention heatmap:</strong> Monthly cohorts on the y-axis, months since first message on the x-axis, and the share of the cohort active in that month.</p><p><strong>Participant activity intensity heatmap:</strong> Monthly cohorts on the y-axis, months since first message on the x-axis, and average messages per active participant in that month.</p><p><strong>Topic depth:</strong> Average, median, and maximum messages per active topic in the interval.</p><p><strong>New topic depth:</strong> Average, median, and maximum messages per topic created in the interval (including future replies).</p><p><strong>Topic longevity:</strong> Average, median, and maximum age of active topics (first to last message).</p></div></details>
94<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
94<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
94<script>(function() { 95 const mainEl = document.getElementById("stats-chart-main"); 96 const depthEl = document.getElementById("stats-chart-depth"); 97 const createdDepthEl = document.getElementById("stats-chart-created-depth"); 98 const messageBreakdownEl = document.getElementById("stats-chart-message-breakdown"); 99 const attachmentsEl = document.getElementById("stats-chart-attachments"); 100 const newTopicAttachmentsEl = document.getElementById("stats-chart-new-topic-attachments"); 101 const longevityEl = document.getElementById("stats-chart-longevity"); 102 const participantLifetimeEl = document.getElementById("stats-chart-participant-lifetime"); 103 const newParticipantRateEl = document.getElementById("stats-chart-new-participant-rate"); 104 const participantRetentionEl = document.getElementById("stats-chart-participant-retention"); 105 const retentionMilestonesEl = document.getElementById("stats-chart-retention-milestones"); 106 const longevityHeatmapEl = document.getElementById("stats-chart-longevity-heatmap"); 107 const retentionHeatmapEl = document.getElementById("stats-chart-retention-heatmap"); 108 const retentionIntensityEl = document.getElementById("stats-chart-retention-intensity-heatmap"); 109 const granularityEl = document.getElementById("stats-granularity"); 110 const retentionGranularityEl = document.getElementById("stats-retention-granularity"); 111 const retentionSegmentEl = document.getElementById("stats-retention-segment"); 112 const rangeEl = document.getElementById("stats-range"); 113 const fromEl = document.getElementById("stats-from"); 114 const toEl = document.getElementById("stats-to"); 115 const applyRangeEl = document.getElementById("stats-apply-range"); 116 const customRangeEls = Array.from(document.querySelectorAll(".stats-custom-range")); 117 function getCSSVar(name) { 118 return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); 119 } 120 121 function getChartColors() { 122 return { 123 textColor: getCSSVar('--color-chart-text'), 124 cardBg: getCSSVar('--color-chart-bg'), 125 borderColor: getCSSVar('--color-chart-border') 126 }; 127 } 128 129 const chartSeries = { 130 main: [ 131 { key: "participants_active", label: "Active participants", color: getCSSVar('--color-chart-blue') }, 132 { key: "participants_new", label: "New participants", color: getCSSVar('--color-chart-cyan') }, 133 { key: "retained_365_participants", label: "Retained participants (365d+)", color: getCSSVar('--color-chart-green') }, 134 { key: "new_users_replied_to_others", label: "New participants replying to others", color: getCSSVar('--color-chart-light-red') }, 135 { key: "topics_new", label: "New topics", color: getCSSVar('--color-chart-purple') }, 136 { key: "topics_new_by_new_users", label: "New topics by new users", color: getCSSVar('--color-chart-light-purple') }, 137 { key: "topics_new_with_attachments_by_new_users", label: "New topics with attachments by new users", color: getCSSVar('--color-chart-light-brown') }, 138 { key: "topics_active", label: "Active topics", color: getCSSVar('--color-chart-brown') }, 139 { key: "topics_new_with_contributor_activity", label: "New topics with contributor activity", color: getCSSVar('--color-chart-pink') }, 140 { key: "topics_new_without_contributor_activity", label: "New topics without contributor activity", color: getCSSVar('--color-chart-gray') }, 141 { key: "messages_total", label: "Total messages", color: getCSSVar('--color-chart-orange') }, 142 { key: "messages_committers", label: "Messages by committers", color: getCSSVar('--color-chart-red') }, 143 { key: "messages_contributors", label: "Messages by contributors", color: getCSSVar('--color-chart-yellow-green') }, 144 { key: "messages_new_participants", label: "Messages by new participants", color: getCSSVar('--color-chart-light-blue') }, 145 { key: "topics_messages_avg", label: "Active topic avg msgs", color: getCSSVar('--color-chart-blue') }, 146 { key: "topics_messages_median", label: "Active topic median msgs", color: getCSSVar('--color-chart-orange') }, 147 { key: "topics_messages_max", label: "Active topic max msgs", color: getCSSVar('--color-chart-green') }, 148 { key: "topics_created_messages_avg", label: "New topic avg msgs", color: getCSSVar('--color-chart-blue') }, 149 { key: "topics_created_messages_median", label: "New topic median msgs", color: getCSSVar('--color-chart-orange') }, 150 { key: "topics_created_messages_max", label: "New topic max msgs", color: getCSSVar('--color-chart-green') }, 151 { key: "topic_longevity_avg_days", label: "Topic longevity avg days", color: getCSSVar('--color-chart-purple') }, 152 { key: "topic_longevity_median_days", label: "Topic longevity median days", color: getCSSVar('--color-chart-brown') }, 153 { key: "topic_longevity_max_days", label: "Topic longevity max days", color: getCSSVar('--color-chart-red') }, 154 { key: "new_participants_lifetime_avg_days", label: "New participant lifetime avg days", color: getCSSVar('--color-chart-blue') }, 155 { key: "new_participants_lifetime_median_days", label: "New participant lifetime median days", color: getCSSVar('--color-chart-orange') }, 156 { key: "new_participants_lifetime_max_days", label: "New participant lifetime max days", color: getCSSVar('--color-chart-green') }, 157 { key: "retained_365_lifetime_avg_days", label: "Retained lifetime avg days", color: getCSSVar('--color-chart-purple') }, 158 { key: "retained_365_lifetime_median_days", label: "Retained lifetime median days", color: getCSSVar('--color-chart-brown') }, 159 { key: "new_participants_daily_avg_messages", label: "New participant daily avg msgs", color: getCSSVar('--color-chart-blue') }, 160 { key: "retained_365_daily_avg_messages", label: "Retained daily avg msgs", color: getCSSVar('--color-chart-orange') } 161 ], 162 depth: [ 163 { key: "topics_messages_avg", label: "Avg messages", color: getCSSVar('--color-chart-blue') }, 164 { key: "topics_messages_median", label: "Median messages", color: getCSSVar('--color-chart-orange') }, 165 { key: "topics_messages_max", label: "Max messages", color: getCSSVar('--color-chart-green') } 166 ], 167 created_depth: [ 168 { key: "topics_created_messages_avg", label: "Avg messages", color: getCSSVar('--color-chart-blue') }, 169 { key: "topics_created_messages_median", label: "Median messages", color: getCSSVar('--color-chart-orange') }, 170 { key: "topics_created_messages_max", label: "Max messages", color: getCSSVar('--color-chart-green') } 171 ], 172 message_breakdown: [ 173 { key: "messages_committers", label: "Committers", color: getCSSVar('--color-chart-red') }, 174 { key: "messages_contributors_non_committers", label: "Contributors (non-committers)", color: getCSSVar('--color-chart-yellow-green') }, 175 { key: "messages_new_participants", label: "New participants", color: getCSSVar('--color-chart-light-blue') }, 176 { key: "messages_regular_participants", label: "Regular participants", color: getCSSVar('--color-chart-gray') } 177 ], 178 attachments: [ 179 { key: "topics_new_no_attachments", label: "No attachments", color: getCSSVar('--color-chart-light-gray') }, 180 { key: "topics_new_with_attachments_no_commitfest", label: "Attachment, not commitfest", color: getCSSVar('--color-chart-blue') }, 181 { key: "topics_new_commitfest_in_progress", label: "Commitfest in progress", color: getCSSVar('--color-chart-orange') }, 182 { key: "topics_new_commitfest_abandoned", label: "Commitfest rejected", color: getCSSVar('--color-chart-red') }, 183 { key: "topics_new_commitfest_committed", label: "Commitfest committed", color: getCSSVar('--color-chart-green') } 184 ], 185 new_topic_attachments: [ 186 { key: "topics_new_by_new_users_no_attachments", label: "New users, no attachments", color: getCSSVar('--color-chart-silver') }, 187 { key: "topics_new_by_new_users_with_attachments", label: "New users, attachments", color: getCSSVar('--color-chart-steel-blue') }, 188 { key: "topics_new_by_existing_users_no_attachments", label: "Existing users, no attachments", color: getCSSVar('--color-chart-dark-gray') }, 189 { key: "topics_new_by_existing_users_with_attachments", label: "Existing users, attachments", color: getCSSVar('--color-chart-medium-blue') } 190 ], 191 longevity: [ 192 { key: "topic_longevity_avg_days", label: "Avg days", color: getCSSVar('--color-chart-purple') }, 193 { key: "topic_longevity_median_days", label: "Median days", color: getCSSVar('--color-chart-brown') }, 194 { key: "topic_longevity_max_days", label: "Max days", color: getCSSVar('--color-chart-red') } 195 ], 196 participant_lifetime: [ 197 { key: "new_participants_lifetime_avg_days", label: "New avg days", color: getCSSVar('--color-chart-blue') }, 198 { key: "new_participants_lifetime_median_days", label: "New median days", color: getCSSVar('--color-chart-orange') }, 199 { key: "new_participants_lifetime_max_days", label: "New max days", color: getCSSVar('--color-chart-green') }, 200 { key: "retained_365_lifetime_avg_days", label: "Retained avg days", color: getCSSVar('--color-chart-purple') }, 201 { key: "retained_365_lifetime_median_days", label: "Retained median days", color: getCSSVar('--color-chart-brown') } 202 ], 203 new_participant_rate: [ 204 { key: "new_participants_daily_avg_messages", label: "New participants", color: getCSSVar('--color-chart-blue') }, 205 { key: "retained_365_daily_avg_messages", label: "Retained 365d+", color: getCSSVar('--color-chart-orange') } 206 ], 207 participant_retention: [ 208 { key: "retained_365_participants", label: "Retained 365d+", color: getCSSVar('--color-chart-orange') }, 209 { key: "participants_new_not_retained_365", label: "New (not retained)", color: getCSSVar('--color-chart-blue') } 210 ], 211 retention_milestones: [ 212 { key: "retention_q1", label: "3m+", color: getCSSVar('--color-chart-blue') }, 213 { key: "retention_q2", label: "6m+", color: getCSSVar('--color-chart-orange') }, 214 { key: "retention_q4", label: "1y+", color: getCSSVar('--color-chart-green') }, 215 { key: "retention_q6", label: "1.5y+", color: getCSSVar('--color-chart-red') }, 216 { key: "retention_q8", label: "2y+", color: getCSSVar('--color-chart-purple') }, 217 { key: "retention_q10", label: "2.5y+", color: getCSSVar('--color-chart-brown') }, 218 { key: "retention_q12", label: "3y+", color: getCSSVar('--color-chart-pink') }, 219 { key: "retention_q16", label: "4y+", color: getCSSVar('--color-chart-gray') }, 220 { key: "retention_q20", label: "5y+", color: getCSSVar('--color-chart-cyan') } 221 ] 222 }; 223 224 const chartConfigs = { 225 main: { 226 key: "main", 227 el: mainEl, 228 editorEl: document.getElementById("stats-editor-main"), 229 applyEl: document.getElementById("stats-editor-apply-main"), 230 height: 320 231 }, 232 depth: { 233 key: "depth", 234 el: depthEl, 235 editorEl: document.getElementById("stats-editor-depth"), 236 applyEl: document.getElementById("stats-editor-apply-depth"), 237 height: 220 238 }, 239 created_depth: { 240 key: "created_depth", 241 el: createdDepthEl, 242 editorEl: document.getElementById("stats-editor-created-depth"), 243 applyEl: document.getElementById("stats-editor-apply-created-depth"), 244 height: 220 245 }, 246 message_breakdown: { 247 key: "message_breakdown", 248 el: messageBreakdownEl, 249 editorEl: document.getElementById("stats-editor-message-breakdown"), 250 applyEl: document.getElementById("stats-editor-apply-message-breakdown"), 251 height: 260, 252 type: "stacked_bar" 253 }, 254 attachments: { 255 key: "attachments", 256 el: attachmentsEl, 257 editorEl: document.getElementById("stats-editor-attachments"), 258 applyEl: document.getElementById("stats-editor-apply-attachments"), 259 height: 260, 260 type: "stacked_bar" 261 }, 262 new_topic_attachments: { 263 key: "new_topic_attachments", 264 el: newTopicAttachmentsEl, 265 editorEl: document.getElementById("stats-editor-new-topic-attachments"), 266 applyEl: document.getElementById("stats-editor-apply-new-topic-attachments"), 267 height: 260, 268 type: "stacked_bar" 269 }, 270 longevity: { 271 key: "longevity", 272 el: longevityEl, 273 editorEl: document.getElementById("stats-editor-longevity"), 274 applyEl: document.getElementById("stats-editor-apply-longevity"), 275 height: 220 276 }, 277 participant_lifetime: { 278 key: "participant_lifetime", 279 el: participantLifetimeEl, 280 editorEl: document.getElementById("stats-editor-participant-lifetime"), 281 applyEl: document.getElementById("stats-editor-apply-participant-lifetime"), 282 height: 220 283 }, 284 new_participant_rate: { 285 key: "new_participant_rate", 286 el: newParticipantRateEl, 287 editorEl: document.getElementById("stats-editor-new-participant-rate"), 288 applyEl: document.getElementById("stats-editor-apply-new-participant-rate"), 289 height: 220 290 }, 291 participant_retention: { 292 key: "participant_retention", 293 el: participantRetentionEl, 294 editorEl: document.getElementById("stats-editor-participant-retention"), 295 applyEl: document.getElementById("stats-editor-apply-participant-retention"), 296 height: 240, 297 type: "stacked_bar" 298 }, 299 retention_milestones: { 300 key: "retention_milestones", 301 el: retentionMilestonesEl, 302 editorEl: document.getElementById("stats-editor-retention-milestones"), 303 applyEl: document.getElementById("stats-editor-apply-retention-milestones"), 304 height: 260, 305 type: "overlay_bar" 306 } 307 }; 308 309 const collapsibleKeys = [ 310 "main", 311 "depth", 312 "created_depth", 313 "message_breakdown", 314 "attachments", 315 "new_topic_attachments", 316 "longevity", 317 "longevity_heatmap", 318 "participant_lifetime", 319 "new_participant_rate", 320 "participant_retention", 321 "retention_milestones", 322 "retention_heatmap", 323 "retention_intensity" 324 ]; 325 326 function applySeriesSwatches() { 327 const swatches = Array.from(document.querySelectorAll(".series-swatch[data-chart][data-series]"));
328 swatches.forEach(swatch => { 329 const chartKey = swatch.dataset.chart; 330 const seriesKey = swatch.dataset.series; 331 const series = chartSeries[chartKey]?.find(entry => entry.key === seriesKey); 332 if (!series) return; 333 swatch.style.display = "inline-block"; 334 swatch.style.width = "10px"; 335 swatch.style.height = "10px"; 336 swatch.style.marginRight = "6px"; 337 swatch.style.borderRadius = "2px"; 338 swatch.style.backgroundColor = series.color || "#666"; 339 swatch.style.border = "1px solid rgba(0, 0, 0, 0.2)"; 340 swatch.style.verticalAlign = "middle"; 341 }); 342 } 343 344 Object.values(chartConfigs).forEach(config => { 345 config.seriesInputs = Array.from(document.querySelectorAll(`input[type="checkbox"][data-chart="${config.key}"][data-series]`)); 346 }); 347 348 const collapsibleByKey = new Map(); 349 document.querySelectorAll("[data-collapsible]").forEach(el => { 350 const key = el.dataset.collapsible; 351 if (key) collapsibleByKey.set(key, el); 352 }); 353 354 function setCollapsedState(key, collapsed) { 355 const container = collapsibleByKey.get(key); 356 if (!container) return; 357 container.open = !collapsed; 358 } 359 360 function encodeCollapseFlags() { 361 return collapsibleKeys.map(key => { 362 const container = collapsibleByKey.get(key); 363 return container && !container.open ? "1" : "0"; 364 }).join(""); 365 } 366 367 function applyCollapseFlags(flags) { 368 if (!flags || flags.length !== collapsibleKeys.length) return; 369 collapsibleKeys.forEach((key, idx) => { 370 setCollapsedState(key, flags[idx] === "1"); 371 }); 372 } 373 374 function seriesKeysFor(chartKey) { 375 return chartSeries[chartKey].map(series => series.key); 376 } 377 378 function selectedSeriesFor(chartKey) { 379 const inputs = chartConfigs[chartKey].seriesInputs; 380 const selectedKeys = inputs.filter(input => input.checked).map(input => input.dataset.series); 381 return chartSeries[chartKey].filter(series => selectedKeys.includes(series.key)); 382 } 383 384 function encodeSeriesFlags(keys, selectedKeys) { 385 return keys.map(key => (selectedKeys.includes(key) ? "1" : "0")).join(""); 386 } 387 388 function encodeSeriesFlagsFor(chartKey) { 389 const keys = seriesKeysFor(chartKey); 390 const selectedKeys = selectedSeriesFor(chartKey).map(series => series.key); 391 return encodeSeriesFlags(keys, selectedKeys); 392 } 393 394 function applySeriesFlags(flags, chartKey) { 395 const keys = seriesKeysFor(chartKey); 396 const inputs = chartConfigs[chartKey].seriesInputs; 397 if (!flags || flags.length !== keys.length) return; 398 inputs.forEach((input, idx) => { 399 input.checked = flags[idx] === "1"; 400 }); 401 } 402 403 function formatDate(value) { 404 return new Date(value).toISOString().slice(0, 10); 405 } 406 407 function fetchData() { 408 const granularity = granularityEl.value; 409 const range = rangeEl.value; 410 const retentionGranularity = retentionGranularityEl.value; 411 const retentionSegment = retentionSegmentEl.value; 412 let url = `/stats/data?granularity=${granularity}&range=${range}`; 413 url += `&retention_granularity=${retentionGranularity}`; 414 url += `&retention_segment=${retentionSegment}`; 415 if (range === "custom") { 416 const from = fromEl.value; 417 const to = toEl.value; 418 if (from && to) { 419 url += `&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; 420 } 421 } 422 return fetch(url) 423 .then(resp => resp.json()); 424 } 425 426 function syncUrl() { 427 const granularity = granularityEl.value; 428 const range = rangeEl.value; 429 const retentionGranularity = retentionGranularityEl.value; 430 const retentionSegment = retentionSegmentEl.value; 431 const params = new URLSearchParams(); 432 params.set("granularity", granularity); 433 params.set("range", range); 434 params.set("retention_granularity", retentionGranularity); 435 params.set("retention_segment", retentionSegment); 436 params.set("collapsed", encodeCollapseFlags()); 437 params.set("series_main", encodeSeriesFlagsFor("main")); 438 params.set("series_depth", encodeSeriesFlagsFor("depth")); 439 params.set("series_created_depth", encodeSeriesFlagsFor("created_depth")); 440 params.set("series_message_breakdown", encodeSeriesFlagsFor("message_breakdown")); 441 params.set("series_attachments", encodeSeriesFlagsFor("attachments")); 442 params.set("series_new_topic_attachments", encodeSeriesFlagsFor("new_topic_attachments")); 443 params.set("series_longevity", encodeSeriesFlagsFor("longevity")); 444 params.set("series_participant_lifetime", encodeSeriesFlagsFor("participant_lifetime")); 445 params.set("series_new_participant_rate", encodeSeriesFlagsFor("new_participant_rate")); 446 params.set("series_participant_retention", encodeSeriesFlagsFor("participant_retention")); 447 params.set("series_retention_milestones", encodeSeriesFlagsFor("retention_milestones")); 448 if (range === "custom") { 449 if (fromEl.value) params.set("from", fromEl.value); 450 if (toEl.value) params.set("to", toEl.value); 451 } 452 const next = `${window.location.pathname}?${params.toString()}`; 453 window.history.replaceState({}, "", next); 454 } 455 456 function hydrateControlsFromUrl() { 457 const params = new URLSearchParams(window.location.search); 458 const granularity = params.get("granularity"); 459 const range = params.get("range"); 460 const retentionGranularity = params.get("retention_granularity"); 461 const retentionSegment = params.get("retention_segment"); 462 const from = params.get("from"); 463 const to = params.get("to"); 464 const collapsed = params.get("collapsed"); 465 const seriesMain = params.get("series_main") || params.get("series"); 466 const seriesDepth = params.get("series_depth"); 467 const seriesCreatedDepth = params.get("series_created_depth"); 468 const seriesMessageBreakdown = params.get("series_message_breakdown"); 469 const seriesAttachments = params.get("series_attachments"); 470 const seriesNewTopicAttachments = params.get("series_new_topic_attachments"); 471 const seriesLongevity = params.get("series_longevity"); 472 const seriesParticipantLifetime = params.get("series_participant_lifetime"); 473 const seriesNewParticipantRate = params.get("series_new_participant_rate"); 474 const seriesParticipantRetention = params.get("series_participant_retention"); 475 const seriesRetentionMilestones = params.get("series_retention_milestones"); 476 477 if (granularity) granularityEl.value = granularity; 478 if (range) rangeEl.value = range; 479 if (retentionGranularity) retentionGranularityEl.value = retentionGranularity; 480 if (retentionSegment) retentionSegmentEl.value = retentionSegment; 481 if (from) fromEl.value = from; 482 if (to) toEl.value = to; 483 applyCollapseFlags(collapsed); 484 applySeriesFlags(seriesMain, "main"); 485 applySeriesFlags(seriesDepth, "depth"); 486 applySeriesFlags(seriesCreatedDepth, "created_depth"); 487 applySeriesFlags(seriesMessageBreakdown, "message_breakdown"); 488 applySeriesFlags(seriesAttachments, "attachments"); 489 applySeriesFlags(seriesNewTopicAttachments, "new_topic_attachments"); 490 applySeriesFlags(seriesLongevity, "longevity"); 491 applySeriesFlags(seriesParticipantLifetime, "participant_lifetime"); 492 applySeriesFlags(seriesNewParticipantRate, "new_participant_rate"); 493 applySeriesFlags(seriesParticipantRetention, "participant_retention"); 494 if (seriesRetentionMilestones) { 495 applySeriesFlags(seriesRetentionMilestones, "retention_milestones"); 496 } 497 toggleCustomRange(); 498 } 499 500 function decorateIntervals(intervals) { 501 return intervals.map(row => { 502 const intervalKey = row.interval_start; 503 const intervalTs = Date.parse(`${intervalKey}T00:00:00Z`); 504 const messages_total = row.messages_total || 0; 505 const messages_committers = row.messages_committers || 0; 506 const messages_contributors = row.messages_contributors || 0; 507 const contributors_non_committers = Math.max(messages_contributors - messages_committers, 0
507); 508 const messages_new = row.messages_new_participants || 0; 509 const messages_regular = Math.max( 510 messages_total - messages_committers - contributors_non_committers - messages_new, 511 0 512 ); 513 const topics_new = row.topics_new || 0; 514 const participants_new = row.participants_new || 0; 515 const retained_365_participants = row.retained_365_participants || 0; 516 const participants_new_not_retained_365 = Math.max(participants_new - retained_365_participants, 0); 517 const topics_new_no_attachments = row.topics_new_no_attachments || 0; 518 const topics_new_commitfest_in_progress = row.topics_new_commitfest_in_progress || 0; 519 const topics_new_commitfest_abandoned = row.topics_new_commitfest_abandoned || 0; 520 const topics_new_commitfest_committed = row.topics_new_commitfest_committed || 0; 521 const topics_new_with_attachments = Math.max(topics_new - topics_new_no_attachments, 0); 522 const topics_new_commitfest_total = ( 523 topics_new_commitfest_in_progress + 524 topics_new_commitfest_abandoned + 525 topics_new_commitfest_committed 526 ); 527 const topics_new_with_attachments_no_commitfest = row.topics_new_with_attachments_no_commitfest ?? Math.max( 528 topics_new_with_attachments - topics_new_commitfest_total, 529 0 530 ); 531 const topics_new_by_new_users = row.topics_new_by_new_users || 0; 532 const topics_new_by_new_users_with_attachments = row.topics_new_with_attachments_by_new_users || 0; 533 const topics_new_by_new_users_no_attachments = Math.max( 534 topics_new_by_new_users - topics_new_by_new_users_with_attachments, 535 0 536 ); 537 const topics_new_by_existing_users = Math.max(topics_new - topics_new_by_new_users, 0); 538 const topics_new_by_existing_users_with_attachments = Math.max( 539 topics_new_with_attachments - topics_new_by_new_users_with_attachments, 540 0 541 ); 542 const topics_new_by_existing_users_no_attachments = Math.max( 543 topics_new_by_existing_users - topics_new_by_existing_users_with_attachments, 544 0 545 ); 546 return Object.assign({}, row, { 547 interval_key: intervalKey, 548 interval_ts: intervalTs, 549 messages_contributors_non_committers: contributors_non_committers, 550 messages_regular_participants: messages_regular, 551 pct_messages_committers: messages_total ? row.messages_committers / messages_total : 0, 552 pct_messages_contributors: messages_total ? row.messages_contributors / messages_total : 0, 553 pct_topics_new_without_contributor_activity: topics_new ? row.topics_new_without_contributor_activity / topics_new : 0, 554 participants_new_not_retained_365: participants_new_not_retained_365, 555 topics_new_no_attachments: topics_new_no_attachments, 556 topics_new_with_attachments_no_commitfest: topics_new_with_attachments_no_commitfest, 557 topics_new_commitfest_in_progress: topics_new_commitfest_in_progress, 558 topics_new_commitfest_abandoned: topics_new_commitfest_abandoned, 559 topics_new_commitfest_committed: topics_new_commitfest_committed, 560 topics_new_by_new_users_no_attachments: topics_new_by_new_users_no_attachments, 561 topics_new_by_new_users_with_attachments: topics_new_by_new_users_with_attachments, 562 topics_new_by_existing_users_no_attachments: topics_new_by_existing_users_no_attachments, 563 topics_new_by_existing_users_with_attachments: topics_new_by_existing_users_with_attachments 564 }); 565 }); 566 } 567 568 function chartWidthFor(el) { 569 if (!el?.getBoundingClientRect) return 320; 570 const container = el.parentElement || el; 571 const containerWidth = container.getBoundingClientRect().width; 572 const style = window.getComputedStyle(el); 573 const padding = 574 (parseFloat(style.paddingLeft) || 0) + 575 (parseFloat(style.paddingRight) || 0); 576 const border = 577 (parseFloat(style.borderLeftWidth) || 0) + 578 (parseFloat(style.borderRightWidth) || 0); 579 const width = containerWidth - padding - border - 20; 580 return Math.max(320, Math.floor(width)); 581 } 582 583 function buildLineSpec(intervals, width, height, enabledSeries) { 584 const chartColors = getChartColors(); 585 const points = intervals.flatMap(row => ( 586 enabledSeries.map(series => ({ 587 interval_ts: row.interval_ts, 588 series: series.key, 589 series_label: series.label, 590 value: row[series.key] || 0 591 })) 592 )); 593 const marks = enabledSeries.map(series => ({ 594 type: "line", 595 from: { data: "stats" }, 596 encode: { 597 enter: { 598 x: { scale: "x", field: "interval_ts" }, 599 y: { scale: "y", field: series.key }, 600 stroke: { value: series.color || "#666" }, 601 strokeWidth: { value: 2 }, 602 interpolate: { value: "step-after" } 603 } 604 } 605 })); 606 607 const spec = { 608 $schema: "https://vega.github.io/schema/vega/v5.json", 609 width: width, 610 height: height, 611 padding: 10, 612 autosize: { type: "fit", contains: "padding" }, 613 scales: [ 614 { name: "x", type: "time", domain: { data: "stats", field: "interval_ts" }, range: "width" }, 615 { name: "y", type: "linear", domain: { data: "stats", fields: enabledSeries.map(series => series.key) }, nice: true, range: "height" },
616 { name: "color", type: "ordinal", domain: enabledSeries.map(series => series.key), range: enabledSeries.map(series => series.color || "#666") } 617 ], 618 axes: [ 619 { orient: "bottom", scale: "x", format: "%Y-%m-%d", formatType: "utc", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }, 620 { orient: "left", scale: "y", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor } 621 ], 622 signals: [ 623 { name: "hoverTs", value: null, on: [ 624 { events: "mousemove", update: "clamp(invert('x', x()), domain('x')[0], domain('x')[1])" }, 625 { events: "mouseout", update: "null" } 626 ] } 627 ], 628 data: [ 629 { name: "stats", values: intervals }, 630 { name: "points", values: points }, 631 { 632 name: "hover_points", 633 source: "points", 634 transform: [ 635 { type: "formula", as: "dist", expr: "hoverTs == null ? null : abs(datum.interval_ts - hoverTs)" }, 636 { type: "window", sort: { field: "dist", order: "ascending" }, groupby: ["series"], ops: ["row_number"], as: ["rank"] }, 637 { type: "filter", expr: "hoverTs != null && datum.rank == 1" }, 638 { type: "window", sort: { field: "series_label", order: "ascending" }, ops: ["row_number"], as: ["idx"] } 639 ] 640 } 641 ], 642 marks: marks.concat([ 643 { 644 type: "rule", 645 encode: { 646 update: { 647 x: { scale: "x", signal: "hoverTs" }, 648 y: { value: 0 }, 649 y2: { signal: "height" }, 650 stroke: { value: "#c7c7c7" }, 651 strokeWidth: { value: 1 }, 652 opacity: { signal: "hoverTs == null ? 0 : 1" } 653 } 654 } 655 }, 656 { 657 type: "symbol", 658 from: { data: "hover_points" }, 659 encode: { 660 enter: { 661 x: { scale: "x", field: "interval_ts" }, 662 y: { scale: "y", field: "value" }, 663 size: { value: 64 }, 664 fill: { scale: "color", field: "series" }, 665 opacity: { value: 1 } 666 } 667 } 668 }, 669 { 670 type: "group", 671 encode: { 672 enter: { 673 x: { signal: "clamp(scale('x', hoverTs) + 10, 10, width - 240)" }, 674 y: { value: 10 } 675 } 676 }, 677 marks: [ 678 { 679 type: "rect", 680 encode: { 681 update: { 682 x: { value: 0 }, 683 y: { value: 0 }, 684 width: { value: 230 }, 685 height: { signal: "32 + data('hover_points').length * 18" }, 686 fill: { value: "white" }, 687 stroke: { value: "#d0d0d0" }, 688 cornerRadius: { value: 6 }, 689 opacity: { signal: "hoverTs == null ? 0 : 1" } 690 } 691 } 692 }, 693 { 694 type: "text", 695 encode: { 696 update: { 697 x: { value: 10 }, 698 y: { value: 16 }, 699 text: { signal: "hoverTs == null ? '' : timeFormat(hoverTs, '%Y-%m-%d')" }, 700 fontWeight: { value: "600" }, 701 fill: { value: "#111" }, 702 opacity: { signal: "hoverTs == null ? 0 : 1" } 703 } 704 } 705 }, 706 { 707 type: "text", 708 from: { data: "hover_points" }, 709 encode: { 710 update: { 711 x: { value: 10 }, 712 y: { signal: "30 + (datum.idx - 1) * 18" }, 713 text: { signal: "datum.series_label + ': ' + datum.value" }, 714 fill: { value: "#111" }, 715 opacity: { signal: "hoverTs == null ? 0 : 1" } 716 } 717 } 718 } 719 ] 720 } 721 ]) 722 }; 723 return { spec: spec, points: points }; 724 } 725 726 function buildStackedBarSpec(intervals, width, height, enabledSeries) { 727 const chartColors = getChartColors(); 728 const seriesOrder = new Map(enabledSeries.map((series, idx) => [series.key, idx])); 729 const points = intervals.flatMap(row => ( 730 enabledSeries.map(series => ({ 731 interval_ts: row.interval_ts, 732 series: series.key, 733 series_label: series.label, 734 series_order: seriesOrder.get(series.key) ?? 0, 735 value: row[series.key] || 0 736 })) 737 )); 738 const barWidth = Math.max(6, Math.floor(width / Math.max(intervals.length, 1) * 0.75
738)); 739 const spec = { 740 $schema: "https://vega.github.io/schema/vega/v5.json", 741 width: width, 742 height: height, 743 padding: 10, 744 autosize: { type: "fit", contains: "padding" }, 745 signals: [ 746 { name: "hoverTs", value: null, on: [ 747 { events: "mousemove", update: "clamp(invert('x', x()), domain('x')[0], domain('x')[1])" }, 748 { events: "mouseout", update: "null" } 749 ] }, 750 { name: "barWidth", value: barWidth } 751 ], 752 data: [ 753 { name: "stats", values: intervals }, 754 { name: "points", values: points }, 755 { 756 name: "stacked", 757 source: "points", 758 transform: [ 759 { type: "stack", groupby: ["interval_ts"], field: "value", sort: { field: "series_order", order: "ascending" } } 760 ] 761 }, 762 { 763 name: "stack_sums", 764 source: "points", 765 transform: [ 766 { type: "aggregate", groupby: ["interval_ts"], fields: ["value"], ops: ["sum"], as: ["total"] } 767 ] 768 }, 769 { 770 name: "trend", 771 source: "stack_sums", 772 transform: [ 773 { type: "loess", x: "interval_ts", y: "total", bandwidth: 0.3 } 774 ] 775 }, 776 { 777 name: "hover_points", 778 source: "points", 779 transform: [ 780 { type: "formula", as: "dist", expr: "hoverTs == null ? null : abs(datum.interval_ts - hoverTs)" }, 781 { type: "window", sort: { field: "dist", order: "ascending" }, groupby: ["series"], ops: ["row_number"], as: ["rank"] }, 782 { type: "filter", expr: "hoverTs != null && datum.rank == 1" }, 783 { type: "window", sort: { field: "series_label", order: "ascending" }, ops: ["row_number"], as: ["idx"] } 784 ] 785 } 786 ], 787 scales: [ 788 { name: "x", type: "time", domain: { data: "stats", field: "interval_ts" }, range: "width" }, 789 { name: "y", type: "linear", domain: { data: "stack_sums", field: "total" }, nice: true, range: "height" }, 790 { name: "color", type: "ordinal", domain: enabledSeries.map(series => series.key), range: enabledSeries.map(series => series.color || "#666") } 791 ], 792 axes: [ 793 { orient: "bottom", scale: "x", format: "%Y-%m-%d", formatType: "utc", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }, 794 { orient: "left", scale: "y", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor } 795 ], 796 marks: [ 797 { 798 type: "rect", 799 from: { data: "stacked" }, 800 encode: { 801 enter: { 802 x: { signal: "scale('x', datum.interval_ts) - barWidth / 2" }, 803 width: { signal: "barWidth" }, 804 y: { scale: "y", field: "y1" }, 805 y2: { scale: "y", field: "y0" }, 806 fill: { scale: "color", field: "series" } 807 } 808 } 809 }, 810 { 811 type: "line", 812 from: { data: "trend" }, 813 encode: { 814 enter: { 815 x: { scale: "x", field: "interval_ts" }, 816 y: { scale: "y", field: "total" }, 817 stroke: { value: "#111" }, 818 strokeWidth: { value: 2 }, 819 opacity: { value: 0.6 } 820 } 821 } 822 }, 823 { 824 type: "rule", 825 encode: { 826 update: { 827 x: { scale: "x", signal: "hoverTs" }, 828 y: { value: 0 }, 829 y2: { signal: "height" }, 830 stroke: { value: "#c7c7c7" }, 831 strokeWidth: { value: 1 }, 832 opacity: { signal: "hoverTs == null ? 0 : 1" } 833 } 834 } 835 }, 836 { 837 type: "group", 838 encode: { 839 enter: { 840 x: { signal: "clamp(scale('x', hoverTs) + 10, 10, width - 240)" }, 841 y: { value: 10 } 842 } 843 }, 844 marks: [ 845 { 846 type: "rect", 847 encode: { 848 update: { 849 x: { value: 0 }, 850 y: { value: 0 }, 851 width: { value: 230 }, 852 height: { signal: "32 + data('hover_points').length * 18" }, 853 fill: { value: "white" }, 854 stroke: { value: "#d0d0d0" }, 855 cornerRadius: { value: 6 }, 856 opacity: { signal: "hoverTs == null ? 0 : 1" } 857 } 858 } 859 }, 860 { 861 type: "text", 862 encode: { 863 update: { 864 x: { value: 10 }, 865 y: { value: 16 }, 866 text: { signal: "hoverTs == null ? '' : timeFormat(hoverTs, '%Y-%m-%d')" }, 867 fontWeight: { value: "600" }, 868 fill: { value: "#111" }, 869 opacity: { signal: "hoverTs == null ? 0 : 1" } 870 } 871 } 872 }, 873 { 874 type: "text", 875 from: { data: "hover_points" }, 876 encode: { 877 update: { 878 x: { value: 10 }, 879 y: { signal: "30 + (datum.idx - 1) * 18" }, 880 text: { signal: "datum.series_label + ': ' + datum.value" }, 881 fill: { value: "#111" }, 882 opacity: { signal: "hoverTs == null ? 0 : 1" } 883 } 884 } 885 } 886 ] 887 } 888 ] 889 }; 890 return { spec: spec, points: points }; 891 } 892 893 function buildOverlayBarSpec(intervals, width, height, enabledSeries
893) { 894 const chartColors = getChartColors(); 895 if (!intervals.length) { 896 return { spec: null, points: [] }; 897 } 898 const points = intervals.flatMap(row => ( 899 enabledSeries.map((series, idx) => ({ 900 interval_key: row.interval_key, 901 interval_ts: row.interval_ts, 902 series: series.key, 903 series_label: series.label, 904 value: row[series.key] || 0, 905 color: series.color || "#666", 906 order: idx + 1 907 })) 908 )); 909 const spec = { 910 $schema: "https://vega.github.io/schema/vega/v5.json", 911 width: width, 912 height: height, 913 padding: 10, 914 autosize: { type: "fit", contains: "padding" }, 915 signals: [ 916 { name: "hoverKey", value: null, on: [ 917 { events: "@bands:mousemove", update: "datum.interval_key" }, 918 { events: "@bands:mouseout", update: "null" } 919 ] } 920 ], 921 data: [ 922 { name: "points", values: points }, 923 { 924 name: "hover_points", 925 source: "points", 926 transform: [ 927 { type: "filter", expr: "hoverKey != null && datum.interval_key === hoverKey" }, 928 { type: "collect", sort: { field: "order", order: "ascending" } }, 929 { type: "window", ops: ["row_number"], as: ["idx"] } 930 ] 931 } 932 ], 933 scales: [ 934 { name: "x", type: "band", domain: { data: "points", field: "interval_key" }, range: "width", padding: 0.2 }, 935 { name: "y", type: "linear", domain: [0, 1], nice: false, range: "height" } 936 ], 937 axes: [ 938 { orient: "bottom", scale: "x", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }, 939 { orient: "left", scale: "y", format: ".0%", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor } 940 ], 941 marks: [ 942 { 943 type: "rect", 944 from: { data: "points" }, 945 encode: { 946 enter: { 947 x: { scale: "x", field: "interval_key" }, 948 width: { scale: "x", band: 1 }, 949 y: { scale: "y", field: "value" }, 950 y2: { scale: "y", value: 0 }, 951 fill: { field: "color" }, 952 opacity: { value: 1 }, 953 zindex: { field: "order" } 954 } 955 } 956 }, 957 { 958 name: "bands", 959 type: "rect", 960 from: { data: "points" }, 961 encode: { 962 enter: { 963 x: { scale: "x", field: "interval_key" }, 964 width: { scale: "x", band: 1 }, 965 y: { value: 0 }, 966 y2: { signal: "height" }, 967 fill: { value: "transparent" } 968 } 969 } 970 }, 971 { 972 type: "rule", 973 encode: { 974 update: { 975 x: { signal: "hoverKey == null ? 0 : scale('x', hoverKey) + bandwidth('x') / 2" }, 976 y: { value: 0 }, 977 y2: { signal: "height" }, 978 stroke: { value: "#c7c7c7" }, 979 strokeWidth: { value: 1 }, 980 opacity: { signal: "hoverKey == null ? 0 : 1" } 981 } 982 } 983 }, 984 { 985 type: "group", 986 encode: { 987 enter: { 988 x: { signal: "clamp(scale('x', hoverKey) + 10, 10, width - 240)" }, 989 y: { value: 10 } 990 } 991 }, 992 marks: [ 993 { 994 type: "rect", 995 encode: { 996 update: { 997 x: { value: 0 }, 998 y: { value: 0 }, 999 width: { value: 230 }, 1000 height: { signal: "32 + data('hover_points').length * 18" }, 1001 fill: { value: "white" }, 1002 stroke: { value: "#d0d0d0" }, 1003 cornerRadius: { value: 6 }, 1004 opacity: { signal: "hoverKey == null ? 0 : 1" } 1005 } 1006 } 1007 }, 1008 { 1009 type: "text", 1010 encode: { 1011 update: { 1012 x: { value: 10 }, 1013 y: { value: 16 }, 1014 text: { signal: "hoverKey == null ? '' : timeFormat(toDate(hoverKey), '%Y-%m')" }, 1015 fontWeight: { value: "600" }, 1016 fill: { value: "#111" }, 1017 opacity: { signal: "hoverKey == null ? 0 : 1" } 1018 } 1019 } 1020 }, 1021 { 1022 type: "text", 1023 from: { data: "hover_points" }, 1024 encode: { 1025 update: { 1026 x: { value: 10 }, 1027 y: { signal: "30 + (datum.idx - 1) * 18" }, 1028 text: { signal: "datum.series_label + ': ' + format(datum.value, '.1%')" }, 1029 fill: { value: "#111" }, 1030 opacity: { signal: "hoverKey == null ? 0 : 1" } 1031 } 1032 } 1033 } 1034 ] 1035 } 1036 ] 1037 }; 1038 return { spec: spec, points: points }; 1039 } 1040 1041 function buildLongevityHeatmapSpec(histogram, width) { 1042 const chartColors = getChartColors(); 1043 const bucketOrder = ["0-1", "2-7", "8-30", "31-90", "91-180", "181-365", "365+"]; 1044 const points = histogram.map(row => ({ 1045 interval_ts: Date.parse(`${row.interval_start}T00:00:00Z`), 1046 bucket: row.bucket, 1047 count: row.count || 0 1048 })); 1049 return { 1050 $schema: "https://vega.github.io/schema/vega/v5.json", 1051 width: width, 1052 height: 260, 1053 padding: 10, 1054 autosize: { type: "fit", contains: "padding" }, 1055 data: [{ name: "heat", values: points }], 1056 scales: [ 1057 { name: "x", type: "time", domain: { data: "heat", field: "interval_ts" }, range: "width" }, 1058 { name: "y", type: "band", domain: bucketOrder, range: "height", padding: 0.1 },
1059 { name: "color", type: "linear", domain: { data: "heat", field: "count" }, nice: true, range: ["#f2f0f7", "#54278f"] } 1060 ], 1061 axes: [ 1062 { orient: "bottom", scale: "x", format: "%Y-%m-%d", formatType: "utc", labelOverlap: true, labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor }, 1063 { orient: "left", scale: "y", labelColor: chartColors.textColor, tickColor: chartColors.textColor, domainColor: chartColors.textColor } 1064 ], 1065 marks: [ 1066 { 1067 type: "rect", 1068 from: { data: "heat" }, 1069 encode: { 1070 enter: { 1071 x: { scale: "x", field: "interval_ts" }, 1072 width: { value: 6 }, 1073 y: { scale: "y", field: "bucket" }, 1074 height: { scale: "y", band: 1 }, 1075 fill: { signal: "datum.count === 0 ? '#e0e0e0' : scale('color', datum.count)" } 1076 } 1077 } 1078 } 1079 ] 1080 }; 1081 } 1082 1083 function monthStart(dateStr) { 1084 const date = new Date(`${dateStr}T00:00:00Z`); 1085 return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)); 1086 } 1087 1088 function monthsBetween(start, end) { 1089 return (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + (end.getUTCMonth() - start.getUTCMonth()); 1090 } 1091 1092 function bucketMonthStart(date, bucketSize) { 1093 const year = date.getUTCFullYear();
1094 const month = date.getUTCMonth(); 1095 const bucketMonth = Math.floor(month / bucketSize) * bucketSize; 1096 return new Date(Date.UTC(year, bucketMonth, 1)); 1097 } 1098 1099 function buildRetentionHeatmapData(rows, rangeStart, rangeEnd) { 1100 const points = []; 1101 const cohortDomain = []; 1102 const seenCohorts = new Set(); 1103 let maxMonths = 0; 1104 let maxIntensity = 0; 1105 let minIntensity = null; 1106 1107 rows.forEach(row => { 1108 const cohortDate = monthStart(row.cohort_start); 1109 if (cohortDate < rangeStart || cohortDate > rangeEnd) return; 1110 const cohortKey = cohortDate.toISOString().slice(0, 10); 1111 if (!seenCohorts.has(cohortKey)) { 1112 cohortDomain.push(cohortKey); 1113 seenCohorts.add(cohortKey); 1114 } 1115 const monthsSince = Number(row.months_since); 1116 const retentionRate = row.cohort_size ? Number(row.active_users || 0) / Number(row.cohort_size) : 0; 1117 const intensity = Number(row.avg_messages_per_active_user || 0); 1118 maxMonths = Math.max(maxMonths, monthsSince); 1119 maxIntensity = Math.max(maxIntensity, intensity); 1120 if (intensity > 0) { 1121 minIntensity = minIntensity === null ? intensity : Math.min(minIntensity, intensity); 1122 } 1123 points.push({ 1124 cohort_start: cohortKey, 1125 months_since: monthsSince, 1126 retention_rate: retentionRate, 1127 avg_messages_per_active_user: intensity 1128 }); 1129 }); 1130 1131 cohortDomain.sort().reverse(); 1132 1133 return { 1134 points: points, 1135 cohortDomain: cohortDomain, 1136 maxMonths: maxMonths, 1137 maxIntensity: maxIntensity, 1138 minIntensity: minIntensity 1139 }; 1140 } 1141 1142 function buildRetentionHeatmapSpec(points, cohortDomain, width, height, valueField, domainMin, domainMax, colorRange, tooltipSignal, scaleType = "linear") { 1143 return { 1144 $schema: "https://vega.github.io/schema/vega/v5.json", 1145 width: width, 1146 height: height, 1147 padding: 10, 1148 autosize: { type: "fit", contains: "padding" }, 1149 data: [{ name: "heat", values: points }], 1150 scales: [ 1151 { name: "x", type: "band", domain: { data: "heat", field: "months_since", sort: true }, range: "width", padding: 0.03 }, 1152 { name: "y", type: "band", domain: cohortDomain, range: "height", padding: 0.02 }, 1153 { name: "color", type: scaleType, domain: [domainMin, domainMax], nice: true, range: colorRange, clamp: true } 1154 ], 1155 axes: [ 1156 { 1157 orient: "bottom", 1158 scale: "x", 1159 labels: false, 1160 ticks: false 1161 }, 1162 { 1163 orient: "left", 1164 scale: "y", 1165 labels: false, 1166 ticks: false 1167 } 1168 ], 1169 marks: [ 1170 { 1171 type: "rect", 1172 from: { data: "heat" }, 1173 encode: { 1174 enter: { 1175 x: { scale: "x", field: "months_since" }, 1176 width: { scale: "x", band: 1 }, 1177 y: { scale: "y", field: "cohort_start" }, 1178 height: { scale: "y", band: 1 }, 1179 fill: { signal: `datum.${valueField} === 0 ? '#e0e0e0' : scale('color', datum.${valueField})` }, 1180 tooltip: { signal: tooltipSignal } 1181 } 1182 } 1183 } 1184 ] 1185 }; 1186 } 1187 1188 const retentionMilestoneKeys = new Map([ 1189 [3, "retention_q1"], 1190 [6, "retention_q2"], 1191 [12, "retention_q4"], 1192 [18, "retention_q6"], 1193 [24, "retention_q8"], 1194 [30, "retention_q10"], 1195 [36, "retention_q12"], 1196 [48, "retention_q16"], 1197 [60, "retention_q20"] 1198 ]); 1199 1200 function buildRetentionMilestoneIntervals(rows, rangeStart, rangeEnd) { 1201 const byCohort = new Map(); 1202 rows.forEach(row => { 1203 const cohortDate = monthStart(row.cohort_start); 1204 if (cohortDate < rangeStart || cohortDate > rangeEnd) return; 1205 const key = cohortDate.toISOString().slice(0, 10); 1206 const horizonKey = retentionMilestoneKeys.get(Number(row.horizon_months)); 1207 if (!horizonKey) return; 1208 const entry = byCohort.get(key) || { interval_start: key }; 1209 const cohortSize = Number(row.cohort_size || 0); 1210 const retainedUsers = Number(row.retained_users || 0); 1211 entry[horizonKey] = cohortSize ? retainedUsers / cohortSize : 0; 1212 byCohort.set(key, entry); 1213 }); 1214 1215 return Array.from(byCohort.values()) 1216 .map(entry => Object.assign({}, entry, { 1217 interval_key: entry.interval_start, 1218 interval_ts: Date.parse(`${entry.interval_start}T00:00:00Z`) 1219 })) 1220 .sort((a, b) => a.interval_ts - b.interval_ts); 1221 } 1222 1223 let latestIntervals = []; 1224 const latestIntervalsByChart = {}; 1225 const latestPointsByChart = {}; 1226 1227 function stripEditorData(spec) { 1228 const clone = JSON.parse(JSON.stringify(spec)); 1229 if (Array.isArray(clone.data)) { 1230 clone.data = clone.data.map(entry => { 1231 if (entry && Object.prototype.hasOwnProperty.call(entry, "values")) { 1232 const stripped = Object.assign({}, entry); 1233 delete stripped.values; 1234 return stripped; 1235 } 1236 return entry; 1237 }); 1238 } 1239 return clone; 1240 } 1241 1242 function mergeEditorData(spec, intervals, points) { 1243 const clone = JSON.parse(JSON.stringify(spec)); 1244 const existing = Array.isArray(clone.data) ? clone.data : []; 1245 const withoutSeries = existing.filter(entry => entry.name !== "stats" && entry.name !== "points"); 1246 clone.data = [ 1247 { name: "stats", values: intervals }, 1248 { name: "points", values: points }, 1249 ...withoutSeries 1250 ]; 1251 return clone; 1252 } 1253 1254 function renderLineChart(chartKey, intervals, embedOpts) { 1255 const config = chartConfigs[chartKey]; 1256 const enabledSeries = selectedSeriesFor(chartKey); 1257 if (!enabledSeries.length) { 1258 config.el.textContent = "Select at least one series."; 1259 return null; 1260 } 1261 const chartWidth = chartWidthFor(config.el); 1262 const result = config.type === "stacked_bar" 1263 ? buildStackedBarSpec(intervals, chartWidth, config.height, enabledSeries
1263) 1264 : (config.type === "overlay_bar" 1265 ? buildOverlayBarSpec(intervals, chartWidth, config.height, enabledSeries) 1266 : buildLineSpec(intervals, chartWidth, config.height, enabledSeries)); 1267 if (!result || !result.spec) { 1268 config.el.textContent = "No data for this range yet."; 1269 return null; 1270 } 1271 latestPointsByChart[chartKey] = result.points; 1272 latestIntervalsByChart[chartKey] = intervals; 1273 if (config.editorEl) { 1274 const editorSpec = stripEditorData(result.spec); 1275 config.editorEl.value = JSON.stringify(editorSpec, null, 2); 1276 } 1277 return vegaEmbed(config.el, result.spec, embedOpts); 1278 } 1279 1280 function renderCharts(data) { 1281 const intervals = decorateIntervals(data.intervals || []); 1282 if (!intervals.length || intervals.every(row => !Number.isFinite(row.interval_ts))) { 1283 mainEl.textContent = "No statistics available for this range yet."; 1284 depthEl.textContent = "No statistics available for this range yet."; 1285 createdDepthEl.textContent = "No statistics available for this range yet."; 1286 messageBreakdownEl.textContent = "No statistics available for this range yet."; 1287 attachmentsEl.textContent = "No statistics available for this range yet."; 1288 newTopicAttachmentsEl.textContent = "No statistics available for this range yet."; 1289 longevityEl.textContent = "No statistics available for this range yet."; 1290 participantLifetimeEl.textContent = "No statistics available for this range yet."; 1291 newParticipantRateEl.textContent = "No statistics available for this range yet."; 1292 participantRetentionEl.textContent = "No statistics available for this range yet."; 1293 retentionMilestonesEl.textContent = "No statistics available for this range yet."; 1294 longevityHeatmapEl.textContent = "No statistics available for this range yet."; 1295 retentionHeatmapEl.textContent = "No statistics available for this range yet."; 1296 retentionIntensityEl.textContent = "No statistics available for this range yet."; 1297 return; 1298 } 1299 1300 const histogram = data.longevity_histogram || []; 1301 const heatmapSpec = buildLongevityHeatmapSpec(histogram, chartWidthFor(longevityHeatmapEl)); 1302 const retentionRows = data.retention_heatmap || []; 1303 const retentionMilestones = data.retention_milestones || []; 1304 const rangeStart = monthStart(data.from); 1305 const rangeEnd = monthStart(data.to); 1306 const retentionData = buildRetentionHeatmapData(retentionRows, rangeStart, rangeEnd); 1307 const milestoneIntervals = buildRetentionMilestoneIntervals(retentionMilestones, rangeStart, rangeEnd); 1308 const rowHeight = retentionGranularityEl.value === "quarter" ? 8 : 4; 1309 const heatmapHeight = Math.max(240, retentionData.cohortDomain.length * rowHeight); 1310 const monthsLabel = retentionGranularityEl.value === "quarter" ? "q" : "mo"; 1311 const retentionSpec = retentionData.points.length 1312 ? buildRetentionHeatmapSpec( 1313 retentionData.points, 1314 retentionData.cohortDomain, 1315 chartWidthFor(retentionHeatmapEl), 1316 heatmapHeight, 1317 "retention_rate", 1318 0, 1319 1, 1320 ["#d7fbff", "#08519c"], 1321 "timeFormat(toDate(datum.cohort_start), '%Y-%m') + ' | ' + format(datum.months_since / " + (retentionGranularityEl.value === "quarter" ? 3 : 1) + ", 'd') + ' " + monthsLabel + ": ' + format(datum.retention_rate, '.1%')", 1322 "linear" 1323 ) 1324 : null; 1325 const intensitySpec = retentionData.points.length 1326 ? buildRetentionHeatmapSpec( 1327 retentionData.points, 1328 retentionData.cohortDomain, 1329 chartWidthFor(retentionIntensityEl), 1330 heatmapHeight, 1331 "avg_messages_per_active_user", 1332 Math.max(retentionData.minIntensity || 1, 1), 1333 Math.max(retentionData.maxIntensity, 1), 1334 ["#fff5eb", "#7f2704"], 1335 "timeFormat(toDate(datum.cohort_start), '%Y-%m') + ' | ' + format(datum.months_since / " + (retentionGranularityEl.value === "quarter" ? 3 : 1) + ", 'd') + ' " + monthsLabel + ": ' + format(datum.avg_messages_per_active_user, '.2f') + ' msgs/user'", 1336 "log" 1337 ) 1338 : null; 1339 1340 latestIntervals = intervals; 1341 const embedOpts = { actions: false, tooltip: true }; 1342 const embeds = []; 1343 const mainEmbed = renderLineChart("main", intervals, embedOpts); 1344 if (mainEmbed) embeds.push(mainEmbed); 1345 const depthEmbed = renderLineChart("depth", intervals, embedOpts); 1346 if (depthEmbed) embeds.push(depthEmbed); 1347 const createdDepthEmbed = renderLineChart("created_depth", intervals, embedOpts); 1348 if (createdDepthEmbed) embeds.push(createdDepthEmbed); 1349 const messageBreakdownEmbed = renderLineChart("message_breakdown", intervals, embedOpts); 1350 if (messageBreakdownEmbed) embeds.push(messageBreakdownEmbed); 1351 const attachmentsEmbed = renderLineChart("attachments", intervals, embedOpts); 1352 if (attachmentsEmbed) embeds.push(attachmentsEmbed); 1353 const newTopicAttachmentsEmbed = renderLineChart("new_topic_attachments", intervals, embedOpts); 1354 if (newTopicAttachmentsEmbed) embeds.push(newTopicAttachmentsEmbed); 1355 const longevityEmbed = renderLineChart("longevity", intervals, embedOpts); 1356 if (longevityEmbed) embeds.push(longevityEmbed); 1357 const participantLifetimeEmbed = renderLineChart("participant_lifetime", intervals, embedOpts); 1358 if (participantLifetimeEmbed) embeds.push(participantLifetimeEmbed); 1359 const newParticipantRateEmbed = renderLineChart("new_participant_rate", intervals, embedOpts); 1360 if (newParticipantRateEmbed) embeds.push(newParticipantRateEmbed); 1361 const participantRetentionEmbed = renderLineChart("participant_retention", intervals, embedOpts); 1362 if (participantRetentionEmbed) embeds.push(participantRetentionEmbed); 1363 if (milestoneIntervals.length) { 1364 const retentionMilestonesEmbed = renderLineChart("retention_milestones", milestoneIntervals, embedOpts); 1365 if (retentionMilestonesEmbed) embeds.push(retentionMilestonesEmbed); 1366 } else { 1367 retentionMilestonesEl.textContent = "No retention milestone data available for this range yet."; 1368 } 1369 embeds.push(vegaEmbed(longevityHeatmapEl, heatmapSpec, embedOpts)); 1370 if (retentionSpec) { 1371 embeds.push(vegaEmbed(retentionHeatmapEl, retentionSpec, embedOpts)); 1372 embeds.push(vegaEmbed(retentionIntensityEl, intensitySpec, embedOpts)); 1373 } else { 1374 retentionHeatmapEl.textContent = "No retention data available for this range yet."; 1375 retentionIntensityEl.textContent = "No retention data available for this range yet."; 1376 } 1377 Promise.all(embeds).catch(err => { 1378 mainEl.textContent = "Failed to render charts."; 1379 depthEl.textContent = "Failed to render charts."; 1380 createdDepthEl.textContent = "Failed to render charts."; 1381 messageBreakdownEl.textContent = "Failed to render charts."; 1382 attachmentsEl.textContent = "Failed to render charts."; 1383 newTopicAttachmentsEl.textContent = "Failed to render charts."; 1384 longevityEl.textContent = "Failed to render charts."; 1385 participantLifetimeEl.textContent = "Failed to render charts."; 1386 newParticipantRateEl.textContent = "Failed to render charts."; 1387 participantRetentionEl.textContent = "Failed to render charts."; 1388 retentionMilestonesEl.textContent = "Failed to render charts."; 1389 longevityHeatmapEl.textContent = "Failed to render charts."; 1390 retentionHeatmapEl.textContent = "Failed to render charts."; 1391 retentionIntensityEl.textContent = "Failed to render charts."; 1392 }); 1393 } 1394 1395 function load() { 1396 fetchData().then(renderCharts).catch(() => { 1397 mainEl.textContent = "Failed to load stats."; 1398 }); 1399 } 1400 1401 function toggleCustomRange() { 1402 const isCustom = rangeEl.value === "custom";
1403 customRangeEls.forEach(el => { el.hidden = !isCustom; }); 1404 } 1405 1406 Object.values(chartConfigs).forEach(config => { 1407 config.seriesInputs.forEach(input => input.addEventListener("change", () => { 1408 syncUrl(); 1409 load(); 1410 })); 1411 }); 1412 collapsibleKeys.forEach(key => { 1413 const container = collapsibleByKey.get(key); 1414 if (!container) return; 1415 container.addEventListener("toggle", () => { 1416 syncUrl(); 1417 }); 1418 }); 1419 granularityEl.addEventListener("change", () => { 1420 syncUrl(); 1421 load(); 1422 }); 1423 retentionGranularityEl.addEventListener("change", () => { 1424 syncUrl(); 1425 load(); 1426 }); 1427 retentionSegmentEl.addEventListener("change", () => { 1428 syncUrl(); 1429 load(); 1430 }); 1431 rangeEl.addEventListener("change", () => { 1432 toggleCustomRange(); 1433 if (rangeEl.value !== "custom") { 1434 syncUrl(); 1435 load(); 1436 } 1437 }); 1438 applyRangeEl.addEventListener("click", () => { 1439 syncUrl(); 1440 load(); 1441 }); 1442 1443 Object.values(chartConfigs).forEach(config => { 1444 if (!config.applyEl) return; 1445 config.applyEl.addEventListener("click", () => { 1446 try { 1447 const spec = JSON.parse(config.editorEl.value); 1448 const points = latestPointsByChart[config.key] || []; 1449 const intervals = latestIntervalsByChart[config.key] || latestIntervals; 1450 const merged = mergeEditorData(spec, intervals, points); 1451 vegaEmbed(config.el, merged, { actions: false, tooltip: true }); 1452 } catch (e) { 1453 alert("Invalid JSON spec."); 1454 } 1455 }); 1456 }); 1457 1458 hydrateControlsFromUrl(); 1459 applySeriesSwatches(); 1460 syncUrl(); 1461 load(); 1462})();</script>
1462</div></main></div></body></html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.