Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 64 additions & 23 deletions assets/insights.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,35 @@

function barsSVG(ins) {
var rows = ins.rows.slice(0, 8);
var max = Math.max.apply(null, rows.map(function (r) { return r.value; }));
var vals = rows.map(function (r) { return r.value; });
var max = Math.max.apply(null, vals), min = Math.min.apply(null, vals);
var W = 620, rowH = 34, padL = 200, padR = 130;
var H = rows.length * rowH + 8;
var s = '<svg viewBox="0 0 ' + W + ' ' + H + '" class="vh-ins__viz" role="img" aria-label="' + esc(ins.viz_label || 'bar chart') + '">';
rows.forEach(function (r, i) {
var y = i * rowH + 8, w = Math.max(3, (r.value / max) * (W - padL - padR));
var gold = i === 0;
s += '<text x="0" y="' + (y + 17) + '" class="vh-ins__svglabel">' + esc(r.label) + '</text>';
s += '<rect x="' + padL + '" y="' + y + '" width="' + w.toFixed(1) + '" height="20" rx="4" fill="' + (gold ? GOLD : TERRA) + '" opacity="' + (gold ? 1 : 0.55 + 0.4 * (1 - i / rows.length)) + '"/>';
s += '<text x="' + (padL + w + 8) + '" y="' + (y + 16) + '" class="vh-ins__svgval">' + r.value + (r.tag ? ' · ' + esc(r.tag) : '') + '</text>';
});
if (min >= 0) {
rows.forEach(function (r, i) {
var y = i * rowH + 8, w = Math.max(3, (r.value / max) * (W - padL - padR));
var gold = i === 0;
s += '<text x="0" y="' + (y + 17) + '" class="vh-ins__svglabel">' + esc(r.label) + '</text>';
s += '<rect x="' + padL + '" y="' + y + '" width="' + w.toFixed(1) + '" height="20" rx="4" fill="' + (gold ? GOLD : TERRA) + '" opacity="' + (gold ? 1 : 0.55 + 0.4 * (1 - i / rows.length)) + '"/>';
s += '<text x="' + (padL + w + 8) + '" y="' + (y + 16) + '" class="vh-ins__svgval">' + r.value + (r.tag ? ' · ' + esc(r.tag) : '') + '</text>';
});
} else {
// Diverging bars around a zero axis (e.g. playoff deltas that go negative).
var span = max - min, plotW = W - padL - padR;
var zeroX = padL + ((0 - min) / span) * plotW;
s += '<line x1="' + zeroX.toFixed(1) + '" y1="4" x2="' + zeroX.toFixed(1) + '" y2="' + (H - 4) + '" stroke="' + MUTED + '" stroke-width="1"/>';
rows.forEach(function (r, i) {
var y = i * rowH + 8;
var bw = Math.max(3, (Math.abs(r.value) / span) * plotW);
var bx = r.value < 0 ? zeroX - bw : zeroX;
var fill = r.value < 0 ? TERRA : GOLD;
var lx = r.value < 0 ? zeroX + 8 : bx + bw + 8;
s += '<text x="0" y="' + (y + 17) + '" class="vh-ins__svglabel">' + esc(r.label) + '</text>';
s += '<rect x="' + bx.toFixed(1) + '" y="' + y + '" width="' + bw.toFixed(1) + '" height="20" rx="4" fill="' + fill + '"/>';
s += '<text x="' + lx.toFixed(1) + '" y="' + (y + 16) + '" class="vh-ins__svgval">' + r.value + (r.tag ? ' · ' + esc(r.tag) : '') + '</text>';
});
}
return s + '</svg>';
}

Expand Down Expand Up @@ -255,22 +273,45 @@
c.save();
if (ins.viz === 'bars') {
var rows = ins.rows.slice(0, 5);
var max = Math.max.apply(null, rows.map(function (r) { return r.value; }));
var bvals = rows.map(function (r) { return r.value; });
var bmax = Math.max.apply(null, bvals), bmin = Math.min.apply(null, bvals);
var rh = h / rows.length;
rows.forEach(function (r, i) {
var ry = y + i * rh;
var bw = Math.max(4, (r.value / max) * (w - 260));
c.font = '400 26px ' + F; c.fillStyle = '#F9F6F0';
var label = r.label.length > 26 ? r.label.slice(0, 25) + '…' : r.label;
c.fillText(label, x, ry + rh / 2 + 8);
c.fillStyle = i === 0 ? GOLD : TERRA;
var bx = x + 260;
c.beginPath();
c.roundRect(bx, ry + rh / 2 - 13, bw, 26, 6);
c.fill();
c.fillStyle = MUTED; c.font = '400 24px ' + F;
c.fillText(String(r.value), bx + bw + 12, ry + rh / 2 + 8);
});
if (bmin >= 0) {
rows.forEach(function (r, i) {
var ry = y + i * rh;
var bw = Math.max(4, (r.value / bmax) * (w - 260));
c.font = '400 26px ' + F; c.fillStyle = '#F9F6F0';
var label = r.label.length > 26 ? r.label.slice(0, 25) + '…' : r.label;
c.fillText(label, x, ry + rh / 2 + 8);
c.fillStyle = i === 0 ? GOLD : TERRA;
var bx = x + 260;
c.beginPath();
c.roundRect(bx, ry + rh / 2 - 13, bw, 26, 6);
c.fill();
c.fillStyle = MUTED; c.font = '400 24px ' + F;
c.fillText(String(r.value), bx + bw + 12, ry + rh / 2 + 8);
});
} else {
// Diverging bars around a zero axis (e.g. playoff deltas that go negative).
var dspan = bmax - bmin, dplot = w - 260 - 60;
var dzero = x + 260 + ((0 - bmin) / dspan) * dplot;
c.strokeStyle = MUTED; c.lineWidth = 1;
c.beginPath(); c.moveTo(dzero, y); c.lineTo(dzero, y + h); c.stroke();
rows.forEach(function (r, i) {
var dry = y + i * rh;
var dbw = Math.max(4, (Math.abs(r.value) / dspan) * dplot);
var dbx = r.value < 0 ? dzero - dbw : dzero;
c.font = '400 26px ' + F; c.fillStyle = '#F9F6F0';
var dlabel = r.label.length > 26 ? r.label.slice(0, 25) + '…' : r.label;
c.fillText(dlabel, x, dry + rh / 2 + 8);
c.fillStyle = r.value < 0 ? TERRA : GOLD;
c.beginPath();
c.roundRect(dbx, dry + rh / 2 - 13, dbw, 26, 6);
c.fill();
c.fillStyle = MUTED; c.font = '400 24px ' + F;
c.fillText(String(r.value), (r.value < 0 ? dzero : dbx + dbw) + 12, dry + rh / 2 + 8);
});
}
} else if (ins.viz === 'shares') {
var rows2 = ins.rows;
var rh2 = h / rows2.length;
Expand Down
2 changes: 1 addition & 1 deletion assets/insights.json

Large diffs are not rendered by default.

Binary file added assets/og/insight-matchup-nightmares.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion insights.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
<div class="title-card__eyebrow reveal">Insights</div>
<h1 class="title-card__title reveal reveal-1">What 12,966 player-seasons taught the model</h1>
<p class="title-card__lede reveal reveal-2">Curated findings from the embedding atlas — every number recomputable, every card built to be screenshotted and shared.</p>
<div class="title-card__meta reveal reveal-3"><span><b>8</b>Findings</span><span><b>30</b>Seasons of data</span><span><b>1</b>New pick daily</span></div>
<div class="title-card__meta reveal reveal-3"><span><b>9</b>Findings</span><span><b>30</b>Seasons of data</span><span><b>1</b>New pick daily</span></div>
</header>

<div class="vh-ins__wrap">
Expand Down
27 changes: 27 additions & 0 deletions insights/matchup-nightmares.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The playoffs eat shooters — Vector Hoops Insights</title>
<meta name="description" content="Playoff game plans erase pure shooters — scoring, usage and efficiency all crater — while playmakers are the only ones whose role grows. You can scheme a jumper in two days; not a creator.">
<link rel="canonical" href="https://hoops.dumbmodel.com/insights/matchup-nightmares">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Vector Hoops">
<meta property="og:title" content="The playoffs eat shooters">
<meta property="og:description" content="Playoff game plans erase pure shooters — scoring, usage and efficiency all crater — while playmakers are the only ones whose role grows. You can scheme a jumper in two days; not a creator.">
<meta property="og:url" content="https://hoops.dumbmodel.com/insights/matchup-nightmares">
<meta property="og:image" content="https://hoops.dumbmodel.com/assets/og/insight-matchup-nightmares.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="The playoffs eat shooters">
<meta name="twitter:description" content="Playoff game plans erase pure shooters — scoring, usage and efficiency all crater — while playmakers are the only ones whose role grows. You can scheme a jumper in two days; not a creator.">
<meta name="twitter:image" content="https://hoops.dumbmodel.com/assets/og/insight-matchup-nightmares.png">
<meta http-equiv="refresh" content="0; url=/insights.html#matchup-nightmares">
</head>
<body>
<p>Playoff game plans erase pure shooters — scoring, usage and efficiency all crater — while playmakers are the only ones whose role grows. You can scheme a jumper in two days; not a creator.</p>
<p><a href="/insights.html#matchup-nightmares">View this insight on Vector Hoops</a></p>
<script>location.replace("/insights.html#matchup-nightmares");</script>
</body>
</html>
86 changes: 86 additions & 0 deletions pipeline/build_insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def cdist(v):
insights.append(insight_three_point(drift))
insights.append(insight_careful_era(drift))
insights.append(insight_contender_chemistry())
insights.append(insight_matchup_nightmares())

out = {
"built": "build_insights.py",
Expand Down Expand Up @@ -483,6 +484,91 @@ def insight_contender_chemistry():
}


# ---- 9. matchup nightmares ----------------------------------------------------
# Research: /tmp/matchup-nightmares-report.md (2026-10-01).
# Method: per-player regular-season vs playoff splits from assets/playoffs.json
# (stats.nba.com: GP, MIN, USG, PTS100, TS, PLUS_MINUS), joined to 14-d
# embeddings + 8 style clusters; 4,739 rotation player-seasons (made playoffs,
# PO GP>=4, RS >=25 scaled min/g), 1996-97 -> 2024-25. League mean PO-RS
# pts/100 is -1.50 (defenses tighten), so flat is genuinely good.
# Honesty: "game-planning" is the best-fit mechanism (usage AND efficiency fall
# together for shooters), not proven causation. Cluster [2]'s +0.30 (n=125) is
# not significant -- tagged as noise, not claimed.


def insight_matchup_nightmares():
# Exact values from the research pass; kept as constants with the
# citation above (recomputing needs playoffs.json split joins).
rows = [
{
"label": "Pure shooters",
"value": -2.28,
"tag": "3PT accuracy + volume · n=1,010",
},
{"label": "Volume scorers", "value": -2.09, "tag": "shot volume + 3PT volume"},
{
"label": "Glass + rim pressure",
"value": -1.92,
"tag": "defensive glass + rim pressure",
},
{
"label": "Glass + rim protection",
"value": -1.69,
"tag": "offensive glass + rim protection",
},
{
"label": "Scoring volume",
"value": -1.41,
"tag": "scoring volume + shot volume",
},
{
"label": "Low-volume glass",
"value": -0.74,
"tag": "offensive glass, low shot volume",
},
{
"label": "3PT volume, low impact",
"value": 0.30,
"tag": "n=125 · not significant",
},
{
"label": "Playmakers",
"value": -0.18,
"tag": "playmaking + steals · n=588 · role grows",
},
]
return {
"slug": "matchup-nightmares",
"tldr": "Playoff game plans erase pure shooters — scoring, usage and efficiency "
"all crater — while playmakers are the only ones whose role grows. You can "
"scheme a jumper in two days; not a creator.",
"kicker": "Matchup nightmares",
"title": "May solves shooters. It can't solve playmakers.",
"lede": "Playoff minus regular-season pts/100 by style archetype — 4,739 rotation "
"player-seasons, 1996-97 → 2024-25. Pure 3-point specialists lose 2.3 pts/100 in "
"May, with usage (-1.00) and true shooting (-0.039) falling too — worst of all "
"eight archetypes. Playmakers are the only group whose role grows (usage +0.19), "
"and 1 in 16 playoff offenses runs through a playmaker as its usage leader, up "
"from 1 in 250 in the regular season. The pattern fits a game-planning story — a "
"best-of-7 gives defenses time to scheme a jumper, not a creator — but that's the "
"best-fit mechanism, not proven causation. Loudest single-season swings are "
"small-sample color: Donovan Mitchell '19-20 (+16.0) and DeMarcus Cousins '20-21 "
"(+18.1) up; Joel Embiid '22-23 (-14.6) and Troy Daniels '16-17 (-16.3) down.",
"stat": "-2.3",
"stat_label": "playoff pts/100 drop for pure 3-point specialists "
"(usage and efficiency fall too)",
"viz": "bars",
"viz_label": "Playoff scoring change (pts/100) by style archetype",
"rows": rows,
"foot": "4,739 rotation player-seasons (playoff GP ≥ 4, regular season ≥ 25 scaled "
"min/g); regular-season vs playoff splits via stats.nba.com; 2025-26 excluded "
"(season in progress).",
"og_title": "The playoffs eat shooters",
"og_desc": "Pure 3-point specialists lose 2.3 pts/100 in May — playmakers are the "
"only ones whose role grows.",
}


# ---- OG images + stub pages ---------------------------------------------------

OG_W, OG_H = 1200, 630
Expand Down
87 changes: 64 additions & 23 deletions public/assets/insights.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,35 @@

function barsSVG(ins) {
var rows = ins.rows.slice(0, 8);
var max = Math.max.apply(null, rows.map(function (r) { return r.value; }));
var vals = rows.map(function (r) { return r.value; });
var max = Math.max.apply(null, vals), min = Math.min.apply(null, vals);
var W = 620, rowH = 34, padL = 200, padR = 130;
var H = rows.length * rowH + 8;
var s = '<svg viewBox="0 0 ' + W + ' ' + H + '" class="vh-ins__viz" role="img" aria-label="' + esc(ins.viz_label || 'bar chart') + '">';
rows.forEach(function (r, i) {
var y = i * rowH + 8, w = Math.max(3, (r.value / max) * (W - padL - padR));
var gold = i === 0;
s += '<text x="0" y="' + (y + 17) + '" class="vh-ins__svglabel">' + esc(r.label) + '</text>';
s += '<rect x="' + padL + '" y="' + y + '" width="' + w.toFixed(1) + '" height="20" rx="4" fill="' + (gold ? GOLD : TERRA) + '" opacity="' + (gold ? 1 : 0.55 + 0.4 * (1 - i / rows.length)) + '"/>';
s += '<text x="' + (padL + w + 8) + '" y="' + (y + 16) + '" class="vh-ins__svgval">' + r.value + (r.tag ? ' · ' + esc(r.tag) : '') + '</text>';
});
if (min >= 0) {
rows.forEach(function (r, i) {
var y = i * rowH + 8, w = Math.max(3, (r.value / max) * (W - padL - padR));
var gold = i === 0;
s += '<text x="0" y="' + (y + 17) + '" class="vh-ins__svglabel">' + esc(r.label) + '</text>';
s += '<rect x="' + padL + '" y="' + y + '" width="' + w.toFixed(1) + '" height="20" rx="4" fill="' + (gold ? GOLD : TERRA) + '" opacity="' + (gold ? 1 : 0.55 + 0.4 * (1 - i / rows.length)) + '"/>';
s += '<text x="' + (padL + w + 8) + '" y="' + (y + 16) + '" class="vh-ins__svgval">' + r.value + (r.tag ? ' · ' + esc(r.tag) : '') + '</text>';
});
} else {
// Diverging bars around a zero axis (e.g. playoff deltas that go negative).
var span = max - min, plotW = W - padL - padR;
var zeroX = padL + ((0 - min) / span) * plotW;
s += '<line x1="' + zeroX.toFixed(1) + '" y1="4" x2="' + zeroX.toFixed(1) + '" y2="' + (H - 4) + '" stroke="' + MUTED + '" stroke-width="1"/>';
rows.forEach(function (r, i) {
var y = i * rowH + 8;
var bw = Math.max(3, (Math.abs(r.value) / span) * plotW);
var bx = r.value < 0 ? zeroX - bw : zeroX;
var fill = r.value < 0 ? TERRA : GOLD;
var lx = r.value < 0 ? zeroX + 8 : bx + bw + 8;
s += '<text x="0" y="' + (y + 17) + '" class="vh-ins__svglabel">' + esc(r.label) + '</text>';
s += '<rect x="' + bx.toFixed(1) + '" y="' + y + '" width="' + bw.toFixed(1) + '" height="20" rx="4" fill="' + fill + '"/>';
s += '<text x="' + lx.toFixed(1) + '" y="' + (y + 16) + '" class="vh-ins__svgval">' + r.value + (r.tag ? ' · ' + esc(r.tag) : '') + '</text>';
});
}
return s + '</svg>';
}

Expand Down Expand Up @@ -255,22 +273,45 @@
c.save();
if (ins.viz === 'bars') {
var rows = ins.rows.slice(0, 5);
var max = Math.max.apply(null, rows.map(function (r) { return r.value; }));
var bvals = rows.map(function (r) { return r.value; });
var bmax = Math.max.apply(null, bvals), bmin = Math.min.apply(null, bvals);
var rh = h / rows.length;
rows.forEach(function (r, i) {
var ry = y + i * rh;
var bw = Math.max(4, (r.value / max) * (w - 260));
c.font = '400 26px ' + F; c.fillStyle = '#F9F6F0';
var label = r.label.length > 26 ? r.label.slice(0, 25) + '…' : r.label;
c.fillText(label, x, ry + rh / 2 + 8);
c.fillStyle = i === 0 ? GOLD : TERRA;
var bx = x + 260;
c.beginPath();
c.roundRect(bx, ry + rh / 2 - 13, bw, 26, 6);
c.fill();
c.fillStyle = MUTED; c.font = '400 24px ' + F;
c.fillText(String(r.value), bx + bw + 12, ry + rh / 2 + 8);
});
if (bmin >= 0) {
rows.forEach(function (r, i) {
var ry = y + i * rh;
var bw = Math.max(4, (r.value / bmax) * (w - 260));
c.font = '400 26px ' + F; c.fillStyle = '#F9F6F0';
var label = r.label.length > 26 ? r.label.slice(0, 25) + '…' : r.label;
c.fillText(label, x, ry + rh / 2 + 8);
c.fillStyle = i === 0 ? GOLD : TERRA;
var bx = x + 260;
c.beginPath();
c.roundRect(bx, ry + rh / 2 - 13, bw, 26, 6);
c.fill();
c.fillStyle = MUTED; c.font = '400 24px ' + F;
c.fillText(String(r.value), bx + bw + 12, ry + rh / 2 + 8);
});
} else {
// Diverging bars around a zero axis (e.g. playoff deltas that go negative).
var dspan = bmax - bmin, dplot = w - 260 - 60;
var dzero = x + 260 + ((0 - bmin) / dspan) * dplot;
c.strokeStyle = MUTED; c.lineWidth = 1;
c.beginPath(); c.moveTo(dzero, y); c.lineTo(dzero, y + h); c.stroke();
rows.forEach(function (r, i) {
var dry = y + i * rh;
var dbw = Math.max(4, (Math.abs(r.value) / dspan) * dplot);
var dbx = r.value < 0 ? dzero - dbw : dzero;
c.font = '400 26px ' + F; c.fillStyle = '#F9F6F0';
var dlabel = r.label.length > 26 ? r.label.slice(0, 25) + '…' : r.label;
c.fillText(dlabel, x, dry + rh / 2 + 8);
c.fillStyle = r.value < 0 ? TERRA : GOLD;
c.beginPath();
c.roundRect(dbx, dry + rh / 2 - 13, dbw, 26, 6);
c.fill();
c.fillStyle = MUTED; c.font = '400 24px ' + F;
c.fillText(String(r.value), (r.value < 0 ? dzero : dbx + dbw) + 12, dry + rh / 2 + 8);
});
}
} else if (ins.viz === 'shares') {
var rows2 = ins.rows;
var rh2 = h / rows2.length;
Expand Down
Loading
Loading