Skip to content

Instantly share code, notes, and snippets.

@tfidfwastaken
Last active January 18, 2026 17:29
Show Gist options
  • Select an option

  • Save tfidfwastaken/2a49b5d2fcb2960ebf3c2e04dd680a3a to your computer and use it in GitHub Desktop.

Select an option

Save tfidfwastaken/2a49b5d2fcb2960ebf3c2e04dd680a3a to your computer and use it in GitHub Desktop.
Lobsters Algorithm Explorer - Interactive visualization
<!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