OpenModelsTimeline / static /index.html
RohitPoreddy's picture
Update static/index.html
f47369c verified
Raw History Blame Contribute Delete
27.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI Model & API Release Timeline</title>
<style>
:root{
--bg: #f5f5f7;
--panel: rgba(255,255,255,.72);
--panel-solid: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--border: rgba(0,0,0,.08);
--shadow: 0 8px 30px rgba(0,0,0,.08);
--accent: #0071e3; /* Apple blue */
--good: #34c759; /* Apple green */
--bad: #ff3b30; /* Apple red */
--warn: #ffcc00; /* Apple yellow */
--radius-xl: 22px;
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
}
*{ box-sizing: border-box; }
html, body { height: 100%; }
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
color: var(--text);
background: radial-gradient(1200px 800px at 20% 0%, rgba(0,113,227,.15), transparent 55%),
radial-gradient(900px 700px at 90% 20%, rgba(52,199,89,.10), transparent 55%),
var(--bg);
}
a{ color: var(--accent); text-decoration: none; }
a:hover{ text-decoration: underline; }
/* Top bar */
.topbar{
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
background: rgba(245,245,247,.75);
border-bottom: 1px solid var(--border);
}
.topbar-inner{
max-width: 1200px;
margin: 0 auto;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.brand{
display:flex; align-items:center; gap:10px;
min-width: 230px;
}
.logo{
width: 34px; height: 34px; border-radius: 10px;
background: linear-gradient(135deg, rgba(0,113,227,.95), rgba(88,86,214,.92));
box-shadow: 0 12px 24px rgba(0,113,227,.22);
}
.brand h1{
font-size: 14px;
margin: 0;
letter-spacing: .2px;
font-weight: 650;
}
.brand p{
margin: 0;
font-size: 12px;
color: var(--muted);
}
.search{
flex: 1;
display:flex;
align-items:center;
gap: 10px;
padding: 10px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255,255,255,.65);
box-shadow: 0 1px 0 rgba(255,255,255,.6) inset;
}
.search input{
border:none;
outline:none;
background: transparent;
font-size: 14px;
width: 100%;
color: var(--text);
}
.pillrow{
display:flex; gap:8px; align-items:center;
flex-wrap: wrap;
justify-content: flex-end;
}
.pill{
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255,255,255,.55);
color: var(--text);
font-size: 13px;
cursor: pointer;
user-select: none;
transition: transform .12s ease, background .15s ease, border-color .15s ease;
}
.pill:hover{ transform: translateY(-1px); }
.pill.active{
background: rgba(0,113,227,.12);
border-color: rgba(0,113,227,.35);
color: var(--accent);
font-weight: 650;
}
.select{
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255,255,255,.55);
font-size: 13px;
color: var(--text);
outline: none;
cursor: pointer;
}
/* Summary row */
.summary{
max-width: 1200px;
margin: 14px auto 0;
padding: 0 16px;
display: grid;
grid-template-columns: 1.3fr .7fr;
gap: 12px;
}
.card{
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.card-inner{ padding: 14px 16px; }
.kpis{
display:flex; gap: 14px; flex-wrap: wrap; align-items: center;
}
.kpi{
display:flex; gap:10px; align-items:center;
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,.55);
border: 1px solid rgba(0,0,0,.06);
}
.dot{
width: 10px; height: 10px; border-radius: 999px;
}
.kpi .label{ font-size: 12px; color: var(--muted); margin:0; }
.kpi .value{ font-size: 16px; font-weight: 750; margin:0; }
.hint{
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
/* Timeline layout */
.wrap{
max-width: 1200px;
margin: 14px auto 30px;
padding: 0 16px;
}
/* .timeline{
display:flex;
gap: 14px;
overflow-x: auto;
padding: 14px;
scroll-snap-type: x mandatory;
border-radius: var(--radius-xl);
border: 1px solid var(--border);
background: rgba(255,255,255,.35);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: var(--shadow);
} */
.timeline {
display: flex;
gap: 14px;
/* FULL WIDTH FIX */
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
overflow-x: auto;
padding: 20px 28px;
scroll-snap-type: x mandatory;
border-radius: 0; /* full-bleed = no rounded corners */
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.55);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: inset 0 1px 0 rgba(255,255,255,.4);
}
.timeline::-webkit-scrollbar{ height: 10px; }
.timeline::-webkit-scrollbar-thumb{ background: rgba(0,0,0,.18); border-radius: 999px; }
.timeline::-webkit-scrollbar-track{ background: rgba(0,0,0,.06); border-radius: 999px; }
.timeline::-webkit-scrollbar {
height: 6px;
}
.timeline::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.25);
border-radius: 999px;
}
.timeline::-webkit-scrollbar-track {
background: transparent;
}
/* Scroll buttons */
.scroll-controls{ position: relative; }
.scroll-btn{
position: absolute;
top: 50%; transform: translateY(-50%);
width: 44px; height: 44px; border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255,255,255,.9);
box-shadow: 0 6px 18px rgba(0,0,0,.06);
display:flex; align-items:center; justify-content:center;
cursor:pointer; font-size:18px; color:var(--text);
z-index: 40;
}
.scroll-btn.left{ left: 8px; }
.scroll-btn.right{ right: 8px; }
.scroll-btn:active{ transform: translateY(-50%) scale(.98); }
.scroll-btn.fixed{ position: fixed; left: 12px; top: 48%; transform: translateY(-50%); }
.scroll-btn.fixed.right{ left: auto; right: 12px; }
.scroll-btn.fixed{ box-shadow: 0 10px 30px rgba(0,0,0,.12); background: rgba(255,255,255,.95); }
/* translucent vertical bands behind fixed scroll buttons to make them pop */
.scroll-band{
position: fixed;
top: 0;
bottom: 0;
width: 60px;
pointer-events: none;
z-index: 30;
backdrop-filter: blur(6px);
}
.scroll-band.left{
left: 0;
background: linear-gradient(to right, rgba(81, 127, 255, 0.1), rgba(0,0,0,0));
}
.scroll-band.right{
right: 0;
background: linear-gradient(to left, rgba(252, 188, 188, 0.1), rgba(0,0,0,0));
}
.month{
scroll-snap-align: start;
min-width: 320px;
max-width: 320px;
display:flex;
flex-direction: column;
gap: 10px;
}
.month-header{
position: sticky; /* stays visible within the month column while scrolling its content */
top: 74px; /* below topbar */
z-index: 20;
display:flex;
align-items:center;
justify-content: space-between;
padding: 12px 14px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(0,113,227,0.95), rgba(88,86,214,0.92));
border: 1px solid rgba(255,255,255,0.14);
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
color: #fff;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.month-header h2{
margin:0;
font-size: 14px;
letter-spacing: .2px;
font-weight: 750;
}
.month-header .count{
font-size: 12px;
color: rgba(255,255,255,0.88);
}
.events{
display:flex;
flex-direction: column;
gap: 10px;
/* reserve vertical space so sticky month header does not visually overlap the first cards */
padding-top: 64px;
}
.event{
background: rgba(255,255,255,.78);
border: 1px solid rgba(0,0,0,.06);
border-radius: 18px;
padding: 12px 12px 11px;
box-shadow: 0 10px 25px rgba(0,0,0,.06);
transition: transform .12s ease, box-shadow .2s ease;
cursor: pointer;
}
.event:hover{
transform: translateY(-1px);
box-shadow: 0 14px 32px rgba(0,0,0,.09);
}
.event-title{
margin:0;
font-size: 13px;
font-weight: 720;
letter-spacing: .1px;
}
.event-desc{
margin: 6px 0 0;
font-size: 12.5px;
color: var(--muted);
line-height: 1.35;
}
.meta{
margin-top: 10px;
display:flex;
align-items:center;
justify-content: space-between;
gap: 10px;
}
.tag{
font-size: 11px;
padding: 5px 8px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,.08);
background: rgba(255,255,255,.55);
color: var(--text);
display:inline-flex;
align-items:center;
gap:6px;
}
.tag .dot{ width: 7px; height: 7px; }
.tag.open-weights{ border-color: rgba(52,199,89,.25); background: rgba(52,199,89,.09); }
.tag.open-weights .dot{ background: var(--good); }
.tag.api{ border-color: rgba(255,59,48,.22); background: rgba(255,59,48,.08); }
.tag.api .dot{ background: var(--bad); }
.small{
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
/* Modal */
.overlay{
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display:none;
align-items:center;
justify-content:center;
padding: 18px;
z-index: 80;
}
.overlay.open{ display:flex; }
.modal{
width: min(720px, 100%);
border-radius: 26px;
background: rgba(255,255,255,.92);
border: 1px solid rgba(255,255,255,.65);
box-shadow: 0 30px 80px rgba(0,0,0,.35);
overflow: hidden;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.modal-head{
padding: 14px 16px;
display:flex;
justify-content: space-between;
align-items:center;
border-bottom: 1px solid rgba(0,0,0,.06);
}
.modal-head h3{ margin:0; font-size: 15px; font-weight: 760; }
.xbtn{
border: 1px solid rgba(0,0,0,.08);
background: rgba(255,255,255,.7);
border-radius: 999px;
padding: 8px 10px;
cursor: pointer;
font-size: 12px;
}
.modal-body{ padding: 16px; }
.modal-body p{ margin: 8px 0 0; color: var(--muted); line-height: 1.45; }
.modal-grid{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
}
.mini{
border: 1px solid rgba(0,0,0,.06);
background: rgba(245,245,247,.85);
border-radius: 16px;
padding: 10px 12px;
}
.mini .k{ font-size: 11px; color: var(--muted); }
.mini .v{ font-size: 12.5px; font-weight: 650; margin-top: 3px; }
footer{
max-width: 1200px;
margin: 0 auto 30px;
padding: 0 16px;
color: var(--muted);
font-size: 12px;
display:flex;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
@media (max-width: 860px){
.topbar-inner{ flex-wrap: wrap; }
.brand{ min-width: unset; width: 100%; }
.pillrow{ width: 100%; justify-content: flex-start; }
.summary{ grid-template-columns: 1fr; }
.month{ min-width: 290px; max-width: 290px; }
.modal-grid{ grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="topbar">
<div class="topbar-inner">
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<h1>AI Release Timeline</h1>
<p id="subtitle">Open weights + API releases • 2022 → present</p>
</div>
</div>
<div class="search" role="search">
<span style="color:var(--muted);font-size:13px;">⌘K</span>
<input id="searchInput" placeholder="Search: Llama, Mistral, diffusion, Claude…" autocomplete="off" />
</div>
<div class="pillrow">
<span class="pill active" data-filter="all">All</span>
<span class="pill" data-filter="open-weights">Open weights</span>
<span class="pill" data-filter="api">API only</span>
<select id="monthJump" class="select" title="Jump to month"></select>
</div>
</div>
</div>
<div class="summary">
<div class="card">
<div class="card-inner kpis">
<div class="kpi">
<span class="dot" style="background:var(--good)"></span>
<div>
<p class="label">Open weights</p>
<p class="value" id="kpiOpen">—</p>
</div>
</div>
<div class="kpi">
<span class="dot" style="background:var(--bad)"></span>
<div>
<p class="label">API only</p>
<p class="value" id="kpiApi">—</p>
</div>
</div>
<div class="kpi">
<span class="dot" style="background:var(--accent)"></span>
<div>
<p class="label">Total</p>
<p class="value" id="kpiTotal">—</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-inner">
<div class="hint">
<div><b>Tips</b></div>
<div>• Press <b>⌘K</b> (or Ctrl+K) to focus search.</div>
<div>• Use ← / → to move month-by-month.</div>
<div>• Click any card to open details.</div>
<div style="margin-top:8px;">
Data refresh: <span id="asOf">—</span>
</div>
</div>
</div>
</div>
</div>
<div class="wrap">
<div class="scroll-controls">
<div id="timeline" class="timeline" aria-label="Timeline (horizontal scroll)">
<!-- populated by JS -->
</div>
</div>
</div>
<!-- Fixed overlay buttons for easy access -->
<button id="scrollLeftFixed" class="scroll-btn left fixed" aria-label="Scroll left">◀</button>
<button id="scrollRightFixed" class="scroll-btn right fixed" aria-label="Scroll right">▶</button>
<div class="scroll-band left" aria-hidden="true"></div>
<div class="scroll-band right" aria-hidden="true"></div>
<div id="overlay" class="overlay" role="dialog" aria-modal="true" aria-label="Event details">
<div class="modal">
<div class="modal-head">
<h3 id="mTitle">—</h3>
<button class="xbtn" id="closeBtn">Close</button>
</div>
<div class="modal-body">
<div id="mTag"></div>
<p id="mDesc">—</p>
<div style="margin-top:10px;">
<a id="mLink" href="#" target="_blank" rel="noreferrer">Open link →</a>
</div>
<div class="modal-grid">
<div class="mini">
<div class="k">Month</div>
<div class="v" id="mMonth">—</div>
</div>
<div class="mini">
<div class="k">Date</div>
<div class="v" id="mDate">—</div>
</div>
<div class="mini">
<div class="k">Source</div>
<div class="v" id="mSource">—</div>
</div>
<div class="mini">
<div class="k">Org / Author</div>
<div class="v" id="mOrg">—</div>
</div>
</div>
</div>
</div>
</div>
<footer>
<div> &#169 Made for fast scanning & navigation by Rohit Poreddy.</div>
<div><span id="metaNote">—</span></div>
</footer>
<script>
const el = (id) => document.getElementById(id);
const timeline = el("timeline");
let STATE = {
events: [],
filter: "all",
q: "",
months: [],
monthIndexByName: new Map(),
};
function groupByMonth(events){
const groups = new Map();
for (const e of events){
const month = e.month || "Unknown";
if (!groups.has(month)) groups.set(month, []);
groups.get(month).push(e);
}
// Sort months chronologically by first event date we have (date is YYYY-MM-DD)
const monthList = Array.from(groups.entries()).map(([month, evs]) => {
evs.sort((a,b)=> (b.date||"").localeCompare(a.date||""));
const key = evs.length ? (evs[0].date || "") : "";
return { month, key, events: evs };
});
monthList.sort((a,b)=> (a.key||"").localeCompare(b.key||"")); // oldest -> newest
return monthList;
}
function tagChip(category){
const cls = category === "open-weights" ? "open-weights" : "api";
const label = category === "open-weights" ? "Open weights" : "API only";
return `<span class="tag ${cls}"><span class="dot"></span>${label}</span>`;
}
function render(){
timeline.innerHTML = "";
const months = groupByMonth(STATE.events);
STATE.months = months;
STATE.monthIndexByName = new Map(months.map((m,i)=>[m.month,i]));
// Month jump options
const jump = el("monthJump");
jump.innerHTML = "";
for (let i=0;i<months.length;i++){
const opt = document.createElement("option");
opt.value = months[i].month;
opt.textContent = months[i].month;
jump.appendChild(opt);
}
// default to latest month
if (months.length){
jump.value = months[months.length-1].month;
}
// Render month columns
months.forEach((m, idx) => {
const col = document.createElement("section");
col.className = "month";
col.dataset.month = m.month;
const head = document.createElement("div");
head.className = "month-header";
head.innerHTML = `<h2>${m.month}</h2><div class="count">${m.events.length} items</div>`;
col.appendChild(head);
const list = document.createElement("div");
list.className = "events";
for (const e of m.events){
const card = document.createElement("article");
card.className = "event";
card.tabIndex = 0;
card.innerHTML = `
<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start;">
<div>
<p class="event-title">${escapeHtml(e.title || "Untitled")}</p>
<p class="event-desc">${escapeHtml(e.description || "")}</p>
</div>
</div>
<div class="meta">
${tagChip(e.category)}
<span class="small">${escapeHtml(e.date || "")}</span>
</div>
`;
card.addEventListener("click", () => openModal(e));
card.addEventListener("keydown", (ev) => { if (ev.key === "Enter") openModal(e); });
list.appendChild(card);
}
col.appendChild(list);
timeline.appendChild(col);
});
// KPIs
const openCount = STATE.events.filter(e => e.category === "open-weights").length;
const apiCount = STATE.events.filter(e => e.category === "api").length;
el("kpiOpen").textContent = openCount;
el("kpiApi").textContent = apiCount;
el("kpiTotal").textContent = STATE.events.length;
// Meta footer
el("metaNote").textContent = `${STATE.events.length} events loaded • filter: ${STATE.filter}${STATE.q ? " • search: "+STATE.q : ""}`;
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, (m)=>({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;" }[m]));
}
async function load(){
const params = new URLSearchParams();
if (STATE.filter !== "all") params.set("category", STATE.filter);
if (STATE.q) params.set("q", STATE.q);
const res = await fetch(`/api/events?${params.toString()}`, { cache: "no-store" });
const data = await res.json();
el("asOf").textContent = new Date(data.as_of).toLocaleString();
el("subtitle").textContent = `Open weights + API releases • 2022 → present • ${data.count} showing`;
STATE.events = data.events || [];
// update month jump once we render
render();
// snap to latest month
requestAnimationFrame(() => scrollToMonth(el("monthJump").value, "instant"));
}
function scrollToMonth(month, behavior="smooth"){
const col = timeline.querySelector(`.month[data-month="${cssEscape(month)}"]`);
if (!col) return;
// center the month column in the timeline viewport
const containerWidth = timeline.clientWidth;
const colWidth = col.offsetWidth;
const maxScroll = Math.max(0, timeline.scrollWidth - containerWidth);
// Compute desired scrollLeft so the column is centered
const target = col.offsetLeft - Math.round((containerWidth - colWidth) / 2);
const bounded = Math.min(maxScroll, Math.max(0, target));
if (behavior === 'instant'){
timeline.scrollLeft = bounded;
return;
}
// perform smooth scroll to center the column
timeline.scrollTo({ left: bounded, behavior });
}
function cssEscape(s){
// minimal escape for attribute selector
return String(s).replace(/"/g, '\\"');
}
// Filters
document.querySelectorAll(".pill").forEach(p => {
p.addEventListener("click", () => {
document.querySelectorAll(".pill").forEach(x=>x.classList.remove("active"));
p.classList.add("active");
STATE.filter = p.dataset.filter;
load();
});
});
// Month jump
el("monthJump").addEventListener("change", (e) => scrollToMonth(e.target.value));
// Search (debounced)
let t = null;
el("searchInput").addEventListener("input", (e) => {
clearTimeout(t);
t = setTimeout(() => { STATE.q = e.target.value.trim(); load(); }, 220);
});
// Keyboard shortcuts
window.addEventListener("keydown", (e) => {
const isMac = navigator.platform.toLowerCase().includes("mac");
const cmdk = (isMac && e.metaKey && e.key.toLowerCase()==="k") || (!isMac && e.ctrlKey && e.key.toLowerCase()==="k");
if (cmdk){
e.preventDefault();
el("searchInput").focus();
el("searchInput").select();
return;
}
if (e.key === "Escape") closeModal();
if (e.key === "ArrowRight" || e.key === "ArrowLeft"){
const months = STATE.months || [];
if (!months.length) return;
// Find closest visible month column (based on scrollLeft)
const cols = Array.from(timeline.querySelectorAll(".month"));
const left = timeline.scrollLeft;
let bestIdx = 0;
let bestDist = Infinity;
cols.forEach((c, idx) => {
const dist = Math.abs(c.offsetLeft - left);
if (dist < bestDist){ bestDist = dist; bestIdx = idx; }
});
const nextIdx = e.key === "ArrowRight" ? Math.min(cols.length-1, bestIdx+1) : Math.max(0, bestIdx-1);
cols[nextIdx].scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
}
});
// Modal
const overlay = el("overlay");
const closeBtn = el("closeBtn");
closeBtn.addEventListener("click", closeModal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
function openModal(e){
el("mTitle").textContent = e.title || "Untitled";
el("mDesc").textContent = e.description || "";
el("mMonth").textContent = e.month || "—";
el("mDate").textContent = e.date || "—";
el("mSource").textContent = e.source || "—";
el("mOrg").textContent = e.org || "—";
el("mTag").innerHTML = tagChip(e.category);
const link = el("mLink");
if (e.link){
link.style.display = "inline";
link.href = e.link;
} else {
link.style.display = "none";
}
overlay.classList.add("open");
}
function closeModal(){
overlay.classList.remove("open");
}
// Replace cursor auto-scroll with left/right arrow buttons (4-month jump)
(function(){
const leftBtn = el('scrollLeft');
const rightBtn = el('scrollRight');
const leftFixed = el('scrollLeftFixed');
const rightFixed = el('scrollRightFixed');
function scrollMonths(delta){
const cols = Array.from(timeline.querySelectorAll('.month'));
if (!cols.length) return;
const left = timeline.scrollLeft;
let bestIdx = 0;
let bestDist = Infinity;
cols.forEach((c, idx) => {
const dist = Math.abs(c.offsetLeft - left);
if (dist < bestDist){ bestDist = dist; bestIdx = idx; }
});
const targetIdx = Math.min(cols.length - 1, Math.max(0, bestIdx + delta));
cols[targetIdx].scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
}
[leftBtn, rightBtn, leftFixed, rightFixed].forEach(btn => {
if (!btn) return;
btn.addEventListener('click', (e) => {
if (btn === leftBtn || btn === leftFixed) scrollMonths(-4);
else scrollMonths(4);
});
});
// Horizontal scrolling with mouse wheel when over timeline
// Allow normal vertical page scroll unless horizontal intent is clear or Shift is held.
timeline.addEventListener('wheel', (ev) => {
const absX = Math.abs(ev.deltaX);
const absY = Math.abs(ev.deltaY);
const wantHorizontal = ev.shiftKey || absX > absY;
if (wantHorizontal){
// use deltaX when available, otherwise fall back to deltaY
const delta = ev.deltaX || ev.deltaY;
timeline.scrollLeft += delta;
ev.preventDefault();
}
// otherwise, let the wheel event scroll the page vertically
}, { passive: false });
// Click-and-drag to pan timeline
(function(){
let isDown = false;
let startX = 0;
let startScroll = 0;
timeline.addEventListener('mousedown', (e) => {
isDown = true; startX = e.clientX; startScroll = timeline.scrollLeft;
timeline.classList.add('dragging');
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
timeline.scrollLeft = startScroll - dx;
});
window.addEventListener('mouseup', () => { isDown = false; timeline.classList.remove('dragging'); });
})();
})();
// Initial load
load();
</script>
</body>
</html>