// Open-source comments — table, column popover, project record and the
// interactive star chart. Plain script, no build step, no dependencies.
(function () {
'use strict';
var lib = window.oscLib;
var KEYS = window.col_keys || [];
var COLS = window.cols || [];
var RAW = window.osc_data || [];
var HISTORY = window.osc_history || {};
var HISTORY_RANGE = window.osc_history_range || null;
var HISTORY_DEFAULT = window.osc_history_default || [];
var EM_DASH = '—';
/* ── small DOM helpers ─────────────────────────────────────────────── */
function el(tag, attrs, children) {
var node = document.createElement(tag);
applyAttrs(node, attrs);
append(node, children);
return node;
}
var SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs, children) {
var node = document.createElementNS(SVG_NS, tag);
applyAttrs(node, attrs);
append(node, children);
return node;
}
function applyAttrs(node, attrs) {
if (!attrs) return;
Object.keys(attrs).forEach(function (name) {
var value = attrs[name];
if (value == null || value === false) return;
if (name === 'text') node.textContent = value;
else if (name === 'class') node.setAttribute('class', value);
else if (name.indexOf('on') === 0) node.addEventListener(name.slice(2), value);
else node.setAttribute(name, value);
});
}
function append(node, children) {
if (children == null) return;
(Array.isArray(children) ? children : [children]).forEach(function (child) {
if (child == null || child === false) return;
node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function pct(value, total) {
return ((value / total) * 100).toFixed(3) + '%';
}
// Lucide, inlined: no icon font, no sprite.
function icon(name, size) {
var paths = {
x: ['', 'none'],
external: ['', 'none'],
lock: ['', 'none'],
// Sort indicators. Archivo has no ↕ glyph, so all three are drawn rather
// than set in type — one fallback font in the header would be visible.
'sort-none': ['', 'none'],
'sort-desc': ['', 'none'],
'sort-asc': ['', 'none'],
};
var spec = paths[name];
var node = svg('svg', {
width: size || 16,
height: size || 16,
viewBox: '0 0 24 24',
fill: spec[1] === 'fill' ? 'currentColor' : 'none',
stroke: spec[1] === 'fill' ? 'none' : 'currentColor',
'stroke-width': spec[1] === 'fill' ? null : 2.2,
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'aria-hidden': 'true',
});
node.innerHTML = spec[0];
return node;
}
/* ── data ──────────────────────────────────────────────────────────── */
var scratch = document.createElement('div');
// data.js ships each cell as the HTML the build generated from data.yaml.
// Parse it once here so the page keeps data.yaml as its single source of truth.
function parseCell(html) {
scratch.innerHTML = html == null ? '' : String(html);
var parts = [];
var rich = false;
(function walk(node, href, struck) {
for (var child = node.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 3) {
if (child.nodeValue) parts.push({ v: child.nodeValue, h: href, s: struck });
} else if (child.nodeType === 1) {
var tag = child.tagName.toLowerCase();
if (tag === 'a') {
rich = true;
walk(child, child.getAttribute('href'), struck);
} else if (tag === 's' || tag === 'del' || tag === 'strike') {
rich = true;
walk(child, href, true);
} else {
walk(child, href, struck);
}
}
}
})(scratch, null, false);
return {
text: scratch.textContent.replace(/\s+/g, ' ').trim(),
parts: rich ? parts : [],
marker: scratch.querySelector('.stars-with-extra'),
};
}
function buildRows() {
return RAW.map(function (cells, index) {
var row = { index: index, fields: {}, parts: {} };
KEYS.forEach(function (key, i) {
var cell = parseCell(cells[i]);
row.fields[key] = cell.text;
if (cell.parts.length) row.parts[key] = cell.parts;
if (key === 'stars') {
row.starsAsterisk = !!cell.marker;
row.starsTitle = cell.marker ? cell.marker.getAttribute('title') || '' : '';
}
});
row.name = row.fields.name || '';
row.starsNum = parseInt(row.fields.stars, 10);
if (!isFinite(row.starsNum)) row.starsNum = -1;
row.difNum = parseInt(row.fields.stars_dif, 10);
if (!isFinite(row.difNum)) row.difNum = 0;
return row;
});
}
var rows = buildRows();
var colTitle = {};
KEYS.forEach(function (key, i) { colTitle[key] = (COLS[i] && COLS[i].title) || key; });
/* ── state ─────────────────────────────────────────────────────────── */
var state = {
query: '',
sortKey: 'stars',
sortDir: 'desc',
visible: new Set(lib.DEFAULT_VISIBLE),
columnsOpen: false,
detailIndex: null,
seriesRows: HISTORY_DEFAULT.slice(),
rangeIdx: 2,
window: null,
hoverX: null,
hoverRow: null,
pinnedRow: null,
dragFrom: null,
brush: null,
plotW: 900,
narrow: false,
};
var sorted = [];
function visibleKeys() {
var keys = KEYS.filter(function (key) {
if (!state.visible.has(key)) return false;
if (state.narrow && lib.NARROW_HIDDEN.indexOf(key) !== -1) return false;
return true;
});
return lib.orderColumns(keys);
}
/* ── cell rendering ────────────────────────────────────────────────── */
// A cell's link must not open the record behind it.
function stopClick(event) { event.stopPropagation(); }
function renderParts(parts, text, fallback) {
var nodes = [];
var source = parts && parts.length
? parts
: [{ v: lib.isEmptyValue(text) ? (fallback == null ? EM_DASH : fallback) : text }];
source.forEach(function (part) {
var attrs = { text: part.v };
if (part.s) attrs.style = 'text-decoration:line-through';
if (part.h) {
attrs.href = part.h;
attrs.onclick = stopClick;
nodes.push(el('a', attrs));
} else {
nodes.push(el('span', attrs));
}
});
return nodes;
}
function renderCell(row, key) {
if (key === 'stars') {
var nodes = [el('span', { text: lib.fmtInt(row.starsNum) })];
if (row.starsAsterisk) {
nodes.push(el('span', { class: 'stars-extra', title: row.starsTitle }));
}
return nodes;
}
if (key === 'stars_dif') {
var delta = lib.fmtDelta(row.fields.stars_dif);
return [el('span', { class: 'delta-' + delta.tone, text: delta.text })];
}
return renderParts(row.parts[key], row.fields[key], EM_DASH);
}
/* ── table ─────────────────────────────────────────────────────────── */
var tableHead = document.getElementById('table-head');
var tableBody = document.getElementById('table-body');
var tableCount = document.getElementById('table-count');
var tableEmpty = document.getElementById('table-empty');
var searchInput = document.getElementById('search');
function renderTable() {
var keys = visibleKeys();
sorted = lib.filterSort(rows, {
query: state.query,
sortKey: state.sortKey,
sortDir: state.sortDir,
searchKeys: keys.concat(lib.SEARCH_ALWAYS),
});
clear(tableHead);
var headRow = el('tr');
keys.forEach(function (key) {
var active = state.sortKey === key;
var th = el('th', {
class: 'col-' + key + (lib.NUMERIC_KEYS.indexOf(key) !== -1 ? ' num' : ''),
'data-key': key,
title: colTitle[key],
scope: 'col',
onclick: function () { sortBy(key); },
}, [
lib.shortTitle(key, colTitle[key]),
el('span', { class: 'sort' }, [
icon(active ? (state.sortDir === 'desc' ? 'sort-desc' : 'sort-asc') : 'sort-none', 11),
]),
]);
if (active) th.setAttribute('aria-sort', state.sortDir === 'desc' ? 'descending' : 'ascending');
headRow.appendChild(th);
});
tableHead.appendChild(headRow);
var body = document.createDocumentFragment();
sorted.forEach(function (row, position) {
var tr = el('tr', {
'data-position': position,
onclick: function () { openRecord(position); },
});
keys.forEach(function (key) {
var numeric = lib.NUMERIC_KEYS.indexOf(key) !== -1;
var td = el('td', {
class: 'col-' + key + ' cell-' + key + (numeric ? ' num' : ''),
title: key === 'stars' || key === 'stars_dif' ? null : row.fields[key] || null,
}, renderCell(row, key));
tr.appendChild(td);
});
body.appendChild(tr);
});
clear(tableBody);
tableBody.appendChild(body);
tableCount.textContent = sorted.length === rows.length
? rows.length + ' systems · ' + KEYS.length + ' attributes'
: sorted.length + ' of ' + rows.length + ' systems';
if (sorted.length) {
tableEmpty.hidden = true;
} else {
tableEmpty.hidden = false;
tableEmpty.textContent = 'Nothing matches “' + state.query + '”.';
}
}
function sortBy(key) {
state.sortDir = state.sortKey === key && state.sortDir === 'desc' ? 'asc' : 'desc';
state.sortKey = key;
closeRecord();
renderTable();
}
/* ── columns popover ───────────────────────────────────────────────── */
var columnsButton = document.getElementById('columns-button');
var columnsCount = document.getElementById('columns-count');
var columnsPopover = document.getElementById('columns-popover');
var columnsBody = document.getElementById('columns-body');
// Built once. Toggling a box only updates state on the existing inputs, so
// keyboard focus survives a change.
var columnInputs = {};
function buildColumns() {
clear(columnsBody);
columnInputs = {};
lib.COLUMN_GROUPS.forEach(function (group) {
var box = el('div', null, [el('div', { class: 'columns__group-name', text: group.name })]);
group.keys.forEach(function (key) {
var input = el('input', {
type: 'checkbox',
onchange: function () { toggleColumn(key); },
});
columnInputs[key] = input;
box.appendChild(el('label', { class: 'columns__option' }, [
input,
el('span', { text: lib.shortTitle(key, colTitle[key]) }),
]));
});
columnsBody.appendChild(box);
});
}
function syncColumns() {
columnsCount.textContent = state.visible.size;
Object.keys(columnInputs).forEach(function (key) {
var on = state.visible.has(key);
var input = columnInputs[key];
input.checked = on;
// The last visible column can never be turned off.
input.disabled = on && state.visible.size === 1;
});
}
function renderColumns() {
columnsButton.setAttribute('aria-expanded', state.columnsOpen ? 'true' : 'false');
columnsPopover.hidden = !state.columnsOpen;
syncColumns();
}
function toggleColumn(key) {
if (state.visible.has(key)) {
if (state.visible.size === 1) {
syncColumns();
return;
}
state.visible.delete(key);
} else {
state.visible.add(key);
}
syncColumns();
renderTable();
}
function setColumnsOpen(open) {
state.columnsOpen = open;
renderColumns();
}
columnsButton.addEventListener('click', function () { setColumnsOpen(!state.columnsOpen); });
document.getElementById('columns-all').addEventListener('click', function () {
state.visible = new Set(KEYS);
syncColumns();
renderTable();
});
document.getElementById('columns-reset').addEventListener('click', function () {
state.visible = new Set(lib.DEFAULT_VISIBLE);
syncColumns();
renderTable();
});
document.getElementById('columns-close').addEventListener('click', function () {
setColumnsOpen(false);
});
document.addEventListener('click', function (event) {
if (!state.columnsOpen) return;
if (columnsPopover.contains(event.target) || columnsButton.contains(event.target)) return;
setColumnsOpen(false);
});
searchInput.addEventListener('input', function (event) {
state.query = event.target.value;
closeRecord();
renderTable();
});
/* ── project record ────────────────────────────────────────────────── */
var record = document.getElementById('record');
var recordPosition = document.getElementById('record-position');
var recordHead = document.getElementById('record-head');
var recordBody = document.getElementById('record-body');
var recordMissing = document.getElementById('record-missing');
// Shown in the head, so not repeated in the grouped body below it.
var HEAD_KEYS = ['stars', 'stars_dif', 'name', 'description', 'license', 'last_committed'];
function linkButtons(row) {
var links = [];
(row.parts.source || []).forEach(function (part) {
if (part.h) links.push({ label: part.v, href: part.h });
});
var demoIndex = 0;
(row.parts.demo || []).forEach(function (part) {
if (!part.h) return;
demoIndex += 1;
// Bare [1]/[2] labels say nothing on their own.
links.push({ label: /^\[\d+\]$/.test(part.v.trim()) ? 'demo ' + demoIndex : part.v, href: part.h });
});
return links;
}
// A fixed box is sized by the initial containing block, and that is not always
// the width the document lays out in: Chrome's device toolbar in responsive
// mode reports it wider, and the card then runs off the side of the screen with
// no way to read it but panning. Pin the card to the layout viewport, which is
// the width everything else on the page already obeys.
function sizeRecord() {
record.style.width = document.documentElement.clientWidth + 'px';
}
function renderRecord() {
var row = state.detailIndex == null ? null : sorted[state.detailIndex];
if (!row) {
record.hidden = true;
record.style.width = '';
return;
}
record.hidden = false;
sizeRecord();
recordPosition.textContent = 'Record ' + (state.detailIndex + 1) + ' / ' + sorted.length;
var delta = lib.fmtDelta(row.fields.stars_dif);
clear(recordHead);
recordHead.appendChild(el('div', null, [
el('h2', { class: 'record__name', text: row.name }),
el('p', { class: 'record__desc' }, renderParts(row.parts.description, row.fields.description, 'No description yet.')),
el('div', { class: 'record__links' }, linkButtons(row).map(function (link) {
return el('a', { class: 'record__link', href: link.href }, [link.label, icon('external', 12)]);
})),
]));
recordHead.appendChild(el('div', { class: 'record__stats' }, [
stat('Stars', lib.fmtInt(row.starsNum)),
stat('Last 30 days', delta.text, null, delta.tone === 'up' ? 'color:var(--color-positive)' : null),
stat('Last commit', row.fields.last_committed || EM_DASH, true),
stat('License', lib.isEmptyValue(row.fields.license) ? EM_DASH : row.fields.license, true),
]));
clear(recordBody);
lib.COLUMN_GROUPS.forEach(function (group) {
var items = group.keys.filter(function (key) {
return HEAD_KEYS.indexOf(key) === -1 && !lib.isEmptyValue(row.fields[key]);
});
if (!items.length) return;
var box = el('div', null, [el('div', { class: 'record__group-name', text: group.name })]);
items.forEach(function (key) {
box.appendChild(el('div', { class: 'record__field' }, [
el('div', { class: 'record__field-label', text: lib.shortTitle(key, colTitle[key]) }),
el('div', { class: 'record__field-value' }, renderParts(row.parts[key], row.fields[key])),
]));
});
recordBody.appendChild(box);
});
// Gaps become a contribution prompt instead of a wall of question marks.
var unknown = KEYS.filter(function (key) { return lib.isEmptyValue(row.fields[key]); });
if (unknown.length) {
recordMissing.hidden = false;
recordMissing.textContent = 'Not documented (' + unknown.length + '): '
+ unknown.map(function (key) { return lib.shortTitle(key, colTitle[key]); }).join(', ')
+ '. Contribute the missing data on GitHub.';
} else {
recordMissing.hidden = true;
}
record.scrollTop = 0;
}
function stat(label, value, small, style) {
return el('div', null, [
el('span', {
class: 'record__stat-value' + (small ? ' record__stat-value--sm' : ''),
style: style,
text: value,
}),
el('span', { class: 'record__stat-label', text: label }),
]);
}
function openRecord(position) {
state.detailIndex = position;
renderRecord();
}
function closeRecord() {
if (state.detailIndex == null) return;
state.detailIndex = null;
renderRecord();
}
function stepRecord(delta) {
if (state.detailIndex == null || !sorted.length) return;
state.detailIndex = (state.detailIndex + delta + sorted.length) % sorted.length;
renderRecord();
}
document.getElementById('record-prev').addEventListener('click', function () { stepRecord(-1); });
document.getElementById('record-next').addEventListener('click', function () { stepRecord(1); });
document.getElementById('record-close').addEventListener('click', closeRecord);
document.addEventListener('keydown', function (event) {
if (event.key !== 'Escape') return;
closeRecord();
setColumnsOpen(false);
});
/* ── chart ─────────────────────────────────────────────────────────── */
var plot = document.getElementById('plot');
var plotSvg = document.getElementById('plot-svg');
var plotOverlay = document.getElementById('plot-overlay');
var plotTip = document.getElementById('plot-tip');
var rangeBox = document.getElementById('range');
var seriesList = document.getElementById('series-list');
var seriesAdd = document.getElementById('series-add');
// Right padding is the gutter the end labels sit in. Below the width where
// they stop tracking their own lines they are dropped and the plot takes the
// space back — by then the series rail is stacked directly underneath and
// names the lines anyway.
var PAD_WIDE = { l: 4, r: 96, t: 18, b: 30 };
var PAD_NARROW = { l: 4, r: 12, t: 18, b: 30 };
var END_LABEL_MIN_WIDTH = 620;
var PLOT_H = 320;
var geo = null;
var seriesStyles = Object.create(null);
var nextSeriesColor = 0;
var nextSeriesDash = 0;
function showEndLabels() { return state.plotW >= END_LABEL_MIN_WIDTH; }
function styleFor(row) {
// Assign once per project, retaining its style even while it is removed.
if (!seriesStyles[row]) {
seriesStyles[row] = {
color: lib.SERIES_COLORS[nextSeriesColor++ % lib.SERIES_COLORS.length],
dash: HISTORY[row].d || lib.DASH_CYCLE[nextSeriesDash++ % lib.DASH_CYCLE.length],
};
}
return seriesStyles[row];
}
function chartWindow() {
if (state.window) return state.window;
return lib.rangeFor(lib.RANGE_PRESETS[state.rangeIdx], HISTORY_RANGE.first, HISTORY_RANGE.last);
}
function focusRow() {
return state.pinnedRow != null ? state.pinnedRow : state.hoverRow;
}
// How far everything that is not in focus recedes. Used for both the plot
// lines and the rail rows so the two columns read at the same strength.
var DIM_OPACITY = 0.35;
function emphasis(row) {
var focus = focusRow();
if (focus == null) return { opacity: 1, width: 2, weight: 600 };
// Never recolour the focused line — red read as an error.
return row === focus
? { opacity: 1, width: 3.4, weight: 800 }
: { opacity: DIM_OPACITY, width: 1.8, weight: 600 };
}
function buildSeries() {
return state.seriesRows
.filter(function (row) { return HISTORY[row]; })
.map(function (row) {
var style = styleFor(row);
return {
row: row,
label: rows[row] ? rows[row].name : String(row),
points: HISTORY[row].p,
dash: style.dash,
color: style.color,
};
});
}
function svgX(event) {
var box = plotSvg.getBoundingClientRect();
var view = plotSvg.viewBox.baseVal;
return ((event.clientX - box.left) / box.width) * (view.width || box.width);
}
function renderChart() {
var win = chartWindow();
var w = state.plotW;
geo = lib.chartGeometry({
series: buildSeries(),
w: w,
h: PLOT_H,
pad: showEndLabels() ? PAD_WIDE : PAD_NARROW,
from: win.from,
to: win.to,
});
plotSvg.setAttribute('viewBox', '0 0 ' + w + ' ' + PLOT_H);
clear(plotSvg);
geo.yTicks.forEach(function (tick) {
plotSvg.appendChild(svg('line', {
class: 'chart__grid-y', x1: geo.x0, x2: geo.x1, y1: tick.y, y2: tick.y, 'stroke-width': 1,
}));
});
geo.xTicks.forEach(function (tick) {
plotSvg.appendChild(svg('line', {
class: 'chart__grid-x', x1: tick.x, x2: tick.x, y1: geo.y1, y2: geo.y0, 'stroke-width': 1,
}));
});
plotSvg.appendChild(svg('line', {
class: 'chart__baseline', x1: geo.x0, x2: geo.x1, y1: geo.y0, y2: geo.y0,
}));
var brush = svg('rect', { class: 'chart__brush', y: geo.y1, height: geo.y0 - geo.y1 });
brush.style.display = 'none';
plotSvg.appendChild(brush);
var cursor = svg('line', { class: 'chart__cursor', y1: geo.y1, y2: geo.y0 });
cursor.style.display = 'none';
plotSvg.appendChild(cursor);
var lineNodes = geo.lines.map(function (line) {
var node = svg('path', {
class: 'chart__line', d: line.d, stroke: line.color, 'stroke-dasharray': line.dash,
});
plotSvg.appendChild(node);
return node;
});
// A transparent fat duplicate is the hit target: hovering a 2px stroke does
// not feel instant.
geo.lines.forEach(function (line) {
plotSvg.appendChild(svg('path', {
class: 'chart__hit',
d: line.d,
onmousemove: function () { setHoverRow(line.row); },
onclick: function (event) {
event.stopPropagation();
if (suppressClick) return;
state.pinnedRow = state.pinnedRow === line.row ? null : line.row;
applyEmphasis();
syncSeries();
},
}));
});
var dots = svg('g');
plotSvg.appendChild(dots);
// Axis and end labels are HTML: cheaper to style and they inherit page type.
clear(plotOverlay);
geo.yTicks.forEach(function (tick) {
plotOverlay.appendChild(el('div', {
class: 'chart__ylabel',
style: 'left:0;top:' + pct(tick.y, PLOT_H) + ';padding-bottom:2px',
text: tick.label,
}));
});
geo.xTicks.forEach(function (tick) {
plotOverlay.appendChild(el('div', {
class: 'chart__xlabel',
style: 'left:' + pct(tick.x, w) + ';top:' + pct(geo.y0, PLOT_H) + ';padding-top:5px',
text: tick.label,
}));
});
var endLabels = !showEndLabels() ? [] : geo.lines.map(function (line) {
var node = el('div', {
class: 'chart__endlabel',
style: 'left:' + pct(geo.x1 + 8, w) + ';top:' + pct(line.labelY, PLOT_H) + ';color:' + line.color,
text: line.label,
});
plotOverlay.appendChild(node);
return node;
});
geo.nodes = { lines: lineNodes, endLabels: endLabels, dots: dots, cursor: cursor, brush: brush };
applyEmphasis();
renderHover();
syncRange();
buildSeriesRows();
syncSeries();
}
function applyEmphasis() {
if (!geo || !geo.nodes) return;
geo.lines.forEach(function (line, i) {
var em = emphasis(line.row);
geo.nodes.lines[i].setAttribute('stroke-opacity', em.opacity);
geo.nodes.lines[i].setAttribute('stroke-width', em.width);
var label = geo.nodes.endLabels[i];
if (!label) return;
label.style.opacity = em.opacity;
label.style.fontWeight = em.weight;
label.setAttribute('data-focus', em.weight === 800 ? 'on' : 'off');
});
}
function renderHover() {
if (!geo || !geo.nodes) return;
var nodes = geo.nodes;
clear(nodes.dots);
if (state.brush) {
var a = Math.min(state.brush.a, state.brush.b);
nodes.brush.setAttribute('x', a);
nodes.brush.setAttribute('width', Math.abs(state.brush.b - state.brush.a));
nodes.brush.style.display = '';
} else {
nodes.brush.style.display = 'none';
}
if (state.hoverX == null || state.dragFrom != null) {
nodes.cursor.style.display = 'none';
plotTip.hidden = true;
return;
}
nodes.cursor.setAttribute('x1', state.hoverX);
nodes.cursor.setAttribute('x2', state.hoverX);
nodes.cursor.style.display = '';
var cursorTime = geo.xToTime(state.hoverX);
var items = [];
geo.lines.forEach(function (line) {
var read = lib.readAt(line, cursorTime, geo.sy);
if (!read) {
// The series has no data on this date; say so instead of borrowing the
// nearest number, and draw no dot.
items.push({ label: line.label, value: EM_DASH, muted: true });
return;
}
items.push({ label: line.label, value: lib.fmtInt(read.value) });
nodes.dots.appendChild(svg('circle', {
class: 'chart__dot',
cx: state.hoverX,
cy: read.y,
r: 3,
fill: 'var(--color-bg)',
stroke: line.color,
}));
});
if (!items.length) {
plotTip.hidden = true;
return;
}
var flip = state.hoverX > (geo.x1 - geo.x0) * 0.6;
plotTip.hidden = false;
plotTip.style.left = 'calc(' + ((state.hoverX / state.plotW) * 100).toFixed(2) + '% + '
+ (flip ? '-168px' : '14px') + ')';
clear(plotTip);
plotTip.appendChild(el('div', {
class: 'chart__tooltip-date',
text: lib.monthYear(cursorTime),
}));
items.forEach(function (item) {
plotTip.appendChild(el('div', {
class: 'chart__tooltip-row' + (item.muted ? ' chart__tooltip-row--empty' : ''),
}, [
el('span', { text: item.label }),
el('span', { text: item.value }),
]));
});
}
function setHoverRow(row) {
if (state.hoverRow === row) return;
state.hoverRow = row;
applyEmphasis();
}
var suppressClick = false;
plotSvg.addEventListener('mousemove', function (event) {
var x = svgX(event);
state.hoverX = x;
if (state.dragFrom != null) state.brush = { a: state.dragFrom, b: x };
renderHover();
});
plotSvg.addEventListener('mouseleave', function () {
state.hoverX = null;
state.dragFrom = null;
state.brush = null;
state.hoverRow = null;
applyEmphasis();
renderHover();
});
plotSvg.addEventListener('mousedown', function (event) {
var x = svgX(event);
state.dragFrom = x;
state.brush = { a: x, b: x };
renderHover();
});
plotSvg.addEventListener('mouseup', function (event) {
var from = state.dragFrom;
var x = svgX(event);
state.dragFrom = null;
state.brush = null;
if (from == null || Math.abs(x - from) < 12) {
renderHover();
return;
}
// A drag that committed a zoom must not also land as a click on a line.
suppressClick = true;
setTimeout(function () { suppressClick = false; }, 0);
state.window = {
from: lib.isoDate(geo.xToTime(Math.min(from, x))),
to: lib.isoDate(geo.xToTime(Math.max(from, x))),
};
state.hoverX = null;
renderChart();
});
plotSvg.addEventListener('dblclick', function () {
state.window = null;
state.brush = null;
state.dragFrom = null;
state.pinnedRow = null;
renderChart();
});
var rangeButtons = [];
function buildRange() {
clear(rangeBox);
rangeButtons = lib.RANGE_PRESETS.map(function (preset, i) {
var button = el('button', {
type: 'button',
'aria-pressed': 'false',
text: preset.label,
onclick: function () {
state.rangeIdx = i;
state.window = null;
state.brush = null;
state.hoverX = null;
renderChart();
},
});
rangeBox.appendChild(button);
return button;
});
}
function syncRange() {
rangeButtons.forEach(function (button, i) {
var active = !state.window && state.rangeIdx === i;
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
var seriesNodes = [];
function buildSeriesRows() {
clear(seriesList);
seriesNodes = geo.lines.map(function (line) {
var swatch = lib.SWATCH[dashName(line.dash)];
var stroke = svg('line', {
x1: 0, x2: swatch.length, y1: 4, y2: 4,
stroke: line.color,
'stroke-width': 2,
'stroke-dasharray': swatch.dash,
});
var lock = lockGlyph();
var node = el('div', {
class: 'series__row',
onmouseenter: function () { setHoverRow(line.row); },
onmouseleave: function () { setHoverRow(null); },
onclick: function () {
state.pinnedRow = state.pinnedRow === line.row ? null : line.row;
applyEmphasis();
syncSeries();
},
}, [
svg('svg', { class: 'series__swatch', width: 26, height: 8 }, [stroke]),
el('span', { class: 'series__name', text: line.label }),
lock,
el('span', { class: 'series__stars', text: lib.fmtInt(line.endValue) }),
el('button', {
type: 'button',
class: 'series__remove',
title: 'Remove series',
onclick: function (event) {
event.stopPropagation();
state.seriesRows = state.seriesRows.filter(function (row) { return row !== line.row; });
if (state.pinnedRow === line.row) state.pinnedRow = null;
if (state.hoverRow === line.row) state.hoverRow = null;
renderChart();
renderAddable();
},
}, [icon('x', 12)]),
]);
seriesList.appendChild(node);
return { row: line.row, node: node, stroke: stroke, lock: lock };
});
}
// The rail follows the lock only. Tying it to hover as well made the whole
// column flicker while the pointer crossed the plot.
function syncSeries() {
var anyLocked = state.pinnedRow != null;
seriesNodes.forEach(function (entry) {
var locked = state.pinnedRow === entry.row;
entry.node.setAttribute('data-locked', locked ? 'true' : 'false');
entry.node.setAttribute(
'title',
locked ? 'Locked — click to release this series' : 'Click to isolate this series'
);
entry.node.style.opacity = anyLocked && !locked ? String(DIM_OPACITY) : '';
entry.stroke.setAttribute('stroke-width', locked ? 3.4 : 2);
entry.lock.style.display = locked ? '' : 'none';
});
}
function lockGlyph() {
var node = icon('lock', 12);
node.setAttribute('class', 'series__lock');
node.setAttribute('stroke', 'var(--color-accent)');
return node;
}
function dashName(dash) {
var names = Object.keys(lib.DASH);
for (var i = 0; i < names.length; i++) {
if (lib.DASH[names[i]] === dash) return names[i];
}
return 'solid';
}
function renderAddable() {
clear(seriesAdd);
seriesAdd.appendChild(el('option', { value: '', text: '+ Add a project…' }));
Object.keys(HISTORY)
.map(Number)
.filter(function (row) { return state.seriesRows.indexOf(row) === -1 && rows[row]; })
.sort(function (a, b) { return rows[b].starsNum - rows[a].starsNum; })
.forEach(function (row) {
seriesAdd.appendChild(el('option', {
value: String(row),
text: rows[row].name + ' · ' + lib.fmtInt(rows[row].starsNum),
}));
});
seriesAdd.value = '';
}
seriesAdd.addEventListener('change', function (event) {
var row = parseInt(event.target.value, 10);
if (!isFinite(row)) return;
state.seriesRows = state.seriesRows.concat([row]);
renderChart();
renderAddable();
});
/* ── layout ────────────────────────────────────────────────────────── */
function measure() {
var narrow = window.matchMedia('(max-width: 880px)').matches;
var width = Math.max(420, plot.clientWidth || 900);
var changedTable = narrow !== state.narrow;
var changedPlot = Math.abs(width - state.plotW) > 0.5;
state.narrow = narrow;
state.plotW = width;
if (state.detailIndex != null) sizeRecord();
if (changedTable) {
closeRecord();
renderTable();
}
if (changedPlot) renderChart();
}
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(measure, 80);
});
/* ── Isso vote counters ────────────────────────────────────────────── */
// Isso prints a bare JavaScript number, so a negative score arrives as an
// ASCII hyphen and a positive one carries no sign at all. Rewrite both to
// match the table: a real minus sign, and an explicit + when the score is up.
var MINUS_SIGN = '\u2212';
function formatVotes(scope) {
if (!scope || !scope.querySelectorAll) return;
var nodes = scope.querySelectorAll('.isso-votes');
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
var count = parseInt(node.textContent.replace(/\u2212/g, '-').replace(/[+\s]/g, ''), 10);
if (!isFinite(count)) continue;
var text = count > 0 ? '+' + count : count < 0 ? MINUS_SIGN + Math.abs(count) : '0';
// Writing only on a real change keeps the observer below from looping.
if (node.textContent !== text) node.textContent = text;
}
}
var issoThread = document.getElementById('isso-thread');
if (issoThread && window.MutationObserver) {
formatVotes(issoThread);
// The thread is rendered after this script runs, and re-rendered on a vote.
new MutationObserver(function () { formatVotes(issoThread); })
.observe(issoThread, { childList: true, subtree: true, characterData: true });
}
/* ── start ─────────────────────────────────────────────────────────── */
state.narrow = window.matchMedia('(max-width: 880px)').matches;
state.plotW = Math.max(420, plot.clientWidth || 900);
renderTable();
buildColumns();
renderColumns();
if (HISTORY_RANGE && state.seriesRows.length) {
buildRange();
renderChart();
renderAddable();
}
// Web fonts change the plot width; re-measure once they land.
if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure);
})();