Last active
January 18, 2026 17:29
-
-
Save tfidfwastaken/2a49b5d2fcb2960ebf3c2e04dd680a3a to your computer and use it in GitHub Desktop.
Lobsters Algorithm Explorer - Interactive visualization
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Lobsters Algorithm Explorer</title> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| height: 100%; | |
| overflow: auto; | |
| } | |
| :root { | |
| --lobster-red: rgb(172, 19, 13); | |
| --lobster-red-dark: #8a0f0a; | |
| --link-blue: rgb(28, 89, 209); | |
| --tag-bg: #fffcd7; | |
| --tag-border: #d5d458; | |
| --tag-negative-bg: #f9ddde; | |
| --tag-negative-border: #f0b2b8; | |
| --bg-white: rgb(254, 254, 254); | |
| --text-gray: rgba(51, 51, 51, 0.59); | |
| --text-dark: #333; | |
| --border-light: #ccc; | |
| } | |
| .lobsters-explorer { | |
| font-family: "Helvetica Neue", Arial, sans-serif; | |
| font-size: 10pt; | |
| max-width: 900px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| color: #333; | |
| line-height: 1.45em; | |
| background: rgb(254, 254, 254); | |
| border: 1px solid #ccc; | |
| border-top: 3px solid var(--lobster-red); | |
| border-radius: 4px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.08); | |
| } | |
| .explorer-section { | |
| margin-bottom: 48px; | |
| } | |
| .explorer-title { | |
| font-size: 12pt; | |
| font-weight: bold; | |
| margin: 0.5em 0 1em 0; | |
| padding: 0; | |
| color: #333; | |
| } | |
| .explorer-subtitle { | |
| color: rgba(51,51,51,0.59); | |
| margin-bottom: 20px; | |
| font-size: 10pt; | |
| line-height: 1.45; | |
| } | |
| /* Story List Styles */ | |
| .story-list { | |
| background: rgb(254, 254, 254); | |
| } | |
| .story-item { | |
| display: flex; | |
| padding: 0.25em 0; | |
| transition: background 0.2s ease; | |
| position: relative; | |
| cursor: pointer; | |
| } | |
| .story-item:hover { | |
| background: #f5f5f5; | |
| } | |
| .story-item.selected { | |
| background: #fffcd7; | |
| } | |
| .story-item.rank-changed { | |
| animation: highlight 0.5s ease; | |
| } | |
| @keyframes highlight { | |
| 0%, 100% { background: transparent; } | |
| 50% { background: #fffcd7; } | |
| } | |
| .click-hint { | |
| font-size: 9.5pt; | |
| color: rgba(51,51,51,0.59); | |
| margin-bottom: 12px; | |
| } | |
| .story-vote { | |
| width: 40px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding-top: 0; | |
| flex-shrink: 0; | |
| } | |
| .vote-arrow { | |
| color: #333; | |
| font-size: 1.2rem; | |
| line-height: 1; | |
| cursor: default; | |
| } | |
| .vote-arrow:before { | |
| content: "\25b3"; | |
| } | |
| .story-score { | |
| font-size: 9.5pt; | |
| color: rgba(51,51,51,0.59); | |
| margin-top: 0; | |
| } | |
| .story-content { | |
| flex: 1; | |
| min-width: 0; | |
| padding-top: 0.1em; | |
| } | |
| .story-title-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: baseline; | |
| gap: 0.25em; | |
| margin-bottom: 1px; | |
| line-height: 1.45; | |
| } | |
| .story-title { | |
| color: rgb(28, 89, 209); | |
| font-weight: bold; | |
| font-size: 11.5pt; | |
| text-decoration: none; | |
| } | |
| .story-tag { | |
| display: inline-block; | |
| padding: 0px 0.4em 1px 0.4em; | |
| font-size: 8pt; | |
| border-radius: 5px; | |
| background: #fffcd7; | |
| border: 1px solid #d5d458; | |
| color: rgba(51,51,51,0.84); | |
| text-decoration: none; | |
| margin-left: 0.25em; | |
| white-space: nowrap; | |
| } | |
| .story-tag.negative { | |
| background: #f9ddde; | |
| border-color: #f0b2b8; | |
| } | |
| .story-tag.special { | |
| background: #f9ddde; | |
| border-color: #f0b2b8; | |
| } | |
| .story-domain { | |
| color: rgba(51,51,51,0.59); | |
| font-size: 9pt; | |
| font-style: italic; | |
| text-decoration: none; | |
| margin-left: 0.25em; | |
| } | |
| .story-meta { | |
| font-size: 9.5pt; | |
| color: rgba(51,51,51,0.59); | |
| margin-top: 1px; | |
| } | |
| .story-avatar { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| display: inline-block; | |
| vertical-align: middle; | |
| margin-right: 2px; | |
| margin-bottom: 2px; | |
| } | |
| .story-meta-text { | |
| color: rgba(51,51,51,0.59); | |
| } | |
| .story-meta-link { | |
| color: rgba(51,51,51,0.59); | |
| text-decoration: none; | |
| } | |
| .story-meta-link.user { | |
| color: rgba(51,51,51,0.59); | |
| } | |
| .story-meta-link.author { | |
| color: rgb(96, 129, 189); | |
| } | |
| .story-meta-sep { | |
| color: rgba(51,51,51,0.59); | |
| } | |
| .story-hotness { | |
| font-family: 'SF Mono', Monaco, 'Courier New', monospace; | |
| font-size: 8pt; | |
| color: rgba(51,51,51,0.59); | |
| background: #eee; | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| cursor: help; | |
| margin-left: auto; | |
| flex-shrink: 0; | |
| align-self: flex-start; | |
| margin-top: 0; | |
| } | |
| .story-hotness .tooltip { | |
| display: none; | |
| position: absolute; | |
| top: 100%; | |
| right: 0; | |
| margin-top: 6px; | |
| background: #333; | |
| color: #fff; | |
| padding: 10px 12px; | |
| border-radius: 6px; | |
| font-size: 1.1em; | |
| white-space: nowrap; | |
| z-index: 100; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
| } | |
| .story-hotness .tooltip::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: 100%; | |
| right: 12px; | |
| border: 6px solid transparent; | |
| border-bottom-color: #333; | |
| } | |
| .story-hotness:hover .tooltip { | |
| display: block; | |
| } | |
| .tooltip-row { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 20px; | |
| margin-bottom: 4px; | |
| } | |
| .tooltip-row:last-child { | |
| margin-bottom: 0; | |
| padding-top: 4px; | |
| border-top: 1px solid #555; | |
| font-weight: 600; | |
| } | |
| .tooltip-label { | |
| color: #aaa; | |
| } | |
| .tooltip-value { | |
| color: #4ecdc4; | |
| } | |
| /* Controls Panel */ | |
| .controls-panel { | |
| background: #f9f9f9; | |
| border: 1px solid var(--border-light); | |
| border-radius: 4px; | |
| padding: 16px; | |
| margin-bottom: 16px; | |
| display: none; | |
| } | |
| .controls-panel.visible { | |
| display: block; | |
| } | |
| .controls-panel-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 12px; | |
| } | |
| .controls-panel-title { | |
| font-weight: 600; | |
| color: var(--lobster-red); | |
| font-size: 1em; | |
| } | |
| .controls-panel-close { | |
| background: none; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| padding: 4px 10px; | |
| cursor: pointer; | |
| font-size: 0.85em; | |
| color: #666; | |
| } | |
| .controls-panel-close:hover { | |
| background: #eee; | |
| } | |
| .story-control { | |
| background: #fff; | |
| border: 1px solid #e0e0e0; | |
| border-radius: 6px; | |
| padding: 12px; | |
| } | |
| .story-control-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 10px; | |
| } | |
| .story-control-title { | |
| font-weight: 600; | |
| color: var(--lobster-red); | |
| font-size: 0.95em; | |
| } | |
| .story-control-hotness { | |
| font-family: 'SF Mono', Monaco, monospace; | |
| font-size: 0.8em; | |
| color: #666; | |
| } | |
| .control-row { | |
| display: flex; | |
| align-items: center; | |
| margin-bottom: 8px; | |
| gap: 8px; | |
| } | |
| .control-row:last-child { | |
| margin-bottom: 0; | |
| } | |
| .control-label { | |
| font-size: 10pt; | |
| font-weight: 500; | |
| color: #333; | |
| width: 110px; | |
| flex-shrink: 0; | |
| } | |
| .control-slider { | |
| flex: 1; | |
| -webkit-appearance: none; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: #e0e0e0; | |
| outline: none; | |
| } | |
| .control-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--lobster-red); | |
| cursor: pointer; | |
| } | |
| .control-slider::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--lobster-red); | |
| cursor: pointer; | |
| border: none; | |
| } | |
| .control-value { | |
| font-family: 'SF Mono', Monaco, monospace; | |
| font-size: 10pt; | |
| width: 50px; | |
| text-align: right; | |
| color: #333; | |
| } | |
| .tag-select { | |
| flex: 1; | |
| padding: 5px 8px; | |
| border: 1px solid #ccc; | |
| border-radius: 4px; | |
| font-size: 10pt; | |
| background: #fff; | |
| } | |
| .self-authored-toggle { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 10pt; | |
| color: #333; | |
| } | |
| .self-authored-toggle input { | |
| accent-color: var(--lobster-red); | |
| } | |
| /* Racing Section Styles - Lobsters Light Theme */ | |
| .racing-container { | |
| background: #fff; | |
| border: 1px solid var(--border-light); | |
| border-radius: 4px; | |
| padding: 20px; | |
| color: var(--text-dark); | |
| } | |
| .racing-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .racing-btn { | |
| padding: 8px 20px; | |
| border: none; | |
| border-radius: 4px; | |
| font-size: 0.9em; | |
| font-weight: 500; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: all 0.2s; | |
| } | |
| .racing-btn.play { | |
| background: var(--lobster-red); | |
| color: white; | |
| } | |
| .racing-btn.play:hover { | |
| background: var(--lobster-red-dark); | |
| } | |
| .racing-btn.reset { | |
| background: #f5f5f5; | |
| border: 1px solid #ddd; | |
| color: #666; | |
| } | |
| .racing-btn.reset:hover { | |
| background: #eee; | |
| } | |
| .racing-timeline { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| min-width: 200px; | |
| } | |
| .timeline-slider { | |
| flex: 1; | |
| -webkit-appearance: none; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: linear-gradient(to right, var(--lobster-red) 0%, var(--lobster-red) var(--progress, 0%), #ddd var(--progress, 0%), #ddd 100%); | |
| outline: none; | |
| } | |
| .timeline-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--lobster-red); | |
| cursor: pointer; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.2); | |
| } | |
| .timeline-slider::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--lobster-red); | |
| cursor: pointer; | |
| border: none; | |
| } | |
| .timeline-time { | |
| font-family: 'SF Mono', Monaco, monospace; | |
| font-size: 0.9em; | |
| min-width: 40px; | |
| color: var(--text-dark); | |
| } | |
| .speed-select { | |
| padding: 6px 10px; | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| color: #666; | |
| font-size: 0.85em; | |
| cursor: pointer; | |
| } | |
| .speed-select:hover { | |
| background: #f9f9f9; | |
| } | |
| .lead-changes { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 0.85em; | |
| color: #888; | |
| margin-bottom: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .lead-change-btn { | |
| padding: 4px 12px; | |
| background: #f9f9f9; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| color: #666; | |
| font-size: 0.85em; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .lead-change-btn:hover { | |
| background: #eee; | |
| color: #333; | |
| } | |
| .lead-change-btn.active { | |
| background: var(--lobster-red); | |
| border-color: var(--lobster-red); | |
| color: white; | |
| } | |
| /* Chart */ | |
| .racing-chart { | |
| position: relative; | |
| height: 280px; | |
| margin-bottom: 20px; | |
| background: #fafafa; | |
| border: 1px solid #eee; | |
| border-radius: 4px; | |
| padding: 16px; | |
| } | |
| .chart-title { | |
| text-align: center; | |
| font-size: 0.9em; | |
| color: #666; | |
| margin-bottom: 12px; | |
| } | |
| .chart-canvas { | |
| width: 100%; | |
| height: 220px; | |
| } | |
| /* Story Cards */ | |
| .racing-cards { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 12px; | |
| } | |
| .racing-card { | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| padding: 14px; | |
| transition: all 0.3s; | |
| } | |
| .racing-card.leading { | |
| border-color: var(--lobster-red); | |
| border-width: 2px; | |
| background: #fff8f8; | |
| } | |
| .racing-card.viral { --card-color: #c44; } | |
| .racing-card.sleeper { --card-color: #2a9d8f; } | |
| .racing-card.steady { --card-color: #6a8a3a; } | |
| .racing-card-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 6px; | |
| } | |
| .racing-card-rank { | |
| font-weight: 600; | |
| color: #888; | |
| } | |
| .racing-card-badge { | |
| font-size: 0.7em; | |
| padding: 2px 6px; | |
| background: var(--lobster-red); | |
| color: white; | |
| border-radius: 3px; | |
| display: none; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .racing-card.leading .racing-card-badge { | |
| display: inline-block; | |
| } | |
| .racing-card-title { | |
| font-weight: 600; | |
| font-size: 0.95em; | |
| margin-bottom: 2px; | |
| color: var(--card-color); | |
| } | |
| .racing-card-subtitle { | |
| font-size: 0.8em; | |
| color: #888; | |
| margin-bottom: 10px; | |
| } | |
| .racing-card-stats { | |
| font-size: 0.85em; | |
| color: #666; | |
| } | |
| .racing-card-stat { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 3px; | |
| } | |
| .racing-card-stat-value { | |
| font-family: 'SF Mono', Monaco, monospace; | |
| color: var(--card-color); | |
| } | |
| .voting-patterns { | |
| margin-top: 16px; | |
| padding-top: 14px; | |
| border-top: 1px solid #eee; | |
| } | |
| .voting-patterns-title { | |
| font-size: 0.85em; | |
| font-weight: 600; | |
| color: #666; | |
| margin-bottom: 10px; | |
| } | |
| .voting-patterns-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 10px; | |
| font-size: 0.8em; | |
| } | |
| .pattern-item { | |
| line-height: 1.4; | |
| } | |
| .pattern-name { | |
| font-weight: 600; | |
| } | |
| .pattern-name.viral { color: #c44; } | |
| .pattern-name.sleeper { color: #2a9d8f; } | |
| .pattern-name.steady { color: #6a8a3a; } | |
| .pattern-desc { | |
| color: #888; | |
| } | |
| /* Mobile responsiveness */ | |
| @media (max-width: 600px) { | |
| .lobsters-explorer { | |
| padding: 12px; | |
| } | |
| .controls-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .story-hotness { | |
| position: static; | |
| transform: none; | |
| margin-top: 8px; | |
| display: inline-block; | |
| } | |
| .story-item { | |
| flex-wrap: wrap; | |
| } | |
| .racing-controls { | |
| flex-direction: column; | |
| align-items: stretch; | |
| } | |
| .racing-btn { | |
| justify-content: center; | |
| } | |
| .racing-cards { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="lobsters-explorer"> | |
| <!-- Section 1: Interactive Story Ranker --> | |
| <div class="explorer-section"> | |
| <h2 class="explorer-title">Interactive Story Ranker</h2> | |
| <p class="click-hint">Click a story to adjust its parameters. Hover over hotness to see the breakdown.</p> | |
| <div class="controls-panel" id="controlsPanel"> | |
| <div class="controls-panel-header"> | |
| <span class="controls-panel-title" id="controlsPanelTitle">Edit Story</span> | |
| <button class="controls-panel-close" id="controlsPanelClose">✕ Close</button> | |
| </div> | |
| <div id="storyControls"></div> | |
| </div> | |
| <div class="story-list" id="storyList"></div> | |
| </div> | |
| <!-- Section 2: Racing Stories --> | |
| <div class="explorer-section"> | |
| <h2 class="explorer-title">Racing Stories</h2> | |
| <p class="explorer-subtitle">Three stories posted simultaneously with different voting patterns. Watch the sleeper hit overtake the viral post as time passes. Click the hour markers to jump to lead changes.</p> | |
| <div class="racing-container"> | |
| <div class="racing-controls"> | |
| <button class="racing-btn play" id="playBtn"> | |
| <span id="playIcon">▶</span> <span id="playText">Play</span> | |
| </button> | |
| <button class="racing-btn reset" id="resetBtn"> | |
| ↺ Reset | |
| </button> | |
| <div class="speed-control"> | |
| <select id="speedSelect" class="speed-select"> | |
| <option value="0.04">0.5×</option> | |
| <option value="0.08" selected>1×</option> | |
| <option value="0.16">2×</option> | |
| <option value="0.32">4×</option> | |
| </select> | |
| </div> | |
| <div class="racing-timeline"> | |
| <input type="range" class="timeline-slider" id="timelineSlider" min="0" max="48" step="0.1" value="0"> | |
| <span class="timeline-time" id="timeDisplay">0h</span> | |
| </div> | |
| </div> | |
| <div class="lead-changes"> | |
| <span>Lead changes at:</span> | |
| <div id="leadChangeButtons"></div> | |
| </div> | |
| <div class="racing-chart"> | |
| <div class="chart-title">↓ Lower = Better Rank</div> | |
| <canvas class="chart-canvas" id="racingChart"></canvas> | |
| </div> | |
| <div class="racing-cards" id="racingCards"></div> | |
| <div class="voting-patterns"> | |
| <div class="voting-patterns-title">Voting patterns modeled:</div> | |
| <div class="voting-patterns-grid"> | |
| <div class="pattern-item"> | |
| <span class="pattern-name viral">Viral:</span> | |
| <span class="pattern-desc">exponential approach to ceiling — fast initial votes, then diminishing</span> | |
| </div> | |
| <div class="pattern-item"> | |
| <span class="pattern-name steady">Steady:</span> | |
| <span class="pattern-desc">linear growth — consistent trickle of upvotes over time</span> | |
| </div> | |
| <div class="pattern-item"> | |
| <span class="pattern-name sleeper">Sleeper:</span> | |
| <span class="pattern-desc">logistic curve — slow discovery, then rapid spread, then plateau</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ===================== | |
| // Hotness Calculation (matching lobsters/app/models/story.rb) | |
| // ===================== | |
| const HOTNESS_WINDOW = 60 * 60 * 22; // 22 hours in seconds (79,200) | |
| // Reference timestamp - all stories compared at the same "current time" | |
| const REFERENCE_TIMESTAMP = Date.now() / 1000; | |
| function calculateHotness(storyScore, commentVoteSum, ageHours, tagMod, selfAuthored) { | |
| // base = tags.sum(:hotness_mod) + ((user_is_author? && url.present?) ? 0.25 : 0.0) | |
| const base = tagMod + (selfAuthored ? 0.25 : 0); | |
| // cpoints: sum of (comment.score + 1) * 0.5 for non-submitter comments | |
| // Only counted if base >= 0 (negative-tagged stories get no comment boost) | |
| let cpoints = 0; | |
| if (base >= 0) { | |
| cpoints = commentVoteSum * 0.5; | |
| } | |
| // Cap comment points at story score (prevents low-score controversial posts from rising) | |
| cpoints = Math.min(cpoints, storyScore); | |
| // order = Math.log([(score + 1).abs + cpoints, 1].max, 10) | |
| const order = Math.log10(Math.max(Math.abs(storyScore + 1) + cpoints, 1)); | |
| // sign based on story score | |
| const sign = storyScore > 0 ? 1 : (storyScore < 0 ? -1 : 0); | |
| // created_at timestamp (older stories have smaller timestamps) | |
| const createdAt = REFERENCE_TIMESTAMP - (ageHours * 3600); | |
| // age term = created_at / HOTNESS_WINDOW (this is a large number ~21,930) | |
| const ageTerm = createdAt / HOTNESS_WINDOW; | |
| // hotness = -((order * sign) + base + (created_at / HOTNESS_WINDOW)) | |
| const hotness = -((order * sign) + base + ageTerm); | |
| return { hotness, base, order, sign, ageHours, cpoints, createdAt, ageTerm }; | |
| } | |
| // ===================== | |
| // Section 1: Interactive Story Ranker | |
| // ===================== | |
| const tagOptions = [ | |
| { name: 'programming', mod: 0 }, | |
| { name: 'web', mod: 0 }, | |
| { name: 'rust', mod: 0 }, | |
| { name: 'javascript', mod: 0 }, | |
| { name: 'security', mod: 0 }, | |
| { name: 'linux', mod: 0 }, | |
| { name: 'culture', mod: -0.25 }, | |
| { name: 'rant', mod: -0.25 }, | |
| { name: 'ask', mod: 0 }, | |
| { name: 'show', mod: 0 }, | |
| { name: 'announce', mod: 1.0 }, | |
| { name: 'vibecoding', mod: 0 }, | |
| ]; | |
| // commentVotes represents sum(comment.score + 1) for non-submitter comments | |
| const initialStories = [ | |
| { id: 1, title: 'Why Forth is Superior to Rust for Web Development', tags: ['web'], score: 18, commentVotes: 62, age: 4, selfAuthored: true, user: 'rpn_stan', domain: 'stackwards.io' }, | |
| { id: 2, title: 'Impure Function Detected in the Linux Kernel', tags: ['security', 'linux'], score: 48, commentVotes: 24, age: 6, selfAuthored: false, user: 'pkh', domain: 'sideeffects.exposed' }, | |
| { id: 3, title: 'Show: Plan 9 Distribution for Smart Watches', tags: ['show'], score: 22, commentVotes: 18, age: 6, selfAuthored: true, user: 'glenda', domain: 'git.9front.org' }, | |
| { id: 4, title: 'If you can\'t write it in C, the AI shouldn\'t write it for you', tags: ['vibecoding'], score: 29, commentVotes: 44, age: 8, selfAuthored: false, user: 'old_mass', domain: 'nojit.zone' }, | |
| { id: 5, title: 'Burnout is just your brain Garbage Collecting too often', tags: ['culture'], score: 24, commentVotes: 31, age: 3, selfAuthored: false, user: 'grumpy', domain: 'heapoverflow.blog' }, | |
| { id: 6, title: 'I Trained an LLM on My Slack Logs and Now It\'s Passive Aggressive', tags: ['vibecoding'], score: 24, commentVotes: 28, age: 18, selfAuthored: true, user: 'zyx', domain: 'zyx.lol' }, | |
| { id: 7, title: 'Show: A Tiling Window Manager for Your UEFI BIOS', tags: ['show', 'linux'], score: 73, commentVotes: 19, age: 3, selfAuthored: true, user: 'rwx', domain: 'sr.ht' }, | |
| { id: 8, title: 'My Homelab Firewall Blocked My Own Pacemaker Updates', tags: ['security'], score: 51, commentVotes: 38, age: 12, selfAuthored: false, user: 'netblock', domain: 'uptime.rip' }, | |
| { id: 9, title: 'Why I Refuse to Use Software That Isn\'t Formally Verified', tags: ['programming'], score: 21, commentVotes: 53, age: 7, selfAuthored: true, user: 'qed42', domain: 'proofassistants.xyz' }, | |
| { id: 10, title: 'I Don\'t Hire Developers Who Use Mouse-Driven Interfaces', tags: ['rant', 'culture'], score: 7, commentVotes: 78, age: 2, selfAuthored: false, user: 'exrc', domain: 'thoughtleader.vc' }, | |
| ]; | |
| let stories = JSON.parse(JSON.stringify(initialStories)); | |
| let selectedStoryId = null; | |
| function getTagMod(tagName) { | |
| const tag = tagOptions.find(t => t.name === tagName); | |
| return tag ? tag.mod : 0; | |
| } | |
| function getTotalTagMod(tags) { | |
| return tags.reduce((sum, tagName) => sum + getTagMod(tagName), 0); | |
| } | |
| // Generate identicon-style avatar SVG from username | |
| function getAvatarSvg(username) { | |
| // Hash function | |
| let hash = 0; | |
| for (let i = 0; i < username.length; i++) { | |
| hash = ((hash << 5) - hash) + username.charCodeAt(i); | |
| hash = hash & hash; | |
| } | |
| hash = Math.abs(hash); | |
| // Generate color from hash | |
| const hue = hash % 360; | |
| const sat = 50 + (hash % 30); | |
| const light = 45 + ((hash >> 8) % 20); | |
| const color = `hsl(${hue}, ${sat}%, ${light}%)`; | |
| const bgColor = `hsl(${hue}, ${sat - 20}%, 92%)`; | |
| // Generate 5x5 grid pattern (only need 3 columns due to symmetry) | |
| const grid = []; | |
| let bits = hash; | |
| for (let y = 0; y < 5; y++) { | |
| grid[y] = []; | |
| for (let x = 0; x < 3; x++) { | |
| grid[y][x] = bits & 1; | |
| bits = bits >> 1; | |
| if (bits === 0) bits = hash * (y + 1); | |
| } | |
| // Mirror for symmetry | |
| grid[y][3] = grid[y][1]; | |
| grid[y][4] = grid[y][0]; | |
| } | |
| // Build SVG | |
| let rects = ''; | |
| const cellSize = 3; | |
| for (let y = 0; y < 5; y++) { | |
| for (let x = 0; x < 5; x++) { | |
| if (grid[y][x]) { | |
| rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${color}"/>`; | |
| } | |
| } | |
| } | |
| const size = 5 * cellSize; | |
| const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="16" height="16"> | |
| <defs><clipPath id="c${hash % 10000}"><circle cx="${size/2}" cy="${size/2}" r="${size/2}"/></clipPath></defs> | |
| <g clip-path="url(#c${hash % 10000})"> | |
| <rect width="${size}" height="${size}" fill="${bgColor}"/> | |
| ${rects} | |
| </g> | |
| </svg>`; | |
| return `data:image/svg+xml,${encodeURIComponent(svg)}`; | |
| } | |
| function selectStory(storyId) { | |
| selectedStoryId = storyId; | |
| renderStoryControls(); | |
| renderStoryList(); | |
| const panel = document.getElementById('controlsPanel'); | |
| if (storyId) { | |
| panel.classList.add('visible'); | |
| const story = stories.find(s => s.id === storyId); | |
| document.getElementById('controlsPanelTitle').textContent = story.title; | |
| } else { | |
| panel.classList.remove('visible'); | |
| } | |
| } | |
| function renderStoryControls() { | |
| const container = document.getElementById('storyControls'); | |
| if (!selectedStoryId) { | |
| container.innerHTML = ''; | |
| return; | |
| } | |
| const story = stories.find(s => s.id === selectedStoryId); | |
| if (!story) return; | |
| const tagMod = getTotalTagMod(story.tags); | |
| const calc = calculateHotness(story.score, story.commentVotes, story.age, tagMod, story.selfAuthored); | |
| const tag1 = story.tags[0] || ''; | |
| const tag2 = story.tags[1] || ''; | |
| container.innerHTML = ` | |
| <div class="story-control" data-id="${story.id}"> | |
| <div class="control-row"> | |
| <span class="control-label">Score</span> | |
| <input type="range" class="control-slider" data-field="score" min="-5" max="150" value="${story.score}"> | |
| <span class="control-value">${story.score}</span> | |
| </div> | |
| <div class="control-row"> | |
| <span class="control-label">Comment votes</span> | |
| <input type="range" class="control-slider" data-field="commentVotes" min="0" max="200" value="${story.commentVotes}"> | |
| <span class="control-value">${story.commentVotes}</span> | |
| </div> | |
| <div class="control-row"> | |
| <span class="control-label">Age (hours)</span> | |
| <input type="range" class="control-slider" data-field="age" min="0" max="48" step="1" value="${story.age}"> | |
| <span class="control-value">${story.age}h</span> | |
| </div> | |
| <div class="control-row"> | |
| <span class="control-label">Tag 1</span> | |
| <select class="tag-select" data-field="tag1"> | |
| ${tagOptions.map(t => `<option value="${t.name}" ${t.name === tag1 ? 'selected' : ''}>${t.name} (${t.mod >= 0 ? '+' : ''}${t.mod})</option>`).join('')} | |
| </select> | |
| </div> | |
| <div class="control-row"> | |
| <span class="control-label">Tag 2</span> | |
| <select class="tag-select" data-field="tag2"> | |
| <option value="" ${!tag2 ? 'selected' : ''}>none</option> | |
| ${tagOptions.map(t => `<option value="${t.name}" ${t.name === tag2 ? 'selected' : ''}>${t.name} (${t.mod >= 0 ? '+' : ''}${t.mod})</option>`).join('')} | |
| </select> | |
| </div> | |
| <div class="control-row"> | |
| <span class="control-label"></span> | |
| <label class="self-authored-toggle"> | |
| <input type="checkbox" data-field="selfAuthored" ${story.selfAuthored ? 'checked' : ''}> | |
| Self-authored (+0.25) | |
| </label> | |
| </div> | |
| </div> | |
| `; | |
| // Add event listeners | |
| container.querySelectorAll('.control-slider, .tag-select, input[type="checkbox"]').forEach(input => { | |
| input.addEventListener('input', handleControlChange); | |
| input.addEventListener('change', handleControlChange); | |
| }); | |
| } | |
| function handleControlChange(e) { | |
| if (!selectedStoryId) return; | |
| const field = e.target.dataset.field; | |
| const story = stories.find(s => s.id === selectedStoryId); | |
| if (!story) return; | |
| if (field === 'selfAuthored') { | |
| story[field] = e.target.checked; | |
| } else if (field === 'tag1') { | |
| story.tags[0] = e.target.value; | |
| } else if (field === 'tag2') { | |
| if (e.target.value) { | |
| story.tags[1] = e.target.value; | |
| } else { | |
| story.tags = [story.tags[0]]; // Remove second tag | |
| } | |
| } else { | |
| story[field] = parseFloat(e.target.value); | |
| } | |
| // Update displayed value | |
| if (e.target.classList.contains('control-slider')) { | |
| const valueSpan = e.target.nextElementSibling; | |
| valueSpan.textContent = field === 'age' ? `${story[field]}h` : story[field]; | |
| } | |
| renderStoryList(); | |
| } | |
| function renderStoryList() { | |
| const container = document.getElementById('storyList'); | |
| // Calculate hotness and sort | |
| const sortedStories = stories.map(story => { | |
| const tagMod = getTotalTagMod(story.tags); | |
| const calc = calculateHotness(story.score, story.commentVotes, story.age, tagMod, story.selfAuthored); | |
| return { ...story, ...calc, tagMod, commentVotes: story.commentVotes }; | |
| }).sort((a, b) => a.hotness - b.hotness); | |
| container.innerHTML = sortedStories.map((story, idx) => { | |
| const selectedClass = story.id === selectedStoryId ? 'selected' : ''; | |
| const specialTags = ['show', 'ask', 'announce']; | |
| const tagsHtml = story.tags.map(tagName => { | |
| const mod = getTagMod(tagName); | |
| let tagClass = ''; | |
| if (mod < 0) tagClass = 'negative'; | |
| else if (specialTags.includes(tagName)) tagClass = 'special'; | |
| return `<span class="story-tag ${tagClass}">${tagName}</span>`; | |
| }).join(''); | |
| const commentCount = Math.round(story.commentVotes / 2); | |
| const domainHtml = story.domain ? `<span class="story-domain">(${story.domain})</span>` : ''; | |
| return ` | |
| <div class="story-item ${selectedClass}" data-id="${story.id}"> | |
| <div class="story-vote"> | |
| <span class="vote-arrow"></span> | |
| <span class="story-score">${story.score}</span> | |
| </div> | |
| <div class="story-content"> | |
| <div class="story-title-row"> | |
| <span class="story-title">${story.title}</span> | |
| ${tagsHtml} | |
| ${domainHtml} | |
| </div> | |
| <div class="story-meta"> | |
| <img class="story-avatar" src="${getAvatarSvg(story.user)}" alt="">${story.selfAuthored ? ' authored by ' : ' via '}<span class="story-meta-link ${story.selfAuthored ? 'author' : 'user'}">${story.user}</span> ${story.ageHours}h ago | <span class="story-meta-link">${commentCount} comment${commentCount !== 1 ? 's' : ''}</span> | |
| </div> | |
| </div> | |
| <div class="story-hotness"> | |
| ${story.hotness.toFixed(2)} | |
| <div class="tooltip"> | |
| <div class="tooltip-row"> | |
| <span class="tooltip-label">base</span> | |
| <span class="tooltip-value">${story.base.toFixed(2)}</span> | |
| </div> | |
| <div class="tooltip-row"> | |
| <span class="tooltip-label">order × sign</span> | |
| <span class="tooltip-value">${(story.order * story.sign).toFixed(3)}</span> | |
| </div> | |
| <div class="tooltip-row"> | |
| <span class="tooltip-label">cpoints</span> | |
| <span class="tooltip-value">${story.cpoints.toFixed(1)}${story.cpoints < story.commentVotes * 0.5 && story.base >= 0 ? ' (capped)' : story.base < 0 ? ' (ignored)' : ''}</span> | |
| </div> | |
| <div class="tooltip-row"> | |
| <span class="tooltip-label">age term</span> | |
| <span class="tooltip-value">${story.ageTerm.toFixed(2)}</span> | |
| </div> | |
| <div class="tooltip-row"> | |
| <span class="tooltip-label">hotness</span> | |
| <span class="tooltip-value">${story.hotness.toFixed(2)}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| // Add click handlers | |
| container.querySelectorAll('.story-item').forEach(item => { | |
| item.addEventListener('click', () => { | |
| const id = parseInt(item.dataset.id); | |
| selectStory(id === selectedStoryId ? null : id); | |
| }); | |
| }); | |
| } | |
| // ===================== | |
| // Section 2: Racing Stories | |
| // ===================== | |
| const racingStories = [ | |
| { | |
| id: 'viral', | |
| title: 'Viral hit', | |
| subtitle: 'Fast spike, early plateau', | |
| color: '#c44', | |
| // Exponential approach: fast initial, then plateaus | |
| getScore: (t) => Math.round(150 * (1 - Math.exp(-t / 8))) | |
| }, | |
| { | |
| id: 'sleeper', | |
| title: 'Sleeper hit', | |
| subtitle: 'Slow burn, late surge', | |
| color: '#2a9d8f', | |
| // Logistic curve: slow start, rapid middle, plateau end | |
| getScore: (t) => Math.round(180 / (1 + Math.exp(-0.25 * (t - 20)))) | |
| }, | |
| { | |
| id: 'steady', | |
| title: 'Steady performer', | |
| subtitle: 'Consistent upvotes', | |
| color: '#6a8a3a', | |
| // Linear growth | |
| getScore: (t) => Math.round(2.5 * t) | |
| } | |
| ]; | |
| let currentTime = 0; | |
| let isPlaying = false; | |
| let animationId = null; | |
| let leadChanges = []; | |
| // For racing visualization, use RELATIVE hotness since all stories | |
| // start at the same time. This removes the large shared timestamp offset | |
| // and shows only the meaningful differences (score vs age decay). | |
| // Formula: -(order * sign + base) + ageHours/22 | |
| // This is equivalent to comparing stories posted at the same moment. | |
| function calculateRacingHotness(score, ageHours) { | |
| const base = 0; | |
| const order = Math.log10(Math.max(Math.abs(score + 1), 1)); | |
| const sign = score > 0 ? 1 : (score < 0 ? -1 : 0); | |
| // Relative hotness: more negative = better rank | |
| // As age increases, hotness increases (gets worse) | |
| // As score increases, order increases, making hotness more negative (better) | |
| return -((order * sign) + base) + (ageHours / 22); | |
| } | |
| function findLeadChanges() { | |
| leadChanges = [0]; | |
| let lastLeader = null; | |
| for (let t = 0; t <= 48; t += 0.5) { | |
| const hotnesses = racingStories.map(s => ({ | |
| id: s.id, | |
| hotness: calculateRacingHotness(s.getScore(t), t) | |
| })); | |
| hotnesses.sort((a, b) => a.hotness - b.hotness); | |
| const leader = hotnesses[0].id; | |
| if (leader !== lastLeader && lastLeader !== null) { | |
| leadChanges.push(t); | |
| } | |
| lastLeader = leader; | |
| } | |
| return [...new Set(leadChanges.map(t => Math.round(t)))].filter(t => t > 0); | |
| } | |
| function renderLeadChangeButtons() { | |
| const changes = findLeadChanges(); | |
| const container = document.getElementById('leadChangeButtons'); | |
| // Deduplicate and sort | |
| const allTimes = [...new Set([1, ...changes])].sort((a, b) => a - b); | |
| container.innerHTML = allTimes.map(t => | |
| `<button class="lead-change-btn" data-time="${t}">${t}h</button>` | |
| ).join(''); | |
| container.querySelectorAll('.lead-change-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| currentTime = parseFloat(btn.dataset.time); | |
| updateRacing(); | |
| updateLeadChangeHighlight(); | |
| }); | |
| }); | |
| } | |
| function updateLeadChangeHighlight() { | |
| document.querySelectorAll('.lead-change-btn').forEach(btn => { | |
| const t = parseFloat(btn.dataset.time); | |
| btn.classList.toggle('active', Math.abs(t - currentTime) < 1); | |
| }); | |
| } | |
| function renderRacingCards() { | |
| const container = document.getElementById('racingCards'); | |
| container.innerHTML = racingStories.map(story => ` | |
| <div class="racing-card ${story.id}" data-id="${story.id}"> | |
| <div class="racing-card-header"> | |
| <span class="racing-card-rank">#-</span> | |
| <span class="racing-card-badge">👑 LEADING</span> | |
| </div> | |
| <div class="racing-card-title">${story.title}</div> | |
| <div class="racing-card-subtitle">${story.subtitle}</div> | |
| <div class="racing-card-stats"> | |
| <div class="racing-card-stat"> | |
| <span>Score:</span> | |
| <span class="racing-card-stat-value score">0</span> | |
| </div> | |
| <div class="racing-card-stat"> | |
| <span>Age:</span> | |
| <span class="racing-card-stat-value age">0h</span> | |
| </div> | |
| <div class="racing-card-stat"> | |
| <span>Relative hotness:</span> | |
| <span class="racing-card-stat-value hotness">0.000</span> | |
| </div> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function drawChart() { | |
| const canvas = document.getElementById('racingChart'); | |
| const ctx = canvas.getContext('2d'); | |
| const dpr = window.devicePixelRatio || 1; | |
| // Set canvas size | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| ctx.scale(dpr, dpr); | |
| const width = rect.width; | |
| const height = rect.height; | |
| const padding = { top: 10, right: 20, bottom: 30, left: 50 }; | |
| const chartWidth = width - padding.left - padding.right; | |
| const chartHeight = height - padding.top - padding.bottom; | |
| // Calculate y range | |
| let minH = 0, maxH = -2; | |
| for (let t = 0; t <= 48; t += 0.5) { | |
| racingStories.forEach(s => { | |
| const h = calculateRacingHotness(s.getScore(t), t); | |
| minH = Math.min(minH, h); | |
| maxH = Math.max(maxH, h); | |
| }); | |
| } | |
| // Add padding to range | |
| const range = maxH - minH; | |
| minH -= range * 0.1; | |
| maxH += range * 0.1; | |
| // Clear | |
| ctx.fillStyle = '#fafafa'; | |
| ctx.fillRect(0, 0, width, height); | |
| // Draw grid | |
| ctx.strokeStyle = '#ddd'; | |
| ctx.lineWidth = 1; | |
| // Horizontal grid lines | |
| const ySteps = 5; | |
| for (let i = 0; i <= ySteps; i++) { | |
| const y = padding.top + (i / ySteps) * chartHeight; | |
| ctx.beginPath(); | |
| ctx.setLineDash([4, 4]); | |
| ctx.moveTo(padding.left, y); | |
| ctx.lineTo(width - padding.right, y); | |
| ctx.stroke(); | |
| // Y axis labels | |
| const value = maxH - (i / ySteps) * (maxH - minH); | |
| ctx.fillStyle = '#888'; | |
| ctx.font = '11px -apple-system, BlinkMacSystemFont, sans-serif'; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText(value.toFixed(1), padding.left - 8, y + 4); | |
| } | |
| // X axis labels | |
| ctx.setLineDash([]); | |
| ctx.textAlign = 'center'; | |
| [0, 12, 24, 36, 48].forEach(t => { | |
| const x = padding.left + (t / 48) * chartWidth; | |
| ctx.fillText(`${t}h`, x, height - 8); | |
| // Vertical grid line | |
| if (t > 0 && t < 48) { | |
| ctx.beginPath(); | |
| ctx.setLineDash([4, 4]); | |
| ctx.moveTo(x, padding.top); | |
| ctx.lineTo(x, height - padding.bottom); | |
| ctx.stroke(); | |
| } | |
| }); | |
| // Current time line | |
| if (currentTime > 0) { | |
| const x = padding.left + (currentTime / 48) * chartWidth; | |
| ctx.beginPath(); | |
| ctx.setLineDash([4, 4]); | |
| ctx.strokeStyle = '#bbb'; | |
| ctx.moveTo(x, padding.top); | |
| ctx.lineTo(x, height - padding.bottom); | |
| ctx.stroke(); | |
| } | |
| // Draw lines | |
| ctx.setLineDash([]); | |
| ctx.lineWidth = 2.5; | |
| racingStories.forEach(story => { | |
| ctx.beginPath(); | |
| ctx.strokeStyle = story.color; | |
| for (let t = 0; t <= 48; t += 0.5) { | |
| const h = calculateRacingHotness(story.getScore(t), t); | |
| const x = padding.left + (t / 48) * chartWidth; | |
| const y = padding.top + ((maxH - h) / (maxH - minH)) * chartHeight; | |
| if (t === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.stroke(); | |
| // Draw current position dot | |
| const currentH = calculateRacingHotness(story.getScore(currentTime), currentTime); | |
| const dotX = padding.left + (currentTime / 48) * chartWidth; | |
| const dotY = padding.top + ((maxH - currentH) / (maxH - minH)) * chartHeight; | |
| ctx.beginPath(); | |
| ctx.fillStyle = story.color; | |
| ctx.arc(dotX, dotY, 7, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.fillStyle = '#fafafa'; | |
| ctx.arc(dotX, dotY, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| } | |
| function updateRacing() { | |
| // Update slider | |
| const slider = document.getElementById('timelineSlider'); | |
| slider.value = currentTime; | |
| slider.style.setProperty('--progress', `${(currentTime / 48) * 100}%`); | |
| // Update time display | |
| document.getElementById('timeDisplay').textContent = `${Math.round(currentTime)}h`; | |
| // Calculate current values | |
| const currentValues = racingStories.map(story => ({ | |
| ...story, | |
| score: story.getScore(currentTime), | |
| hotness: calculateRacingHotness(story.getScore(currentTime), currentTime) | |
| })); | |
| // Sort by hotness (ascending = better rank) | |
| currentValues.sort((a, b) => a.hotness - b.hotness); | |
| // Update cards | |
| currentValues.forEach((story, idx) => { | |
| const card = document.querySelector(`.racing-card[data-id="${story.id}"]`); | |
| card.querySelector('.racing-card-rank').textContent = `#${idx + 1}`; | |
| card.querySelector('.score').textContent = story.score; | |
| card.querySelector('.age').textContent = `${Math.round(currentTime)}h`; | |
| card.querySelector('.hotness').textContent = story.hotness.toFixed(3); | |
| card.classList.toggle('leading', idx === 0); | |
| }); | |
| // Redraw chart | |
| drawChart(); | |
| updateLeadChangeHighlight(); | |
| } | |
| function togglePlay() { | |
| isPlaying = !isPlaying; | |
| const playIcon = document.getElementById('playIcon'); | |
| const playText = document.getElementById('playText'); | |
| if (isPlaying) { | |
| playIcon.textContent = '⏸'; | |
| playText.textContent = 'Pause'; | |
| lastFrameTime = 0; | |
| animationId = requestAnimationFrame(animate); | |
| } else { | |
| playIcon.textContent = '▶'; | |
| playText.textContent = 'Play'; | |
| cancelAnimationFrame(animationId); | |
| } | |
| } | |
| let lastFrameTime = 0; | |
| function getAnimationSpeed() { | |
| return parseFloat(document.getElementById('speedSelect').value); | |
| } | |
| function animate(timestamp) { | |
| if (!isPlaying) return; | |
| if (!lastFrameTime) lastFrameTime = timestamp; | |
| const delta = timestamp - lastFrameTime; | |
| // Throttle to ~30fps for smoother feel | |
| if (delta > 33) { | |
| lastFrameTime = timestamp; | |
| currentTime += getAnimationSpeed(); | |
| if (currentTime >= 48) { | |
| currentTime = 48; | |
| isPlaying = false; | |
| document.getElementById('playIcon').textContent = '▶'; | |
| document.getElementById('playText').textContent = 'Play'; | |
| } | |
| updateRacing(); | |
| } | |
| if (isPlaying) { | |
| animationId = requestAnimationFrame(animate); | |
| } | |
| } | |
| function reset() { | |
| isPlaying = false; | |
| currentTime = 0; | |
| lastFrameTime = 0; | |
| document.getElementById('playIcon').textContent = '▶'; | |
| document.getElementById('playText').textContent = 'Play'; | |
| cancelAnimationFrame(animationId); | |
| updateRacing(); | |
| } | |
| // ===================== | |
| // Initialize | |
| // ===================== | |
| function init() { | |
| // Section 1 | |
| renderStoryList(); | |
| // Close button handler | |
| document.getElementById('controlsPanelClose').addEventListener('click', () => { | |
| selectStory(null); | |
| }); | |
| // Section 2 | |
| renderLeadChangeButtons(); | |
| renderRacingCards(); | |
| updateRacing(); | |
| // Event listeners | |
| document.getElementById('playBtn').addEventListener('click', togglePlay); | |
| document.getElementById('resetBtn').addEventListener('click', reset); | |
| document.getElementById('timelineSlider').addEventListener('input', (e) => { | |
| currentTime = parseFloat(e.target.value); | |
| updateRacing(); | |
| }); | |
| // Resize handler for chart | |
| window.addEventListener('resize', drawChart); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment