Interactive Scouting Report Template
Code
getPitchLines = {
const lines = [];
// left penalty box
lines.push({
x1: 0,
x2: 16.5,
y1: pitchHeight / 2 - 11 - 9.15,
y2: pitchHeight / 2 - 11 - 9.15
});
lines.push({
x1: 16.5,
x2: 16.5,
y1: 13.85,
y2: pitchHeight / 2 + 11 + 9.15
});
lines.push({
x1: 0,
x2: 16.5,
y1: pitchHeight / 2 + 11 + 9.15,
y2: pitchHeight / 2 + 11 + 9.15
});
// left six-yard box
lines.push({
x1: 0,
x2: 5.5,
y1: pitchHeight / 2 - 9.15,
y2: pitchHeight / 2 - 9.15
});
lines.push({
x1: 5.5,
x2: 5.5,
y1: pitchHeight / 2 - 9.15,
y2: pitchHeight / 2 + 9.15
});
lines.push({
x1: 0,
x2: 5.5,
y1: pitchHeight / 2 + 9.15,
y2: pitchHeight / 2 + 9.15
});
// right penalty box
lines.push({
x1: pitchWidth - 16.5,
x2: pitchWidth,
y1: pitchHeight / 2 - 11 - 9.15,
y2: pitchHeight / 2 - 11 - 9.15
});
lines.push({
x1: pitchWidth - 16.5,
x2: pitchWidth - 16.5,
y1: pitchHeight / 2 - 11 - 9.15,
y2: pitchHeight / 2 + 11 + 9.15
});
lines.push({
x1: pitchWidth - 16.5,
x2: pitchWidth,
y1: pitchHeight / 2 + 11 + 9.15,
y2: pitchHeight / 2 + 11 + 9.15
});
// right six-yard box
lines.push({
x1: pitchWidth - 5.5,
x2: pitchWidth,
y1: pitchHeight / 2 - 9.15,
y2: pitchHeight / 2 - 9.15
});
lines.push({
x1: pitchWidth - 5.5,
x2: pitchWidth - 5.5,
y1: pitchHeight / 2 - 9.15,
y2: pitchHeight / 2 + 9.15
});
lines.push({
x1: pitchWidth - 5.5,
x2: pitchWidth,
y1: pitchHeight / 2 + 9.15,
y2: pitchHeight / 2 + 9.15
});
// outside borders
lines.push({ x1: 0, x2: pitchWidth, y1: 0, y2: 0 });
lines.push({ x1: 0, x2: pitchWidth, y1: pitchHeight, y2: pitchHeight });
lines.push({ x1: 0, x2: 0, y1: 0, y2: pitchHeight });
lines.push({ x1: pitchWidth, x2: pitchWidth, y1: 0, y2: pitchHeight });
// middle line
lines.push({
x1: pitchWidth / 2,
x2: pitchWidth / 2,
y1: 0,
y2: pitchHeight
});
// left goal
lines.push({
x1: -1.5,
x2: -1.5,
y1: pitchHeight / 2 - 7.32 / 2,
y2: pitchHeight / 2 + 7.32 / 2
});
lines.push({
x1: -1.5,
x2: 0,
y1: pitchHeight / 2 - 7.32 / 2,
y2: pitchHeight / 2 - 7.32 / 2
});
lines.push({
x1: -1.5,
x2: 0,
y1: pitchHeight / 2 + 7.32 / 2,
y2: pitchHeight / 2 + 7.32 / 2
});
// right goal
lines.push({
x1: pitchWidth + 1.5,
x2: pitchWidth + 1.5,
y1: pitchHeight / 2 - 7.32 / 2,
y2: pitchHeight / 2 + 7.32 / 2
});
lines.push({
x1: pitchWidth,
x2: pitchWidth + 1.5,
y1: pitchHeight / 2 - 7.32 / 2,
y2: pitchHeight / 2 - 7.32 / 2
});
lines.push({
x1: pitchWidth,
x2: pitchWidth + 1.5,
y1: pitchHeight / 2 + 7.32 / 2,
y2: pitchHeight / 2 + 7.32 / 2
});
return lines;
}Code
getPitchCircles = {
const circles = [];
// center circle
circles.push({
cx: pitchWidth / 2,
cy: pitchHeight / 2,
r: 9.15,
color: "none"
});
// // left penalty spot
// circles.push({cx: 11, cy: pitchHeight/2, r: 0.3, color: lineColor});
// // right penalty spot
// circles.push({cx: pitchWidth - 11, cy: pitchHeight/2, r: 0.3, color: lineColor});
// // kick-off circle
circles.push({
cx: pitchWidth / 2,
cy: pitchHeight / 2,
r: 0.3,
color: lineColor
});
return circles;
}Code
getArcs = {
const arcs = [];
const penaltyRadius = 9.15;
const cornerRadius = 1;
// left top corner
arcs.push({
arc: {
innerRadius: cornerRadius,
outerRadius: cornerRadius,
startAngle: Math.PI / 2,
endAngle: Math.PI
},
x: 0,
y: 0 // In D3 SVG coordinates, Y=0 is the top
});
// Left Bottom Corner (Quadrant: Top-Right)
// Needs to go from 12 o'clock (0) to 3 o'clock (PI/2)
arcs.push({
arc: {
innerRadius: cornerRadius,
outerRadius: cornerRadius,
startAngle: 0,
endAngle: Math.PI / 2
},
x: 0,
y: pitchHeight
});
// Right Top Corner (Quadrant: Bottom-Left)
// Needs to go from 6 o'clock (PI) to 9 o'clock (3PI/2)
arcs.push({
arc: {
innerRadius: cornerRadius,
outerRadius: cornerRadius,
startAngle: Math.PI,
endAngle: (3 * Math.PI) / 2
},
x: pitchWidth,
y: 0
});
// Right Bottom Corner (Quadrant: Top-Left)
// Needs to go from 9 o'clock (3PI/2) to 12 o'clock (2PI)
arcs.push({
arc: {
innerRadius: cornerRadius,
outerRadius: cornerRadius,
startAngle: (3 * Math.PI) / 2,
endAngle: 2 * Math.PI
},
x: pitchWidth,
y: pitchHeight
});
// left penalty arc
// left penalty arc
arcs.push({
arc: {
innerRadius: penaltyRadius,
outerRadius: penaltyRadius,
startAngle: Math.sin(6.5 / 9.15),
endAngle: Math.PI - Math.sin(6.5 / 9.15)
},
x: 11,
y: pitchHeight / 2
});
// right penalty arc
arcs.push({
arc: {
innerRadius: penaltyRadius,
outerRadius: penaltyRadius,
startAngle: -Math.sin(6.5 / 9.15),
endAngle: -(Math.PI - Math.sin(6.5 / 9.15))
},
x: pitchWidth - 11,
y: pitchHeight / 2
});
return arcs;
return arcs;
}Code
mojibakeMap = ({
"√©": "é", "√®": "è", "√™": "ê", "√´": "ë",
"√°": "á", "√†": "à", "√¢": "â", "√§": "ä", "√£": "ã", "√•": "å",
"√≠": "í", "√¨": "ì", "√Æ": "î", "√Ø": "ï",
"√≥": "ó", "√≤": "ò", "√¥": "ô", "√∂": "ö", "√µ": "õ",
"√∫": "ú", "√π": "ù", "√ª": "û", "√º": "ü",
"√±": "ñ", "√ß": "ç", "√ø": "ÿ"
})Code
Code
Code
Code
getplayerURL = (Player_Name, teamShortName) => {
// Overrides for ambiguous name+team combinations.
// Key: "Player|TeamShortName" → filename to use (without .png extension).
// These map to specific images on the cutouts repo for disambiguation.
const OVERRIDES = {
"Nico González|Atlético": "Nico González - Atlético",
"Nico González|Man City": "Nico González - Man City",
"Otávio|Estrela": "Otávio - Estrela",
"Otávio|Paris FC": "Otávio - Paris FC",
"Pedro Santos|Arouca": "Pedro Santos - Arouca",
"Pedro Santos|Famalicão": "Pedro Santos - Famalicão",
};
const key = `${Player_Name}|${teamShortName ?? ""}`;
const filename = OVERRIDES[key] ?? Player_Name;
return `https://juliocosta555.github.io/cutouts/${encodeURIComponent(fixMojibake(filename).normalize("NFD"))}.png`;
}Code
Code
Code
Code
Code
xT_grid = [
[0.00638303, 0.00779616, 0.00844854, 0.00977659, 0.01126267, 0.01248344, 0.01473596, 0.01745060, 0.02122129, 0.02756312, 0.03485072, 0.03792590],
[0.00750072, 0.00878589, 0.00942382, 0.01059490, 0.01214719, 0.01384540, 0.01611813, 0.01870347, 0.02401521, 0.02953272, 0.04066992, 0.04647721],
[0.00887990, 0.00977745, 0.01001304, 0.01110462, 0.01269174, 0.01429128, 0.01685596, 0.01935132, 0.02412240, 0.02855202, 0.05491138, 0.06442595],
[0.00941056, 0.01082722, 0.01016549, 0.01132376, 0.01262646, 0.01484598, 0.01689528, 0.01997070, 0.02385149, 0.03511326, 0.10805102, 0.25745362],
[0.00941056, 0.01082722, 0.01016549, 0.01132376, 0.01262646, 0.01484598, 0.01689528, 0.01997070, 0.02385149, 0.03511326, 0.10805102, 0.25745362],
[0.00887990, 0.00977745, 0.01001304, 0.01110462, 0.01269174, 0.01429128, 0.01685596, 0.01935132, 0.02412240, 0.02855202, 0.05491138, 0.06442595],
[0.00750072, 0.00878589, 0.00942382, 0.01059490, 0.01214719, 0.01384540, 0.01611813, 0.01870347, 0.02401521, 0.02953272, 0.04066992, 0.04647721],
[0.00638303, 0.00779616, 0.00844854, 0.00977659, 0.01126267, 0.01248344, 0.01473596, 0.01745060, 0.02122129, 0.02756312, 0.03485072, 0.03792590]
]Code
addXT = (events) => {
if (!Array.isArray(events)) {
throw new Error("addXT: input must be an array");
}
const assignBin = (a, b) => {
let col, row;
if (a % 8.33 === 0) {
col = Math.floor(a / 8.33);
} else {
col = Math.floor(a / 8.33) + 1;
if (col > 12) col = 12;
}
if (b % 12.5 === 0) {
row = Math.floor(b / 12.5);
} else {
row = Math.floor(b / 12.5) + 1;
if (row > 8) row = 8;
}
const r = Math.max(0, Math.min(7, row - 1));
const c = Math.max(0, Math.min(11, col - 1));
return [r, c];
};
const lookupXT = (x, y) => {
if (x == null || y == null || isNaN(+x) || isNaN(+y)) return null;
const [r, c] = assignBin(+x, +y);
return xT_grid[r][c];
};
return events.map(e => ({
...e,
xTStart: lookupXT(e.x, e.y),
xTEnd: lookupXT(e.EndX, e.EndY)
}));
}Code
db = {
const client = await DuckDBClient.of({})
const url = await FileAttachment("scouting/data_scouting/players.parquet").url()
await client.query(`CREATE TABLE players_raw AS SELECT * FROM read_parquet('${url}')`)
// Build a renamed view
const cols = await client.query(`DESCRIBE players_raw`)
const renames = cols.map(c => {
const clean = c.column_name.trim().replace(/\s+/g, "_")
return `players_raw."${c.column_name}" AS "${clean}"`
}).join(", ")
await client.query(`CREATE VIEW players AS SELECT ${renames} FROM players_raw`)
return client
}Code
all_minutes = db2.query(`
WITH latest_info AS (
SELECT
"playerId",
"teamName",
"teamShortName",
"newestLeague",
ROW_NUMBER() OVER (
PARTITION BY "playerId"
ORDER BY STRPTIME("Date", '%d/%m/%Y') DESC
) AS rn
FROM minutes
)
SELECT
m."Player",
m."playerFullName",
li."newestLeague",
li."teamName",
li."teamShortName",
m."pos",
SUM(m."Min") AS "Min"
FROM minutes m
LEFT JOIN latest_info li
ON m."playerId" = li."playerId"
AND li.rn = 1
GROUP BY
m."Player",
m."playerFullName",
li."newestLeague",
li."teamName",
li."teamShortName",
m."pos"
`)Code
// all_data = db.query(`SELECT * FROM players`)
all_data = db.query(`
SELECT
"playerId",
"Player",
"playerFullName",
"teamName",
"teamShortName",
"pos",
"Min",
"Age",
"newestLeague",
"LgBall",
"LgBallCp",
"PsAttBackLinkUp",
"SucPsAttBackLinkUp",
"SucPsAttForwardLinkUp",
"PsAttForwardLinkUp",
"PsRecInCentralSqr",
"Suc_Forward_Pass_High_Build",
"Forward_Pass_High_Build",
"Suc_Forward_PassLow_Build",
"Forward_PassLow_Build",
"High_Build_Passes",
"Low_Build_Up_Passes",
"ProgCarry",
"Carry10+",
"ProgPass",
"Fwdcmp",
"FwdPass",
"PsAttToF3rd",
"PsSucToF3rd",
"PsOppHalf",
"PsOpHfScs",
"G+A",
"Shot",
"GoalCrs",
"ShotCrs",
"ExpGCrs",
"ShotDistAg",
"TouchOpBox",
"SOTInBox",
"SOTOBox",
"PensWon",
"SucCrossOP",
"OpenPlayCross",
"Open_Play_Passes_Into_Box",
"SucOpen_Play_Passes_Into_Box",
"Open_Play_Assists",
"xAOpenPlay",
"SetPlayAssists",
"SucPass_Into_Box_Set_Play",
"Pass_Into_Box_Set_Play",
"xASetPlay",
"ChanceSetPlay",
"sucCrossSP",
"CrossSP",
"SetPlayxG",
"GoalSetPly",
"ShtStPly",
"OpenPlayChance",
"OpenPlayxG",
"OpnPlyGl",
"Disposs",
"PossEndsInGoal",
"CarryToPenArea",
"Success1v1",
"TakeON",
"Suc_Ground_Duels_Open_Play",
"Ground_Duels_Open_Play",
"Suc_Aerial_Duels_Open_Play",
"Aerial_Duels_Open_Play",
"Suc_Tackles_In_OppoHalf",
"Tackles_In_OppoHalf",
"Suc_Tackles_OwnHalf",
"Tackles_OwnHalf",
"Suc_Ground_DuelsOppoHalf",
"Ground_DuelsOppoHalf",
"Suc_Ground_Duels_OwnHalf",
"Ground_Duels_OwnHalf",
"Suc_Aerial_DuelsOppoHalf",
"Aerial_DuelsOppoHalf",
"Suc_Aerial_Duels_OwnHalf",
"Aerial_Duels_OwnHalf",
"GroundDuels_OwnBox",
"SucGroundDuels_OwnBox",
"AerialDuels_OwnBox",
"SucAerialDuels_OwnBox",
"HighTurnoversOP"
FROM players
`)Code
posdef = [
{
pos: "Left Centre Back",
posabv: "LCB",
Pos_group: "Centre Back",
x: 18,
y: 70
},
{
pos: "Right Centre Back",
posabv: "RCB",
Pos_group: "Centre Back",
x: 18,
y: 30
},
{
pos: "Right Back",
posabv: "RB",
Pos_group: "Full Back",
x: 23,
y: 15
},
{
pos: "Defensive Midfielder",
posabv: "CDM",
Pos_group: "Defensive Midfielder",
x: 35,
y: 50
},
{
pos: "Centre Attacking Midfielder",
posabv: "CAM",
Pos_group: "Attacking Midfielder",
x: 70,
y: 50
},
{
pos: "Central Midfielder",
posabv: "CM",
Pos_group: "Central Midfielder",
x: 50,
y: 50
},
{
pos: "Left Attacking Midfielder",
posabv: "LAM",
Pos_group: "Wide AM",
x: 70,
y: 80
},
{
pos: "Centre Forward",
posabv: "CF",
Pos_group: "Forward",
x: 86,
y: 55
},
{
pos: "Central Defender",
posabv: "CD",
Pos_group: "Centre Back",
x: 18,
y: 50
},
{
pos: "Right Midfielder",
posabv: "RM",
Pos_group: "Wide Midfielder",
x: 50,
y: 15
},
{
pos: "Left Back",
posabv: "LB",
Pos_group: "Full Back",
x: 23,
y: 85
},
{
pos: "Left Midfielder",
posabv: "LM",
Pos_group: "Wide Midfielder",
x: 50,
y: 85
},
{
pos: "Right Attacking Midfielder",
posabv: "RAM",
Pos_group: "Wide AM",
x: 70,
y: 20
},
{
pos: "Left Wing Back",
posabv: "LWB",
Pos_group: "Full Back",
x: 38,
y: 85
},
{
pos: "Right Wing Back",
posabv: "RWB",
Pos_group: "Full Back",
x: 38,
y: 15
},
{
pos: "Second Striker",
posabv: "ST",
Pos_group: "Forward",
x: 88,
y: 35
},
{
pos: "Right Winger",
posabv: "RW",
Pos_group: "Winger",
x: 85,
y: 20
},
{
pos: "Left Winger",
posabv: "LW",
Pos_group: "Winger",
x: 85,
y: 80
}
];Code
player_data = all_data
.filter(d => d.pos !== "Goalkeeper")
.map(d => {
// 1. Find the position definition matching the player's position
const positionDetails = posdef.find(p => p.pos === d.pos);
// 2. Safely parse numbers to prevent NaN errors if fields are missing/strings
const totalShots = Number(d.Shot || 0);
const setPlayShots = Number(d.ShtStPly || 0);
// 3. Return the merged object with the new OpenPlayShots property
return {
...d,
...positionDetails,
OpenPlayShots: totalShots - setPlayShots
};
});Code
matched = {
const stripAccents = (s) =>
(s ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const norm = (s) =>
stripAccents(s).toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ").trim();
const normName = (s) =>
norm(s).replace(/\b[a-z]\b/g, "").replace(/\s+/g, " ").trim();
const nameVariants = (s) => {
if (!s) return [s];
const hyphenated = /(\b[A-Za-zÀ-ÿ]+-[A-Za-zÀ-ÿ]+\b)/.exec(s);
if (!hyphenated) return [s];
const givenName = hyphenated[1];
const surname = s.replace(givenName, "").trim();
if (!surname) return [s];
const reversed = s.startsWith(givenName)
? `${surname} ${givenName}`
: `${givenName} ${surname}`;
return [s, reversed];
};
const jaro = (s1, s2) => {
if (s1 === s2) return 1;
const len1 = s1.length, len2 = s2.length;
if (!len1 || !len2) return 0;
const dist = Math.max(0, Math.floor(Math.max(len1, len2) / 2) - 1);
const m1 = new Array(len1).fill(false);
const m2 = new Array(len2).fill(false);
let matches = 0;
for (let i = 0; i < len1; i++) {
const start = Math.max(0, i - dist);
const end = Math.min(i + dist + 1, len2);
for (let j = start; j < end; j++) {
if (m2[j] || s1[i] !== s2[j]) continue;
m1[i] = true; m2[j] = true; matches++; break;
}
}
if (!matches) return 0;
let t = 0, k = 0;
for (let i = 0; i < len1; i++) {
if (!m1[i]) continue;
while (!m2[k]) k++;
if (s1[i] !== s2[k]) t++;
k++;
}
return (matches / len1 + matches / len2 + (matches - t / 2) / matches) / 3;
};
const jaroWinkler = (a, b) => {
const j = jaro(a, b);
let p = 0;
while (p < 4 && p < a.length && p < b.length && a[p] === b[p]) p++;
return j + p * 0.1 * (1 - j);
};
const tokenize = (s) => new Set(s.split(" ").filter(t => t.length >= 3));
const shareToken = (a, b) => {
const B = tokenize(b);
for (const t of tokenize(a)) if (B.has(t)) return true;
return false;
};
// Transliteration heuristic: matching surname + similar first name
// Catches: "Heorhii Sudakov" ↔ "Georgiy Sudakov" (Ukrainian transliteration)
// "Mykhaylo Mudryk" ↔ "Mikhail Mudryk"
// Rejects: "Matt O'Riley" ↔ "CJ Egan-Riley" (different surnames; "egan riley" vs "riley")
//
// Returns true if: last token matches exactly AND first-token JW >= 0.55
const looksLikeTransliteration = (a, b) => {
const aTokens = a.split(" ").filter(t => t.length >= 2);
const bTokens = b.split(" ").filter(t => t.length >= 2);
if (aTokens.length < 2 || bTokens.length < 2) return false;
// Require exact match of last (surname) token
if (aTokens[aTokens.length - 1] !== bTokens[bTokens.length - 1]) return false;
// First names should share some similarity
const firstJw = jaroWinkler(aTokens[0], bTokens[0]);
return firstJw >= 0.55;
};
const teamBuckets = new Map();
const scoutNormed = [];
for (const s of scoutinfo) {
const teamKey = `${s.optaleague}||${s.Optaname}`;
if (!teamBuckets.has(teamKey)) teamBuckets.set(teamKey, []);
teamBuckets.get(teamKey).push(s);
scoutNormed.push({ s, normed: normName(s.name) });
}
const MANUAL_OVERRIDES = new Map([
["2bgqwwop0ppczzaigcu3j5no4", "1469120"], // Alex Amorim → Amorim (Genoa)
// Add Amad Diallo override here
]);
const scoutById = new Map(scoutinfo.map(s => [String(s.id), s]));
const SAME_TEAM_FUZZY_THRESHOLD = 0.85;
const SAME_TEAM_SHARED_THRESHOLD = 0.78;
const CROSS_TEAM_THRESHOLD = 0.92;
const findBest = (pool, target, fallback) => {
let best = null, bestScore = 0, bestJw = 0, via = null, bestCandidate = null;
for (const item of pool) {
const candidate = item.normed ?? normName(item.s ? item.s.name : item.name);
const s = item.s ?? item;
if (candidate === target || candidate === fallback) {
return { best: s, score: 1, jw: 1, via: "exact", candidate };
}
const jw = Math.max(jaroWinkler(target, candidate), jaroWinkler(fallback, candidate));
const tokenShared =
shareToken(target, candidate) || shareToken(fallback, candidate);
const score = tokenShared ? Math.max(jw, 0.85) : jw;
if (score > bestScore) {
bestScore = score;
bestJw = jw;
best = s;
bestCandidate = candidate;
via = tokenShared && jw < 0.85 ? "shared_token" : "fuzzy";
}
}
return { best, score: bestScore, jw: bestJw, via, candidate: bestCandidate };
};
const findBestAcrossVariants = (pool, targets, fallbacks) => {
let best = { best: null, score: 0, jw: 0, via: null, candidate: null };
for (const t of targets) {
for (const f of fallbacks) {
const r = findBest(pool, t, f);
if (r.via === "exact") return r;
if (r.score > best.score) best = r;
}
}
return best;
};
const results = [];
for (const p of player_data) {
const targetVariants = nameVariants(p.playerFullName).map(normName);
const fallbackVariants = nameVariants(p.Player).map(normName);
const target = targetVariants[0];
const overrideId = MANUAL_OVERRIDES.get(String(p.playerId));
if (overrideId) {
const overrideScout = scoutById.get(overrideId);
if (overrideScout) {
results.push({
...p,
match: overrideScout,
playerScore: 1,
matchType: "manual",
matchSource: "override",
});
continue;
}
}
const teamKey = `${p.newestLeague}||${p.teamShortName}`;
const pool = teamBuckets.get(teamKey) ?? [];
const localPool = pool.map(s => ({ s, normed: normName(s.name) }));
const inTeam = findBestAcrossVariants(localPool, targetVariants, fallbackVariants);
// Same-team accept (transliteration check added)
const sameTeamAccept = inTeam.best && (
inTeam.via === "exact" ||
(inTeam.via === "fuzzy" && inTeam.score >= SAME_TEAM_FUZZY_THRESHOLD) ||
(inTeam.via === "shared_token" && inTeam.jw >= SAME_TEAM_SHARED_THRESHOLD) ||
(inTeam.via === "shared_token" && looksLikeTransliteration(target, inTeam.candidate))
);
if (sameTeamAccept) {
results.push({
...p,
match: inTeam.best,
playerScore: +inTeam.score.toFixed(3),
matchType: inTeam.via,
matchSource: "same_team",
});
continue;
}
const crossPool = scoutNormed.filter(({ s }) =>
`${s.optaleague}||${s.Optaname}` !== teamKey
);
const cross = findBestAcrossVariants(crossPool, targetVariants, fallbackVariants);
const acceptable =
cross.best &&
(cross.via === "exact" || (cross.score >= CROSS_TEAM_THRESHOLD && cross.via === "shared_token"));
if (acceptable) {
const fallback = fallbackVariants[0];
const sameScore = crossPool.filter(({ s, normed }) => {
const jw = Math.max(jaroWinkler(target, normed), jaroWinkler(fallback, normed));
return normed === target || normed === fallback || jw >= cross.score - 0.001;
});
const distinctIds = new Set(sameScore.map(x => x.s.id));
if (distinctIds.size > 1) {
results.push({
...p,
match: null,
playerScore: +cross.score.toFixed(3),
matchType: "ambiguous",
matchSource: "cross_team",
});
continue;
}
results.push({
...p,
match: cross.best,
playerScore: +cross.score.toFixed(3),
matchType: cross.via === "exact" ? "exact_transfer" : "fuzzy_transfer",
matchSource: "cross_team",
});
continue;
}
results.push({
...p,
match: null,
playerScore: cross.score ? +cross.score.toFixed(3) : null,
matchType: pool.length ? "no_player" : "no_match",
matchSource: null,
});
}
return results;
}Code
transferred_candidates = {
// Reuse the same name normalization & Jaro-Winkler from `matched`.
// (If you want this cell to stand alone, copy those helpers in too —
// but in a single Quarto doc, just inline them again for clarity.)
const stripAccents = (s) =>
(s ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const norm = (s) =>
stripAccents(s).toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ").trim();
const normName = (s) =>
norm(s).replace(/\b[a-z]\b/g, "").replace(/\s+/g, " ").trim();
const jaro = (s1, s2) => {
if (s1 === s2) return 1;
const len1 = s1.length, len2 = s2.length;
if (!len1 || !len2) return 0;
const dist = Math.max(0, Math.floor(Math.max(len1, len2) / 2) - 1);
const m1 = new Array(len1).fill(false);
const m2 = new Array(len2).fill(false);
let matches = 0;
for (let i = 0; i < len1; i++) {
const start = Math.max(0, i - dist);
const end = Math.min(i + dist + 1, len2);
for (let j = start; j < end; j++) {
if (m2[j] || s1[i] !== s2[j]) continue;
m1[i] = true; m2[j] = true; matches++; break;
}
}
if (!matches) return 0;
let t = 0, k = 0;
for (let i = 0; i < len1; i++) {
if (!m1[i]) continue;
while (!m2[k]) k++;
if (s1[i] !== s2[k]) t++;
k++;
}
return (matches / len1 + matches / len2 + (matches - t / 2) / matches) / 3;
};
const jaroWinkler = (a, b) => {
const j = jaro(a, b);
let p = 0;
while (p < 4 && p < a.length && p < b.length && a[p] === b[p]) p++;
return j + p * 0.1 * (1 - j);
};
// Threshold for cross-team name matches — set higher than the in-team
// threshold because we have no league/team context to disambiguate.
const CROSS_THRESHOLD = 0.88;
// How many candidate suggestions to keep per unmatched player.
const TOP_N = 3;
// Pre-normalize scoutinfo names once
const scoutNormed = scoutinfo.map(s => ({ s, normed: normName(s.name) }));
// Unmatched rows from the previous cell (both no_player and no_team)
const unmatched = matched.filter(
m => m.matchType === "no_player" || m.matchType === "no_team"
);
const results = [];
for (const u of unmatched) {
const target = normName(u.playerFullName);
const fallback = normName(u.Player);
const scored = scoutNormed.map(({ s, normed }) => ({
candidate: s,
score: Math.max(
jaroWinkler(target, normed),
jaroWinkler(fallback, normed),
),
}));
const top = scored
.filter(x => x.score >= CROSS_THRESHOLD)
.sort((a, b) => b.score - a.score)
.slice(0, TOP_N);
results.push({
Player: u.Player,
playerFullName: u.playerFullName,
expected_league: u.newestLeague,
expected_team: u.teamShortName,
original_matchType: u.matchType,
suggestions: top.map(t => ({
name: t.candidate.name,
league: t.candidate.optaleague,
team: t.candidate.Optaname,
score: +t.score.toFixed(3),
scoutinfo: t.candidate, // full row in case you want to merge it back
})),
});
}
return results;
}Code
summary = ({
total: matched.length,
exact: matched.filter(m => m.matchType === "exact").length,
shared_token: matched.filter(m => m.matchType === "shared_token").length,
fuzzy: matched.filter(m => m.matchType === "fuzzy").length,
exact_transfer: matched.filter(m => m.matchType === "exact_transfer").length,
fuzzy_transfer: matched.filter(m => m.matchType === "fuzzy_transfer").length,
ambiguous: matched.filter(m => m.matchType === "ambiguous").length,
no_player: matched.filter(m => m.matchType === "no_player").length,
no_match: matched.filter(m => m.matchType === "no_match").length,
})
// Eyeball these — fuzzy & transfer matches are where mistakes hide
fuzzy_matches = matched
.filter(m => m.matchType === "fuzzy")
.map(m => ({ player: m.Player, full: m.playerFullName, matched: m.match?.name, score: m.playerScore }))
.sort((a, b) => a.score - b.score)
shared_token_matches = matched
.filter(m => m.matchType === "shared_token")
.map(m => ({ player: m.Player, full: m.playerFullName, matched: m.match?.name, team: m.teamShortName }))
transfer_matches = matched
.filter(m => m.matchType === "exact_transfer" || m.matchType === "fuzzy_transfer")
.map(m => ({
player: m.playerFullName,
player_data_team: `${m.teamShortName} (${m.newestLeague})`,
scoutinfo_team: `${m.match?.Optaname} (${m.match?.optaleague})`,
score: m.playerScore,
type: m.matchType,
}))
unmatched = matched
.filter(m => m.matchType === "no_player" || m.matchType === "no_match" || m.matchType === "ambiguous")
.map(m => ({ player: m.Player, full: m.playerFullName, team: m.teamShortName, league: m.newestLeague, reason: m.matchType }))Code
transfer_summary = ({
unmatched_total: transferred_candidates.length,
with_suggestions: transferred_candidates.filter(r => r.suggestions.length).length,
still_missing: transferred_candidates.filter(r => !r.suggestions.length).length,
})
likely_transfers = transferred_candidates
.filter(r => r.suggestions.length)
.map(r => {
const top = r.suggestions[0];
const rest = r.suggestions.slice(1);
const ambiguous = rest.some(
s => s.team === top.team && s.league === top.league
);
return {
player: r.playerFullName ?? r.Player,
was_at: `${r.expected_team} (${r.expected_league})`,
now_at: ambiguous ? "unknown" : `${top.team} (${top.league})`,
score: ambiguous ? null : top.score,
ambiguous,
alt_suggestions: rest,
};
})
.sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
// No suggestion found → assume the player stayed put at was_at
still_missing = transferred_candidates
.filter(r => !r.suggestions.length)
.map(r => ({
player: r.playerFullName ?? r.Player,
was_at: `${r.expected_team} (${r.expected_league})`,
now_at: `${r.expected_team} (${r.expected_league})`,
resolved_team: r.expected_team,
resolved_league: r.expected_league,
status: "stayed",
}))Code
final_playerdata = {
// Flatten scoutinfo fields under scout_* prefix; never overwrite player_data's
// team/league. If no match, all scout_* fields are null.
const flattenMatch = (s) => s ? ({
scout_league: s.league,
scout_optaleague: s.optaleague,
scout_id: s.id,
scout_name: s.name,
scout_teamId: s.teamId,
scout_teamName: s.teamName,
scout_Optaname: s.Optaname,
scout_country: s.country,
scout_shirtNumber: s.shirtNumber,
}) : {
scout_league: null, scout_optaleague: null, scout_id: null,
scout_name: null, scout_teamId: null, scout_teamName: null,
scout_Optaname: null, scout_country: null, scout_shirtNumber: null,
};
return matched.map(m => {
const { match, ...rest } = m;
return {
...rest, // player_data fields, untouched
...flattenMatch(match), // scoutinfo fields, namespaced
playerScore: m.playerScore,
matchType: m.matchType,
matchSource: m.matchSource,
};
});
}Code
Code
Code
xt_player_latest = {
// 1. Collapse all_minutes to one row per Player (keep latest team only)
const byPlayer = new Map();
for (const m of all_minutes) {
if (byPlayer.has(m.Player)) continue; // first occurrence wins
byPlayer.set(m.Player, {
Player: m.Player,
playerFullName: m.playerFullName,
newestLeague: m.newestLeague,
teamName: m.teamName,
teamShortName: m.teamShortName,
});
}
// 2. Sum xT across all teams a player appears at in xt_player_data
const totals = new Map();
for (const r of xt_player_data) {
const t = totals.get(r.player) ?? {
total_xt: 0, pass_xt: 0, takeon_xt: 0, openplay_xt: 0, setplay_xt: 0,
};
t.total_xt += Number(r.total_xt) || 0;
t.pass_xt += Number(r.pass_xt) || 0;
t.takeon_xt += Number(r.takeon_xt) || 0;
t.openplay_xt += Number(r.openplay_xt) || 0;
t.setplay_xt += Number(r.setplay_xt) || 0;
totals.set(r.player, t);
}
// 3. Build final rows
return [...byPlayer.values()].map(m => {
const t = totals.get(m.Player);
return {
Player: m.Player,
playerFullName: m.playerFullName,
newestLeague: m.newestLeague,
teamName: m.teamName,
teamShortName: m.teamShortName,
total_xt: t?.total_xt ?? null,
pass_xt: t?.pass_xt ?? null,
takeon_xt: t?.takeon_xt ?? null,
openplay_xt: t?.openplay_xt ?? null,
setplay_xt: t?.setplay_xt ?? null,
};
});
}Code
cleaned_playerdata = {
const xtIndex = new Map();
for (const r of xt_player_latest) {
const key = `${r.playerFullName}||${r.teamName}`;
xtIndex.set(key, r);
}
// ── Build a current-team lookup from players_posinfo ──────────
// players_posinfo has one row per playerFullName+position, but all rows
// for the same player share the same (teamName, teamShortName, newestLeague).
// We only need the first occurrence per player.
const currentTeamByPlayer = new Map();
for (const r of players_posinfo) {
if (!currentTeamByPlayer.has(r.playerFullName)) {
currentTeamByPlayer.set(r.playerFullName, {
teamName: r.teamName,
teamShortName: r.teamShortName,
newestLeague: r.newestLeague,
});
}
}
// Map display league name → scoutinfo league code (for keeping scout_league consistent)
const LEAGUE_NAME_TO_CODE = {
"Premier League": "premier-league",
"Primera División": "laliga",
"Serie A": "serie-a",
"Ligue 1": "ligue-1",
"Bundesliga": "bundesliga",
"Eredivisie": "eredivisie",
"Primeira Liga": "liga-portugal",
"First Division A": "first-division",
};
// Step 1: Enrich every row with xT + recomputed 90s + scout team override
const enriched = final_playerdata.map(p => {
const key = `${p.playerFullName}||${p.teamName}`;
const xt = xtIndex.get(key);
const min = Number(p.Min) || 0;
// Current-club override: if players_posinfo says the current team differs
// from the scout team (e.g., player transferred since scoutinfo scrape),
// patch the scout team fields with the current club.
const current = currentTeamByPlayer.get(p.playerFullName);
let scoutTeamPatch = {};
if (current && p.scout_Optaname && current.teamShortName !== p.scout_Optaname) {
scoutTeamPatch = {
scout_teamName: current.teamName,
scout_Optaname: current.teamShortName,
scout_optaleague: current.newestLeague,
scout_league: LEAGUE_NAME_TO_CODE[current.newestLeague] ?? p.scout_league,
};
}
return {
...p,
...scoutTeamPatch,
total_xt: xt?.total_xt ?? null,
pass_xt: xt?.pass_xt ?? null,
takeon_xt: xt?.takeon_xt ?? null,
openplay_xt: xt?.openplay_xt ?? null,
setplay_xt: xt?.setplay_xt ?? null,
"90s": min > 0 ? +(min / 90).toFixed(1) : null,
};
});
// Step 2: Classify fields
const METADATA_FIELDS = new Set([
"playerId", "Player", "playerFullName",
"teamName", "teamShortName",
"pos", "posabv", "Pos_group",
"x", "y", "Age",
"newestLeague",
"playerScore", "matchType", "matchSource",
"scout_league", "scout_optaleague", "scout_id", "scout_name",
"scout_teamId", "scout_teamName", "scout_Optaname",
"scout_country", "scout_shirtNumber",
]);
const sampleRow = enriched[0] ?? {};
const SUMMABLE_FIELDS = Object.keys(sampleRow).filter(k => {
if (METADATA_FIELDS.has(k)) return false;
if (k === "90s") return false;
return typeof sampleRow[k] === "number" || sampleRow[k] === null;
});
// Step 3: Group rows by playerId
const byPlayerId = new Map();
for (const row of enriched) {
const id = row.playerId;
if (!id) {
const key = `__nopid_${Math.random()}`;
byPlayerId.set(key, [row]);
continue;
}
if (!byPlayerId.has(id)) byPlayerId.set(id, []);
byPlayerId.get(id).push(row);
}
// Step 4: Merge
const merged = [];
for (const [pid, rows] of byPlayerId) {
if (rows.length === 1) {
merged.push(rows[0]);
continue;
}
// Find the destination club from whichever row was successfully matched.
// Some rows may have null scout_* fields if matching failed for that team.
const matchedRow = rows.find(r => r.scout_Optaname != null);
const destinationOpta = matchedRow?.scout_Optaname ?? null;
// The "destination row" is the one whose teamShortName matches the destination.
// For most cases, this is the row that successfully matched.
const destinationRow = destinationOpta
? rows.find(r => r.teamShortName === destinationOpta)
: null;
// Position metadata comes from destination row if found,
// else from the matched row (preferring matched over unmatched),
// else fall back to biggest-minutes row.
const positionSource = destinationRow
?? matchedRow
?? rows.reduce((best, r) => (Number(r.Min) || 0) > (Number(best.Min) || 0) ? r : best);
// Sum all summable fields
const sums = {};
for (const field of SUMMABLE_FIELDS) {
let total = 0;
let hasValue = false;
for (const r of rows) {
const v = Number(r[field]);
if (Number.isFinite(v)) {
total += v;
hasValue = true;
}
}
sums[field] = hasValue ? total : null;
}
// Build merged row: positionSource's metadata + summed numerics
const mergedRow = {
...positionSource,
...sums,
teamName: positionSource.scout_teamName ?? positionSource.teamName,
teamShortName: positionSource.scout_Optaname ?? positionSource.teamShortName,
};
const totalMin = Number(mergedRow.Min) || 0;
mergedRow["90s"] = totalMin > 0 ? +(totalMin / 90).toFixed(1) : null;
merged.push(mergedRow);
}
// Step 5: Column ordering
const ORDERED_COLS = [
"playerId", "Player", "playerFullName",
"teamName", "teamShortName",
"pos", "posabv", "Pos_group",
"x", "y", "Min", "90s", "Age",
"newestLeague",
"playerScore", "matchType", "matchSource",
"scout_league", "scout_optaleague", "scout_id", "scout_name",
"scout_teamId", "scout_teamName", "scout_Optaname",
"scout_country", "scout_shirtNumber",
];
return merged.map(row => {
const ordered = {};
for (const col of ORDERED_COLS) ordered[col] = row[col];
for (const [k, v] of Object.entries(row)) {
if (!(k in ordered)) ordered[k] = v;
}
return ordered;
});
}Code
players_posinfo = all_minutes.map(d => {
const positionDetails = posdef.find(p => p.pos === d.pos);
// Unwrap Arrow / typed-array Min values into a plain number
let min = d.Min;
if (min != null && typeof min !== "number") {
if (typeof min[Symbol.iterator] === "function") {
// Iterable (typed array, Arrow vector) — take first element
min = [...min][0];
} else if (typeof min.get === "function") {
// Arrow-style accessor
min = min.get(0);
} else if (min.toArray) {
min = min.toArray()[0];
}
}
min = Number(min) || 0;
return {
...d,
...positionDetails,
Min: min,
};
});Code
processdata = {
// Find the column range to per-90 normalize by name in the first row.
// Anything between LgBall and setplay_xt (inclusive) gets divided by 90s.
const FROM_COL = "LgBall";
const TO_COL = "setplay_xt";
const allCols = Object.keys(cleaned_playerdata[0]);
const fromIdx = allCols.indexOf(FROM_COL);
const toIdx = allCols.indexOf(TO_COL);
if (fromIdx === -1 || toIdx === -1) {
throw new Error(`Could not find ${FROM_COL} or ${TO_COL} in cleaned_playerdata columns`);
}
const perNineCols = new Set(allCols.slice(fromIdx, toIdx + 1));
return cleaned_playerdata.map(p => {
const nineties = Number(p["90s"]) || 0;
const out = { ...p };
// 1. Per-90 normalization across the LgBall → setplay_xt range
if (nineties > 0) {
for (const col of perNineCols) {
const v = Number(p[col]);
if (Number.isFinite(v)) {
out[col] = +(v / nineties).toFixed(3);
}
}
} else {
// No minutes played → all per-90 columns are null
for (const col of perNineCols) out[col] = null;
}
// 2. Performance vs expected (raw values, NOT per-90)
// A negative value means the player underperformed their xG.
const openPlayGoals = Number(p.OpnPlyGl) || 0;
const openPlayXG = Number(p.OpenPlayxG) || 0;
const setPlayGoals = Number(p.GoalSetPly) || 0;
const setPlayXG = Number(p.SetPlayxG) || 0;
const crossGoals = Number(p.GoalCrs) || 0;
const crossXG = Number(p.ExpGCrs) || 0;
out.Openplay_performance = +(openPlayGoals - openPlayXG).toFixed(3);
out.Setplay_performance = +(setPlayGoals - setPlayXG).toFixed(3);
out.Crossplay_performance = +(crossGoals - crossXG).toFixed(3);
return out;
});
}Code
viewof age_min = Inputs.range(
[16, 45],
{ value: 16, step: 1, label: "Min age" }
)
viewof age_max = Inputs.range(
[16, 45],
{ value: 29, step: 1, label: "Max age" }
)
viewof minutes_min = Inputs.range(
[0, 4000],
{ value: 1000, step: 100, label: "Min minutes" }
)
viewof minutes_max = Inputs.range(
[0, 4000],
{ value: 4000, step: 100, label: "Max minutes" }
)
viewof leagues_include = Inputs.checkbox(
[...new Set(processdata.map(d => d.newestLeague).filter(Boolean))].sort(),
{
label: "Leagues (filter who's in the pool)",
value: [...new Set(processdata.map(d => d.newestLeague).filter(Boolean))],
}
)
// viewof teams_exclude = Inputs.select(
// [...new Set(processdata.map(d => d.teamShortName).filter(Boolean))].sort(),
// { label: "Teams to exclude", value: [], multiple: true }
// )
viewof benchmark_mode = Inputs.radio(
["all_leagues", "own_league", "vs_target_league"],
{
label: "Benchmark",
value: "all_leagues",
format: m => ({
all_leagues: "vs all leagues",
own_league: "vs own league",
vs_target_league: "vs target league →",
}[m]),
}
)
viewof target_league = Inputs.select(
[...new Set(processdata.map(d => d.newestLeague).filter(Boolean))].sort(),
{
label: "Target league (used when benchmark = vs target league)",
value: "Premier League",
}
)Code
ALL_TEAMS = [...new Set(processdata.map(d => d.teamShortName).filter(Boolean))].sort()
viewof teams_exclude = {
let selected = new Set();
const root = html`<div style="font-family: system-ui, sans-serif; font-size: 13px; display: inline-block; position: relative;">
<details style="border: 1px solid #ccc; border-radius: 4px; background: white; min-width: 280px;">
<summary style="cursor: pointer; padding: 6px 12px; user-select: none; list-style: none; display: flex; justify-content: space-between; align-items: center;">
<span class="summary-text">Teams to exclude (0/${ALL_TEAMS.length})</span>
<span style="color: #888; margin-left: 8px;">▾</span>
</summary>
<div style="border-top: 1px solid #eee;">
<div style="padding: 6px 12px; border-bottom: 1px solid #eee; display: flex; gap: 8px; align-items: center; background: white;">
<a href="#" class="select-all" style="color: #06b; text-decoration: none; font-size: 12px;">All</a>
<a href="#" class="select-none" style="color: #06b; text-decoration: none; font-size: 12px;">None</a>
<button class="apply-btn" style="margin-left: auto; padding: 4px 14px; border: 1px solid #06b; background: #06b; color: white; border-radius: 3px; font-size: 12px; cursor: pointer; font-weight: 600;">Apply</button>
<input type="text" class="filter-input" placeholder="Filter…"
style="padding: 3px 8px; border: 1px solid #ddd; border-radius: 3px; font-size: 12px; width: 100px;">
</div>
<div class="team-list" style="max-height: 280px; overflow-y: auto;">
${ALL_TEAMS.map(team => html`
<label data-team="${team}" data-search="${team.toLowerCase()}" style="display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer;"
onmouseover="this.style.background='#f4f4f4'"
onmouseout="this.style.background='white'">
<input type="checkbox" data-team="${team}" style="cursor: pointer;">
<span>${team}</span>
</label>
`)}
</div>
</div>
</details>
</div>`;
const summaryText = root.querySelector(".summary-text");
const checkboxes = [...root.querySelectorAll('input[type="checkbox"][data-team]')];
const labels = [...root.querySelectorAll('label[data-team]')];
const filterInput = root.querySelector(".filter-input");
const applyBtn = root.querySelector(".apply-btn");
const detailsEl = root.querySelector("details");
const refreshSummary = () => {
const count = checkboxes.filter(c => c.checked).length;
summaryText.textContent = `Teams to exclude (${count}/${ALL_TEAMS.length})`;
};
const commit = () => {
selected = new Set(checkboxes.filter(c => c.checked).map(c => c.dataset.team));
refreshSummary();
root.value = [...selected];
root.dispatchEvent(new Event("input", { bubbles: true }));
};
// ── Filter input: only listen for input event, stop bubble ──
filterInput.addEventListener("input", e => {
e.stopPropagation();
const q = e.target.value.trim().toLowerCase();
labels.forEach(l => {
l.style.display = l.dataset.search.includes(q) ? "flex" : "none";
});
});
// Stop keyboard events from reaching outer listeners (without preventing the input event itself)
filterInput.addEventListener("keydown", e => e.stopPropagation());
filterInput.addEventListener("keyup", e => e.stopPropagation());
// Stop clicking on the input from collapsing details
filterInput.addEventListener("click", e => e.stopPropagation());
// ── Checkbox: ONLY update summary; don't commit; don't bubble ──
checkboxes.forEach(c => {
c.addEventListener("change", e => {
e.stopPropagation();
refreshSummary();
});
// Stop click event from bubbling so nothing else interferes
c.addEventListener("click", e => e.stopPropagation());
});
// ── Labels: clicking a label triggers the checkbox; stop bubble ──
labels.forEach(l => {
l.addEventListener("click", e => e.stopPropagation());
});
// ── Apply button: commit, don't close ──
applyBtn.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
commit();
});
// ── Select All / None: update summary; don't commit ──
root.querySelector(".select-all").addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
checkboxes.forEach(c => {
const label = c.closest("label");
if (label && label.style.display !== "none") c.checked = true;
});
refreshSummary();
});
root.querySelector(".select-none").addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
checkboxes.forEach(c => c.checked = false);
refreshSummary();
});
// ── Closing the dropdown commits ──
detailsEl.addEventListener("toggle", function(e) {
if (!this.open) commit();
});
root.value = [];
return root;
}Code
processdata_z = {
const groups = {
Build: [
"LgBall", "LgBallCp",
"Suc_Forward_Pass_High_Build", "Forward_Pass_High_Build",
"Suc_Forward_PassLow_Build", "Forward_PassLow_Build",
"High_Build_Passes", "Low_Build_Up_Passes",
],
Linkup: [
"PsAttBackLinkUp", "SucPsAttBackLinkUp",
"SucPsAttForwardLinkUp", "PsAttForwardLinkUp",
"PsRecInCentralSqr",
],
Progression: [
"ProgCarry", "Carry10", "ProgPass",
"PsAttToF3rd", "PsSucToF3rd",
"PsOppHalf", "PsOpHfScs",
"FwdPass", "Fwdcmp",
],
Openplay_creation: [
"OpenPlayCross", "SucCrossOP",
"Open_Play_Passes_Into_Box", "SucOpen_Play_Passes_Into_Box",
"Open_Play_Assists", "OpenPlayChance",
"xAOpenPlay", "openplay_xt",
],
Setplay_creation: [
"SetPlayAssists",
"SucPass_Into_Box_Set_Play", "Pass_Into_Box_Set_Play",
"xASetPlay", "ChanceSetPlay",
"sucCrossSP", "CrossSP", "setplay_xt",
],
Threat: [
"takeon_xt", "PossEndsInGoal", "CarryToPenArea",
"TakeOn", "Success1v1", "PensWon", "TouchOpBox", "openplay_xt", "Disposs",
],
Openplay_finishing: [
"OpnPlyGl", "OpenPlayxG", "OpenPlayShots", "Openplay_performance",
],
Setplay_finishing: [
"SetPlayxG", "GoalSetPly", "ShtStPly", "Setplay_performance",
],
Finishing_crosses: [
"ExpGCrs", "ShotCrs", "GoalCrs", "Crossplay_performance",
],
Openplay_defending: [
"Suc_Ground_Duels_Open_Play", "Ground_Duels_Open_Play",
"Suc_Aerial_Duels_Open_Play", "Aerial_Duels_Open_Play",
],
Defending_oppohalf: [
"Suc_Tackles_In_OppoHalf", "Tackles_In_OppoHalf",
"Suc_Aerial_DuelsOppoHalf", "Aerial_DuelsOppoHalf",
"Suc_Ground_DuelsOppoHalf", "Ground_DuelsOppoHalf",
"HighTurnoversOP",
],
Defending_ownhalf: [
"Suc_Tackles_OwnHalf", "Tackles_OwnHalf",
"Suc_Ground_Duels_OwnHalf", "Ground_Duels_OwnHalf",
"Aerial_Duels_OwnHalf", "Suc_Aerial_Duels_OwnHalf",
],
Defending_ownbox: [
"GroundDuels_OwnBox", "SucGroundDuels_OwnBox",
"AerialDuels_OwnBox", "SucAerialDuels_OwnBox",
],
};
const NEGATIVE_METRICS = new Set(["Disposs"]);
//const MIN_NINETIES_FOR_CALIBRATION = 8.9;
const allMetrics = [...new Set(Object.values(groups).flat())];
// ── Successful-outcome metrics weighted 1.5× in the group sum ──
// Catches: Suc_*, Suc*, *Cp, *cmp, *Scs, *Success* (case-insensitive).
const SUCCESS_RE = /suc|cp$|cmp$|scs$/i;
const successWeight = (metric) => SUCCESS_RE.test(metric) ? 1.5 : 1.0;
const ageMin = age_min;
const ageMax = age_max;
const allowedLeagues = new Set(leagues_include);
const excludedTeams = new Set(teams_exclude);
const inCalibrationPool = (r) => {
const age = Number(r.Age);
if (Number.isFinite(age) && (age < ageMin || age > ageMax)) return false;
if (r.newestLeague && !allowedLeagues.has(r.newestLeague)) return false;
if (r.teamShortName && excludedTeams.has(r.teamShortName)) return false;
const min = Number(r.Min);
if (!Number.isFinite(min)) return false;
if (min < minutes_min || min > minutes_max) return false;
return true;
};
const calibFor = (rows) => {
const out = {};
for (const m of allMetrics) {
const vals = rows.map(r => Number(r[m])).filter(Number.isFinite);
if (vals.length < 3) { out[m] = { mean: null, std: null }; continue; }
const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
const variance = vals.reduce((s, v) => s + (v - mean) ** 2, 0) / vals.length;
out[m] = { mean, std: Math.sqrt(variance) };
}
return out;
};
let calibratorFor;
if (benchmark_mode === "all_leagues") {
const byPos = new Map();
for (const p of processdata.filter(inCalibrationPool)) {
const k = p.Pos_group ?? "__unknown__";
if (!byPos.has(k)) byPos.set(k, []);
byPos.get(k).push(p);
}
const calibs = new Map();
for (const [k, rows] of byPos) calibs.set(k, calibFor(rows));
calibratorFor = (p) => calibs.get(p.Pos_group ?? "__unknown__") ?? {};
} else if (benchmark_mode === "own_league") {
const byPosLeague = new Map();
for (const p of processdata.filter(inCalibrationPool)) {
const k = `${p.Pos_group ?? "__unknown__"}||${p.newestLeague ?? "__unknown__"}`;
if (!byPosLeague.has(k)) byPosLeague.set(k, []);
byPosLeague.get(k).push(p);
}
const calibs = new Map();
for (const [k, rows] of byPosLeague) calibs.set(k, calibFor(rows));
calibratorFor = (p) => {
const k = `${p.Pos_group ?? "__unknown__"}||${p.newestLeague ?? "__unknown__"}`;
return calibs.get(k) ?? {};
};
} else {
const byPos = new Map();
for (const p of processdata.filter(r =>
inCalibrationPool(r) && r.newestLeague === target_league
)) {
const k = p.Pos_group ?? "__unknown__";
if (!byPos.has(k)) byPos.set(k, []);
byPos.get(k).push(p);
}
const calibs = new Map();
for (const [k, rows] of byPos) calibs.set(k, calibFor(rows));
calibratorFor = (p) => calibs.get(p.Pos_group ?? "__unknown__") ?? {};
}
return processdata.map(p => {
const calib = calibratorFor(p);
const out = {
...p,
in_calibration_pool: inCalibrationPool(p),
benchmark_mode,
benchmark_target: benchmark_mode === "vs_target_league"
? target_league
: (benchmark_mode === "own_league" ? p.newestLeague : "all"),
};
const zVals = {};
for (const m of allMetrics) {
const c = calib[m];
const v = Number(p[m]);
if (!c || c.std == null || c.std === 0 || !Number.isFinite(v)) {
zVals[m] = null;
} else {
zVals[m] = +((v - c.mean) / c.std).toFixed(3);
}
out["z_" + m] = zVals[m];
}
// ── Group means: weighted by success-vs-volume (1.5× for completed/successful) ──
for (const [groupName, metrics] of Object.entries(groups)) {
let weightedSum = 0, totalWeight = 0;
for (const m of metrics) {
const z = zVals[m];
if (z == null) continue;
const w = successWeight(m);
const signedZ = NEGATIVE_METRICS.has(m) ? -z : z;
weightedSum += signedZ * w;
totalWeight += w;
}
out[groupName + "_z"] = totalWeight > 0
? +(weightedSum / totalWeight).toFixed(3)
: null;
}
return out;
});
}Code
Code
radar_eligible = processdata_z
.filter(d => {
if (d.Pos_group !== radar_pos) return false;
const min = Number(d.Min);
if (!Number.isFinite(min)) return false;
if (min < minutes_min || min > minutes_max) return false;
const age = Number(d.Age);
if (Number.isFinite(age) && (age < age_min || age > age_max)) return false;
return true;
})
.map(d => ({
...d,
_sortKey: (d.Progression_z ?? 0) + (d.Openplay_creation_z ?? 0),
_label: `${d.playerFullName} — ${d.teamShortName}`,
}))
.sort((a, b) => b._sortKey - a._sortKey)
viewof radar_player = {
const players = radar_eligible.map((d, i) => ({ ...d, _id: i }));
let selectedId = players[0]?._id;
const initialPlayer = players[0];
const root = html`<div style="font-family: system-ui, sans-serif; font-size: 13px; display: inline-block; position: relative;">
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: #555;">Player</label>
<details style="border: 1px solid #ccc; border-radius: 4px; background: white; min-width: 280px;">
<summary style="cursor: pointer; padding: 6px 12px; user-select: none; list-style: none; display: flex; justify-content: space-between; align-items: center;">
<span class="summary-text">${initialPlayer ? initialPlayer._label : "(no players)"}</span>
<span style="color: #888; margin-left: 8px;">▾</span>
</summary>
<div style="border-top: 1px solid #eee; max-height: 360px; overflow-y: auto;">
<div style="padding: 6px 12px; border-bottom: 1px solid #eee; display: flex; gap: 8px; align-items: center;">
<input type="text" class="filter-input" placeholder="Filter players…"
style="flex: 1; padding: 3px 8px; border: 1px solid #ddd; border-radius: 3px; font-size: 12px;">
<span class="counter" style="color: #888; font-size: 11px; white-space: nowrap;">
${players.length}
</span>
</div>
<div class="player-list">
${players.map(p => html`
<label data-pid="${p._id}"
data-search="${(p.playerFullName + " " + p.teamShortName).toLowerCase()}"
style="display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer; ${p._id === selectedId ? "background: #e8f0fe;" : ""}"
onmouseover="if (this.dataset.pid != ${selectedId}) this.style.background='#f4f4f4'"
onmouseout="if (this.dataset.pid != ${selectedId}) this.style.background='white'">
<input type="radio" name="player-pick" data-pid="${p._id}"
${p._id === selectedId ? "checked" : ""}
style="cursor: pointer;">
<div style="display: flex; flex-direction: column; min-width: 0;">
<span style="font-weight: 500;">${p.playerFullName}</span>
<span style="color: #888; font-size: 11px;">${p.teamShortName}</span>
</div>
</label>
`)}
</div>
</div>
</details>
</div>`;
const summaryText = root.querySelector(".summary-text");
const radios = [...root.querySelectorAll('input[type="radio"][data-pid]')];
const labels = [...root.querySelectorAll('label[data-pid]')];
const filterInput = root.querySelector(".filter-input");
const counter = root.querySelector(".counter");
const findPlayer = (id) => players.find(p => p._id === Number(id));
// Commit + close. Selecting a player IS the commit (single-select model).
const commitAndClose = () => {
const checked = radios.find(r => r.checked);
if (!checked) return;
selectedId = Number(checked.dataset.pid);
const player = findPlayer(selectedId);
labels.forEach(l => {
l.style.background = Number(l.dataset.pid) === selectedId ? "#e8f0fe" : "white";
});
summaryText.textContent = player?._label ?? "(no players)";
root.value = player;
root.dispatchEvent(new Event("input", { bubbles: true }));
// Close the dropdown after selection
root.querySelector("details").open = false;
};
radios.forEach(r => r.addEventListener("change", commitAndClose));
labels.forEach(l => {
l.addEventListener("click", e => {
if (e.target.tagName !== "INPUT") {
const radio = l.querySelector('input[type="radio"]');
if (radio) {
radio.checked = true;
commitAndClose();
}
}
});
});
filterInput.addEventListener("input", e => {
const q = e.target.value.trim().toLowerCase();
let shown = 0;
labels.forEach(l => {
const matches = l.dataset.search.includes(q);
l.style.display = matches ? "flex" : "none";
if (matches) shown++;
});
counter.textContent = `${shown}/${players.length}`;
});
filterInput.addEventListener("click", e => e.stopPropagation());
filterInput.addEventListener("keydown", e => e.stopPropagation());
root.value = initialPlayer;
return root;
}Code
ALL_SLICES = [
{ key: "Build_z", label: "Build play" },
{ key: "Linkup_z", label: "Link Up\nplay" },
{ key: "Progression_z", label: "Progression\nplay" },
{ key: "Openplay_creation_z", label: "Open Play\nCreation" },
{ key: "Setplay_creation_z", label: "Set Play\nCreation" },
{ key: "Threat_z", label: "Threat" },
{ key: "Openplay_finishing_z", label: "Open Play\nFinishing" },
{ key: "Setplay_finishing_z", label: "Set Play\nFinishing" },
{ key: "Finishing_crosses_z", label: "Finishing\nCrosses" },
{ key: "Defending_oppohalf_z", label: "Defending\noppo half" },
{ key: "Defending_ownhalf_z", label: "Defending\nown half" },
{ key: "Defending_ownbox_z", label: "Defending\nbox" },
{ key: "Openplay_defending_z", label: "Open Play\nDefending" },
]
viewof selected_slices = {
let selected = new Set(ALL_SLICES.map(s => s.key));
const root = html`<div style="font-family: system-ui, sans-serif; font-size: 13px; display: inline-block; position: relative;">
<details style="border: 1px solid #ccc; border-radius: 4px; background: white; min-width: 240px;">
<summary style="cursor: pointer; padding: 6px 12px; user-select: none; list-style: none; display: flex; justify-content: space-between; align-items: center;">
<span class="summary-text">Radar slices (${selected.size}/${ALL_SLICES.length})</span>
<span style="color: #888; margin-left: 8px;">▾</span>
</summary>
<div style="border-top: 1px solid #eee; max-height: 280px; overflow-y: auto;">
<div style="padding: 6px 12px; border-bottom: 1px solid #eee; display: flex; gap: 12px;">
<a href="#" class="select-all" style="color: #06b; text-decoration: none; font-size: 12px;">Select all</a>
<a href="#" class="select-none" style="color: #06b; text-decoration: none; font-size: 12px;">Select none</a>
</div>
${ALL_SLICES.map(s => html`
<label style="display: flex; align-items: center; gap: 8px; padding: 6px 12px; cursor: pointer;"
onmouseover="this.style.background='#f4f4f4'"
onmouseout="this.style.background='white'">
<input type="checkbox" data-key="${s.key}" checked style="cursor: pointer;">
<span>${s.label.replace("\n", " ")}</span>
</label>
`)}
</div>
</details>
</div>`;
const summaryText = root.querySelector(".summary-text");
const checkboxes = [...root.querySelectorAll('input[type="checkbox"]')];
const sync = () => {
selected = new Set(
checkboxes.filter(c => c.checked).map(c => c.dataset.key)
);
summaryText.textContent = `Radar slices (${selected.size}/${ALL_SLICES.length})`;
root.value = ALL_SLICES.filter(s => selected.has(s.key));
root.dispatchEvent(new Event("input", { bubbles: true }));
};
checkboxes.forEach(c => c.addEventListener("change", sync));
root.querySelector(".select-all").addEventListener("click", e => {
e.preventDefault();
checkboxes.forEach(c => c.checked = true);
sync();
});
root.querySelector(".select-none").addEventListener("click", e => {
e.preventDefault();
checkboxes.forEach(c => c.checked = false);
sync();
});
// Initialize value
root.value = ALL_SLICES.filter(s => selected.has(s.key));
return root;
}Code
radar_chart = {
// ── Slice definitions ────────────────────────────────────────
const slices = selected_slices.length > 0 ? selected_slices : ALL_SLICES;
// ── Geometry ─────────────────────────────────────────────────
const n = slices.length;
const size = 700;
const cx = size / 2;
const cy = size / 2;
const outerR = size * 0.36;
const labelR = outerR + 55;
// ── Angle helpers ────────────────────────────────────────────
const sliceAngle = (2 * Math.PI) / n;
const startAngle = (i) => -Math.PI / 2 + i * sliceAngle - sliceAngle / 2;
const endAngle = (i) => -Math.PI / 2 + i * sliceAngle + sliceAngle / 2;
const midAngle = (i) => -Math.PI / 2 + i * sliceAngle;
const arcPath = (r, a0, a1) => {
const x0 = cx + r * Math.cos(a0);
const y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1);
const y1 = cy + r * Math.sin(a1);
const largeArc = (a1 - a0) > Math.PI ? 1 : 0;
return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z`;
};
const p = radar_player;
// ── Peer pool (same Pos_group, min 7.5 90s) ──────────────────
const ageMinR = age_min;
const ageMaxR = age_max;
const allowedLeaguesR = new Set(leagues_include);
const excludedTeamsR = new Set(teams_exclude);
const samePos = processdata_z.filter(d => {
if (d.Pos_group !== p.Pos_group) return false;
const min = Number(d.Min);
if (!Number.isFinite(min)) return false;
if (min < minutes_min || min > minutes_max) return false;
const age = Number(d.Age);
if (Number.isFinite(age) && (age < ageMinR || age > ageMaxR)) return false;
if (d.newestLeague && !allowedLeaguesR.has(d.newestLeague)) return false;
if (d.teamShortName && excludedTeamsR.has(d.teamShortName)) return false;
return true;
});
const samePosFinal = benchmark_mode === "vs_target_league"
? samePos.filter(d => d.newestLeague === target_league)
: samePos;
// Top 5 per slice
// Top 5 per slice
const topPerSlice = {};
for (const s of slices) {
topPerSlice[s.key] = samePosFinal // ← was samePos
.filter(d => d[s.key] != null)
.sort((a, b) => b[s.key] - a[s.key])
.slice(0, 10)
.map(d => ({ player: d.playerFullName, team: d.teamShortName, z: d[s.key] }));
}
// ── Dynamic scale: data range across all slices in the peer pool ─
// ── Fixed scale: z in [-5, +5] maps linearly to [0, outerR] ──
// 0 (average) lands at the radar's midpoint; +5 hits the rim.
// ── Per-slice normalization: each slice's z is rescaled so the
// peer-pool max → radial position of +5 (rim) and min → -5 (center).
// A z exactly at the peer-pool midpoint → midpoint of the radar.
const FIXED_Z_MAX = 5; // visual target for the top performer in each slice
const FIXED_Z_MIN = -5; // visual target for the bottom performer
const sliceScales = {};
for (const s of slices) {
const vals = samePosFinal.map(d => d[s.key]).filter(Number.isFinite); // ← was samePos
if (vals.length === 0) {
sliceScales[s.key] = null;
continue;
}
sliceScales[s.key] = {
min: Math.min(...vals),
max: Math.max(...vals),
};
}
// Rescale a raw z within its slice's [min, max] to the fixed visual scale [-5, 5].
const sliceScaledZ = (rawZ, sliceKey) => {
const sc = sliceScales[sliceKey];
if (!sc || rawZ == null || !Number.isFinite(rawZ)) return null;
if (sc.max === sc.min) return 0; // everyone tied → midpoint
const frac = (rawZ - sc.min) / (sc.max - sc.min); // 0 to 1
return FIXED_Z_MIN + frac * (FIXED_Z_MAX - FIXED_Z_MIN); // -5 to +5
};
// Convert the rescaled value to a radius on the radar (fixed visual scale).
const zToR = (z) => {
if (z == null || !Number.isFinite(z)) return outerR / 2;
const clamped = Math.max(FIXED_Z_MIN, Math.min(FIXED_Z_MAX, z));
return ((clamped - FIXED_Z_MIN) / (FIXED_Z_MAX - FIXED_Z_MIN)) * outerR;
};
// ── Team kit colors ──────────────────────────────────────────
const kit = kit_color_map.get(p.teamShortName);
const FILL = kit?.HomeKit || "#8b1f1f";
const STROKE = kit?.HomeKit2 || kit?.HomeKit || "#8b1f1f";
// ── SVG ──────────────────────────────────────────────────────
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${size} ${size + 40}`)
.style("width", "100%")
.style("height", "auto")
.attr("preserveAspectRatio", "xMidYMid meet")
.style("font-family", "system-ui, -apple-system, sans-serif");
// svg.append("text")
// .attr("x", cx).attr("y", 24)
// .attr("text-anchor", "middle")
// .attr("font-size", 16)
// .attr("font-weight", 600)
// .text(`${p.playerFullName} · ${p.teamShortName} · ${p.Pos_group}`);
// Background slices
svg.append("g").selectAll("path")
.data(slices).join("path")
.attr("d", (_, i) => arcPath(outerR, startAngle(i), endAngle(i)))
.attr("fill", "#f4f4f4").attr("stroke", "#ddd").attr("stroke-width", 1);
// Data slices
// Data slices
const dataPaths = svg.append("g").selectAll("path")
.data(slices).join("path")
.attr("d", (s, i) => arcPath(zToR(sliceScaledZ(p[s.key], s.key)), startAngle(i), endAngle(i)))
.attr("fill", FILL).attr("stroke", STROKE).attr("stroke-width", 1.5)
.style("cursor", "pointer");
// Reference rings at min / mid / max of the actual range
for (const z of [-2.5, 0, 2.5]) {
svg.append("circle")
.attr("cx", cx).attr("cy", cy).attr("r", zToR(z))
.attr("fill", "none")
.attr("stroke", z === 0 ? "#e8a33d" : "#aaa")
.attr("stroke-width", z === 0 ? 1.5 : 1)
.attr("stroke-dasharray", "4 4")
.style("pointer-events", "none");
}
// Spokes
svg.append("g").selectAll("line")
.data(slices).join("line")
.attr("x1", cx).attr("y1", cy)
.attr("x2", (_, i) => cx + outerR * Math.cos(startAngle(i)))
.attr("y2", (_, i) => cy + outerR * Math.sin(startAngle(i)))
.attr("stroke", "#999").attr("stroke-width", 0.7)
.style("pointer-events", "none");
// Z labels at slice tips
svg.append("g").selectAll("g")
.data(slices).join("g")
.attr("transform", (s, i) => {
const r = zToR(sliceScaledZ(p[s.key], s.key));
const a = midAngle(i);
return `translate(${cx + r * Math.cos(a)}, ${cy + r * Math.sin(a)})`;
})
.style("pointer-events", "none")
.each(function(s) {
const g = d3.select(this);
const z = p[s.key];
const txt = z == null ? "–" : z.toFixed(2); // ← still show the RAW z value
g.append("rect")
.attr("x", -22).attr("y", -10).attr("width", 44).attr("height", 20)
.attr("rx", 3).attr("fill", FILL)
.attr("stroke", "white").attr("stroke-width", 1);
g.append("text")
.attr("text-anchor", "middle").attr("dy", "0.35em")
.attr("fill", "white").attr("font-size", 11).attr("font-weight", 600)
.text(txt);
});
// Outer labels
const labelGroup = svg.append("g").style("pointer-events", "none");
slices.forEach((s, i) => {
const a = midAngle(i);
const lx = cx + labelR * Math.cos(a);
const ly = cy + labelR * Math.sin(a);
const lines = s.label.split("\n");
const g = labelGroup.append("g").attr("transform", `translate(${lx}, ${ly})`);
lines.forEach((line, li) => {
g.append("text")
.attr("text-anchor", "middle")
.attr("y", (li - (lines.length - 1) / 2) * 14)
.attr("dy", "0.35em").attr("font-size", 16).attr("font-weight", 600)
.text(line);
});
});
// ── Side panel for top-5 ─────────────────────────────────────
const panel = d3.create("div")
.style("min-width", "280px")
.style("padding", "14px 16px")
.style("background", "#fafafa")
.style("border", "1px solid #e0e0e0")
.style("border-radius", "6px")
.style("font-family", "system-ui, sans-serif")
.style("font-size", "13px")
.style("align-self", "flex-start");
const panelTitle = panel.append("div")
.style("font-weight", "600")
.style("font-size", "14px")
.style("margin-bottom", "8px")
.style("color", "#666")
.text("Hover a slice for top 10");
const panelList = panel.append("div");
const renderPanel = (slice) => {
if (!slice) {
panelTitle.text("Hover a slice for top 5").style("color", "#666");
panelList.html("");
return;
}
panelTitle
.text(`${slice.label.replace("\n", " ")} — Top 10`)
.style("color", "#222");
const rows = topPerSlice[slice.key];
if (!rows.length) {
panelList.html(`<div style="color:#999">No data</div>`);
return;
}
panelList.html(rows.map((r, i) => `
<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #eee">
<div><span style="color:#999;width:18px;display:inline-block">${i + 1}.</span> <b>${r.player}</b> <span style="color:#888"> — ${r.team}</span></div>
<div style="color:#444;font-variant-numeric:tabular-nums">${r.z.toFixed(2)}</div>
</div>
`).join(""));
};
dataPaths
.on("mouseenter", function(event, s) {
d3.select(this).attr("fill-opacity", 0.85);
renderPanel(s);
})
.on("mouseleave", function() {
d3.select(this).attr("fill-opacity", 1);
});
// ── Layout ───────────────────────────────────────────────────
const container = d3.create("div")
.style("display", "flex")
.style("gap", "12px")
.style("align-items", "flex-start")
.style("width", "100%")
.style("min-width", "0");
const svgWrapper = d3.create("div")
.style("flex", "2 1 0") // ← SVG side gets 2× the space
.style("min-width", "0");
svgWrapper.append(() => svg.node());
panel
.style("flex", "1 1 0") // ← panel grows/shrinks 1×
.style("min-width", "0")
.style("max-width", "240px");
container.append(() => svgWrapper.node());
container.append(() => panel.node());
return container.node();
}Code
country_to_iso = ({
"Albania":"al", "Algeria":"dz", "Angola":"ao", "Argentina":"ar",
"Armenia":"am", "Australia":"au", "Austria":"at", "Belgium":"be",
"Benin":"bj", "Bolivia":"bo", "Bosnia and Herzegovina":"ba", "Brazil":"br",
"Bulgaria":"bg", "Burkina Faso":"bf", "Cameroon":"cm", "Canada":"ca",
"Cape Verde":"cv", "Chile":"cl", "Colombia":"co", "Comoros":"km",
"Costa Rica":"cr", "Croatia":"hr", "Curacao":"cw", "Czech Republic":"cz",
"Czechia":"cz", "Denmark":"dk", "Dominican Republic":"do", "DR Congo":"cd",
"Ecuador":"ec", "Egypt":"eg", "England":"gb-eng", "Equatorial Guinea":"gq",
"Estonia":"ee", "Faroe Islands":"fo", "Finland":"fi", "France":"fr",
"Gabon":"ga", "Gambia":"gm", "Georgia":"ge", "Germany":"de",
"Ghana":"gh", "Greece":"gr", "Guadeloupe":"gp", "Guinea":"gn",
"Guinea-Bissau":"gw", "Haiti":"ht", "Honduras":"hn", "Hungary":"hu",
"Iceland":"is", "Indonesia":"id", "Iran":"ir", "Iraq":"iq",
"Ireland":"ie", "Israel":"il", "Italy":"it", "Ivory Coast":"ci",
"Côte d'Ivoire":"ci", "Jamaica":"jm", "Japan":"jp", "Jordan":"jo",
"Korea Republic":"kr", "Kosovo":"xk", "Libya":"ly", "Lithuania":"lt",
"Luxembourg":"lu", "Madagascar":"mg", "Malaysia":"my", "Mali":"ml",
"Mauritania":"mr", "Mexico":"mx", "Montenegro":"me", "Morocco":"ma",
"Mozambique":"mz", "Netherlands":"nl", "New Zealand":"nz", "Niger":"ne",
"Nigeria":"ng", "North Macedonia":"mk", "Northern Ireland":"gb-nir",
"Norway":"no", "Panama":"pa", "Paraguay":"py", "Peru":"pe",
"Poland":"pl", "Portugal":"pt", "Republic of Ireland":"ie", "Romania":"ro",
"Russia":"ru", "Rwanda":"rw", "Saudi Arabia":"sa", "Scotland":"gb-sct",
"Senegal":"sn", "Serbia":"rs", "Slovakia":"sk", "Slovenia":"si",
"South Africa":"za", "South Korea":"kr", "Spain":"es", "Suriname":"sr",
"Sweden":"se", "Switzerland":"ch", "Syria":"sy", "Tanzania":"tz",
"Togo":"tg", "Trinidad and Tobago":"tt", "Tunisia":"tn", "Turkey":"tr",
"Türkiye":"tr", "Turkiye":"tr", "Ukraine":"ua", "United States":"us",
"Uruguay":"uy", "USA":"us", "Uzbekistan":"uz", "Venezuela":"ve",
"Wales":"gb-wls", "Zambia":"zm", "Zimbabwe":"zw",
})
getFlagURL = (country) => {
if (!country) return null;
const code = country_to_iso[country];
return code ? `https://flagcdn.com/w40/${code}.png` : null;
}Code
player_info_card = {
const p = radar_player;
const playerName = p.playerFullName ?? p.Player ?? "Unknown player";
const teamShort = p.teamShortName ?? "—";
const team = p.teamName ?? teamShort;
const age = p.Age != null ? Number(p.Age) : null;
const pos = p.Pos_group ?? p.pos ?? "—";
const min = Number(p["Min"] ?? p.Min) || 0;
const league = p.newestLeague ?? "—";
const country = p.scout_country ?? "—";
const shirt = p.scout_shirtNumber ?? "—";
const cutoutPrimary = getplayerURL(playerName, teamShort);
const cutoutFallback = p.scout_name ? getplayerURL(p.scout_name, teamShort) : "";
const teamLogoUrl = getLogoURL(teamShort);
const leagueLogoUrl = getleagueLogoURL(league);
return html`<div style="
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-family: system-ui, -apple-system, sans-serif;
width: 100%;
max-width: 260px;
box-sizing: border-box;
">
<div style="display: flex; align-items: center; gap: 10px; width: 100%; justify-content: center;">
<img src="${teamLogoUrl}" alt="${teamShort}"
style="width: 32px; height: 32px; object-fit: contain; flex-shrink: 0;"
onerror="this.style.visibility='hidden'">
<div style="text-align: center; min-width: 0;">
<div style="font-weight: 700; font-size: 14px;">${playerName}</div>
<div style="font-size: 12px; color: #666; margin-top: 1px;">${team}</div>
</div>
<img src="${leagueLogoUrl}" alt="${league}"
style="width: 28px; height: 28px; object-fit: contain; flex-shrink: 0;"
onerror="this.style.visibility='hidden'">
</div>
<div style="
width: 95px; height: 95px;
border-radius: 50%;
background: linear-gradient(180deg, #f0f0f0, #e0e0e0);
overflow: hidden;
display: flex; align-items: center; justify-content: center;
position: relative;
flex-shrink: 0;
">
<img src="${cutoutPrimary}" alt="${playerName}"
style="width: 100%; height: 100%; object-fit: cover; object-position: center top;"
onerror="
if (this.dataset.fallbackTried !== '1' && '${cutoutFallback}') {
this.dataset.fallbackTried = '1';
this.src = '${cutoutFallback}';
} else {
this.style.display = 'none';
}
">
</div>
<div style="
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 10px;
font-size: 11px;
width: 100%;
">
<div style="color: #888;">Position</div>
<div style="font-weight: 600;">${pos}</div>
<div style="color: #888;">Age</div>
<div style="font-weight: 600;">${age ?? "—"}</div>
<div style="color: #888;">Minutes</div>
<div style="font-weight: 600;">${min.toLocaleString()}</div>
<div style="color: #888;">Country</div>
<div style="font-weight: 600; display: flex; align-items: center; gap: 5px;">
${getFlagURL(country)
? html`<img src="${getFlagURL(country)}" alt="${country}"
style="width: 16px; height: 11px; object-fit: cover; border: 1px solid #ddd;"
onerror="this.style.visibility='hidden'">`
: ""}
<span>${country}</span>
</div>
<div style="color: #888;">Shirt #</div>
<div style="font-weight: 600;">${shirt}</div>
<div style="color: #888;">League</div>
<div style="font-weight: 600;">${league}</div>
</div>
</div>`;
}Code
player_slice_scores = {
const p = radar_player;
const slices = selected_slices.length > 0 ? selected_slices : ALL_SLICES;
const allowedLeaguesS = new Set(leagues_include);
const excludedTeamsS = new Set(teams_exclude);
const pool = processdata_z.filter(d => {
if (d.Pos_group !== p.Pos_group) return false;
const min = Number(d.Min);
if (!Number.isFinite(min)) return false;
if (min < minutes_min || min > minutes_max) return false;
const age = Number(d.Age);
if (Number.isFinite(age) && (age < age_min || age > age_max)) return false;
if (d.newestLeague && !allowedLeaguesS.has(d.newestLeague)) return false;
if (d.teamShortName && excludedTeamsS.has(d.teamShortName)) return false;
return true;
});
if (pool.length === 0) {
return html`<div style="padding:6px;color:#888;font-family:system-ui;text-align:center;font-size:9px">
No reference pool
</div>`;
}
const colorFor = (pctile) => {
if (pctile >= 85) return "#1a7a3e";
if (pctile >= 65) return "#4caf50";
if (pctile >= 35) return "#f39c12";
if (pctile >= 15) return "#e74c3c";
return "#922b21";
};
const arrowFor = (z) => {
if (z >= 1.5) return "▲▲";
if (z >= 0.5) return "▲";
if (z <= -1.5) return "▼▼";
if (z <= -0.5) return "▼";
return "▶";
};
const rows = slices.map(s => {
const playerZ = Number(p[s.key]);
if (!Number.isFinite(playerZ)) {
return { label: s.label, z: null, pctile: null, missing: true };
}
const poolZs = pool.map(d => Number(d[s.key])).filter(Number.isFinite);
if (poolZs.length === 0) {
return { label: s.label, z: playerZ, pctile: null, missing: true };
}
const beats = poolZs.filter(v => v < playerZ).length;
return { label: s.label, z: playerZ, pctile: (beats / poolZs.length) * 100, missing: false };
});
rows.sort((a, b) => {
if (a.missing && !b.missing) return 1;
if (!a.missing && b.missing) return -1;
return (b.pctile ?? -1) - (a.pctile ?? -1);
});
const TEXT = "#1a1d1c";
const SUB = "#999";
const BORDER = "#f0f0f0";
const rowEls = rows.map(r => {
if (r.missing) {
return html`<div style="
display: grid;
grid-template-columns: 1fr auto 22px;
gap: 4px;
align-items: center;
padding: 1px 5px;
font-size: 8.5px;
color: ${SUB};
border-bottom: 1px solid ${BORDER};
font-style: italic;
"><span>${r.label}</span><span>—</span><span style="text-align:center">—</span></div>`;
}
const color = colorFor(r.pctile);
const arrow = arrowFor(r.z);
const zSign = r.z >= 0 ? "+" : "";
return html`<div style="
display: grid;
grid-template-columns: 1fr auto 22px;
gap: 4px;
align-items: center;
padding: 2px 5px;
border-bottom: 1px solid ${BORDER};
">
<span style="font-size: 9px; color: ${TEXT};">${r.label}</span>
<div style="display: flex; align-items: center; gap: 2px; font-variant-numeric: tabular-nums;">
<span style="font-size: 7px; color: ${color}; font-weight: 700;">${arrow}</span>
<span style="font-size: 9px; font-weight: 700; color: ${TEXT};">${zSign}${r.z.toFixed(2)}</span>
</div>
<div style="
background: ${color};
color: white;
font-size: 8.5px;
font-weight: 700;
text-align: center;
padding: 1px 0;
border-radius: 2px;
font-variant-numeric: tabular-nums;
">${Math.round(r.pctile)}</div>
</div>`;
});
return html`<div style="
font-family: system-ui, -apple-system, sans-serif;
width: 100%;
box-sizing: border-box;
">
<div style="font-size: 8.5px; color: ${SUB}; padding: 0 5px 2px 5px;">
vs ${pool.length} ${p.Pos_group}s
</div>
${rowEls}
</div>`;
}Code
player_positions_pitch = {
const p = radar_player;
const positions = players_posinfo
.filter(d =>
d.playerFullName === p.playerFullName &&
d.teamShortName === p.teamShortName
)
.map(d => ({ ...d, Min: Number(d.Min) || 0 }))
.filter(d => d.Min > 0);
if (positions.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No position data for ${p.playerFullName}
</div>`;
}
const totalMin = positions.reduce((s, d) => s + d.Min, 0);
const withPct = positions.map(d => ({ ...d, pct: d.Min / totalMin }));
const kit = kit_color_map.get(p.teamShortName);
const kitColor = kit?.HomeKit || "#4a90e2";
// ── Light theme ─────────────────────────────────────────────
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
const W = pitchHeight;
const H = pitchWidth;
const HEADER_H = 14;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([0, 100]).range([H, 0]);
const projectX = (px, py) => xScale(py);
const projectY = (px, py) => yScale(px) + HEADER_H;
// ── Header ──────────────────────────────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 5)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 3.5)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Time in Position`);
headerG.append("text")
.attr("x", W / 2).attr("y", 10)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.8)
.text(`${totalMin.toLocaleString()} minutes across ${positions.length} position${positions.length > 1 ? "s" : ""}`);
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
// Pitch lines
pitchG.selectAll("line.pitchline")
.data(getPitchLines)
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(x, y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(x, y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(x, y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(x, y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
// Pitch circles (centre circle + penalty spots)
pitchG.selectAll("circle.pitch")
.data(getPitchCircles)
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
// ── Arcs: render ALL of them (both halves) with correct rotation ─
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs)
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
const px = projectX(x, y);
const py = projectY(x, y);
// Default rotation for the Penalty Arc (pointing down from the top goal)
let rot = -90; // default for penalty arcs
// Top half corners (y < 10 = right side, y > 90 = left side)
if (y < 10 && x > 50) {
rot = 0; // top-right corner
} else if (y > 90 && x > 50) {
rot = 180; // top-left corner (you said this works)
} else if (y < 10 && x < 50) {
rot = 180; // bottom-right corner — try this; if outside, swap to 90
} else if (y > 90 && x < 50) {
rot = 0; // bottom-left corner — try this; if outside, swap to -90
}
return `translate(${px}, ${py}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
// ── Marker scale ────────────────────────────────────────────
const MARKER_MIN_R = 3.5;
const MARKER_MAX_R = 6.5;
const radiusFor = (pct) => MARKER_MIN_R + (MARKER_MAX_R - MARKER_MIN_R) * pct;
const markerG = svg.append("g")
.selectAll("g.marker")
.data(withPct)
.join("g")
.attr("class", "marker")
.attr("transform", d => `translate(${projectX(d.x, d.y)}, ${projectY(d.x, d.y)})`);
// Outer ring
markerG.append("circle")
.attr("r", d => radiusFor(d.pct) + 0.3)
.attr("fill", "none")
.attr("stroke", kitColor)
.attr("stroke-width", 0.4);
// Filled circle
markerG.append("circle")
.attr("r", d => radiusFor(d.pct))
.attr("fill", kitColor)
.attr("fill-opacity", d => 0.35 + 0.6 * d.pct)
.attr("stroke", "white")
.attr("stroke-width", 0.3);
// % text
markerG.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", "#ffffff")
.attr("font-size", 2.6)
.attr("font-weight", "bold")
.style("paint-order", "stroke")
.style("stroke", "#1a1d1c")
.style("stroke-width", 0.5)
.text(d => `${Math.round(d.pct * 100)}%`);
// posabv label
markerG.append("text")
.attr("text-anchor", "middle")
.attr("y", d => radiusFor(d.pct) + 2.8)
.attr("fill", TEXT_COLOR)
.attr("font-size", 2.4)
.attr("font-weight", "bold")
.text(d => d.posabv);
markerG.append("title")
.text(d => `${d.pos} (${d.posabv})\n${d.Min.toLocaleString()} min — ${(d.pct * 100).toFixed(1)}%`);
return svg.node();
}Code
player_positions_table = {
const p = radar_player;
const positions = players_posinfo
.filter(d =>
d.playerFullName === p.playerFullName &&
d.teamShortName === p.teamShortName
)
.map(d => ({ ...d, Min: Number(d.Min) || 0 }))
.filter(d => d.Min > 180)
.sort((a, b) => b.Min - a.Min);
if (positions.length === 0) return html`<div></div>`;
const totalMin = positions.reduce((s, d) => s + d.Min, 0);
const kit = kit_color_map.get(p.teamShortName);
const kitColor = kit?.HomeKit || "#4a90e2";
const TEXT = "#1a1d1c";
const SUB = "#888";
const rows = positions.map(d => {
const pct = d.Min / totalMin;
const alpha = 0.35 + 0.6 * pct; // ← compute alpha separately
// Parse the hex kit color and apply alpha via rgba()
const hex = kitColor.replace("#", "");
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const bgColor = `rgba(${r}, ${g}, ${b}, ${alpha})`;
return html`<div style="
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 11px;
font-family: system-ui, sans-serif;
">
<div style="
width: 25px; height: 25px;
border-radius: 50%;
background: ${bgColor};
display: flex; align-items: center; justify-content: center;
color: white; font-weight: 700; font-size: 8px;
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
">${d.posabv}</div>
<div style="color: ${TEXT}; font-variant-numeric: tabular-nums; font-weight: 600; display: flex; justify-content: center; align-items: center; text-align: center; width: 100%;">
${d.Min.toLocaleString()}
</div>
<div style="color: ${SUB}; font-variant-numeric: tabular-nums;">
${(pct * 100).toFixed(0)}%
</div>
</div>`;
});
return html`<div style="
padding: 6px 8px;
font-family: system-ui, sans-serif;
width: 100%;
box-sizing: border-box;
">
${rows}
</div>`;
}Code
similar_players = {
const p = radar_player;
const slices = selected_slices.length > 0 ? selected_slices : ALL_SLICES;
const sliceKeys = slices.map(s => s.key);
// ── Build target player's vector ─────────────────────────────
const targetVec = sliceKeys.map(k => Number(p[k]));
if (targetVec.some(v => !Number.isFinite(v))) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
Selected player is missing data for one or more slices — can't compute similarity.
</div>`;
}
// ── Cosine similarity ────────────────────────────────────────
const dot = (a, b) => a.reduce((s, v, i) => s + v * b[i], 0);
const mag = (v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0));
const tMag = mag(targetVec);
const cosineSim = (otherVec) => {
const oMag = mag(otherVec);
if (tMag === 0 || oMag === 0) return 0;
return dot(targetVec, otherVec) / (tMag * oMag);
};
// ── Filters ──────────────────────────────────────────────────
const ageMinS = age_min;
const ageMaxS = age_max;
const allowedLeaguesS = new Set(leagues_include);
const excludedTeamsS = new Set(teams_exclude);
const candidates = processdata_z.filter(d => {
if (d.playerFullName === p.playerFullName && d.teamShortName === p.teamShortName) return false;
if (d.Pos_group !== p.Pos_group) return false;
const min = Number(d.Min);
if (!Number.isFinite(min)) return false;
if (min < minutes_min || min > minutes_max) return false;
const age = Number(d.Age);
if (Number.isFinite(age) && (age < ageMinS || age > ageMaxS)) return false;
if (d.newestLeague && !allowedLeaguesS.has(d.newestLeague)) return false;
if (d.teamShortName && excludedTeamsS.has(d.teamShortName)) return false;
if (sliceKeys.some(k => !Number.isFinite(Number(d[k])))) return false;
return true;
});
// ── Score & rank ─────────────────────────────────────────────
const ranked = candidates
.map(d => {
const v = sliceKeys.map(k => Number(d[k]));
const sim = cosineSim(v);
return { ...d, similarity: sim, similarity_pct: ((sim + 1) / 2) * 100 };
})
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 15);
// ── Render ───────────────────────────────────────────────────
const BG = "#fafafa";
const BORDER = "#e0e0e0";
const TEXT = "#1a1d1c";
const SUB = "#888";
// Each row: just the <tr> for the player
const rows = ranked.map((d, i) => {
const cutoutPrimary = getplayerURL(d.playerFullName);
const cutoutFallback = d.scout_name ? getplayerURL(d.scout_name) : "";
const teamLogo = getLogoURL(d.teamShortName);
const pctColor =
d.similarity_pct >= 80 ? "#2ecc71" :
d.similarity_pct >= 65 ? "#f39c12" :
"#888";
return html`<tr style="border-bottom: 1px solid ${BORDER};">
<td style="padding: 4px 6px; color: ${SUB}; font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; width: 18px;">${i + 1}</td>
<td style="padding: 3px 4px; width: 30px;">
<div style="
width: 26px; height: 26px;
border-radius: 50%;
background: linear-gradient(180deg, #f0f0f0, #e0e0e0);
overflow: hidden;
">
<img src="${cutoutPrimary}" alt="${d.playerFullName}"
style="width: 100%; height: 100%; object-fit: cover; object-position: center top;"
onerror="
if (this.dataset.fallbackTried !== '1' && '${cutoutFallback}') {
this.dataset.fallbackTried = '1';
this.src = '${cutoutFallback}';
} else {
this.style.display = 'none';
}
">
</div>
</td>
<td style="padding: 4px 6px; font-weight: 600; color: ${TEXT}; font-size: 12px;">${d.playerFullName}</td>
<td style="padding: 3px 4px; width: 22px;">
<img src="${teamLogo}" alt="${d.teamShortName}"
style="width: 18px; height: 18px; object-fit: contain;"
onerror="this.style.visibility='hidden'">
</td>
<td style="padding: 4px 6px; color: ${SUB}; font-size: 11px;">${d.teamShortName}</td>
<td style="padding: 4px 6px; text-align: right; font-weight: 700; color: ${pctColor}; font-variant-numeric: tabular-nums; font-size: 12px;">${d.similarity_pct.toFixed(1)}%</td>
</tr>`;
});
// Outer wrapper: header + table holding the rows
return html`<div style="
background: ${BG};
border: 1px solid ${BORDER};
border-radius: 8px;
padding: 12px 14px;
font-family: system-ui, -apple-system, sans-serif;
width: 100%;
max-width: 460px;
box-sizing: border-box;
">
<div style="font-weight: 700; font-size: 14px; color: ${TEXT}; margin-bottom: 2px;">
Most similar to ${p.playerFullName}
</div>
<div style="font-size: 11px; color: ${SUB}; margin-bottom: 8px;">
${ranked.length} closest in ${p.Pos_group} · ${sliceKeys.length} metric${sliceKeys.length > 1 ? "s" : ""}
</div>
<table style="width: 100%; border-collapse: collapse;">
<tbody>
${rows}
</tbody>
</table>
</div>`;
}Code
player_heatmap = {
const p = radar_player;
// ── Filter events for this player ─────────────────────────────
const EVENT_TYPES = new Set(["Pass", "OffsidePass", "TakeOn", "AttemptSaved", "Miss", "Goal", "Post"]);
const SETPLAY_TYPES = new Set(["Free kick", "Corner"]);
const events = xt_events.filter(e => {
if (e.Player !== p.Player && e.playerFullName !== p.playerFullName) return false;
if (!EVENT_TYPES.has(e.Event)) return false;
if (e.Event === "Pass" && SETPLAY_TYPES.has(e.PassType)) return false;
if (e.x == null || e.y == null) return false;
return true;
});
if (events.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No event data for ${p.playerFullName}
</div>`;
}
const kit = kit_color_map.get(p.teamShortName);
const kitColor = kit?.HomeKit || "#4a90e2";
// ── Light theme ─────────────────────────────────────────────
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
const W = pitchHeight;
const H = pitchWidth;
const HEADER_H = 14;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([0, 100]).range([H, 0]);
const projectX = (px, py) => xScale(py);
const projectY = (px, py) => yScale(px) + HEADER_H;
// ── Header ──────────────────────────────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 5)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 3.5)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Heatmap`);
headerG.append("text")
.attr("x", W / 2).attr("y", 10)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.8)
.text(`${events.length.toLocaleString()} Open Play Actions`);
// ── Clip heatmap to pitch rectangle ─────────────────────────
const defs = svg.append("defs");
const clipId = `pitch-clip-${Math.random().toString(36).slice(2, 7)}`;
defs.append("clipPath").attr("id", clipId)
.append("rect")
.attr("x", 0).attr("y", HEADER_H)
.attr("width", W).attr("height", H);
// ── Heatmap via stacked radial dots (smooth Gaussian-like blur) ─
const projectedPoints = events.map(e => [projectX(e.x, e.y), projectY(e.x, e.y)]);
const dotRadius = 5; // bigger = smoother / more diffuse
const dotOpacity = 0.07; // lower = needs more events to glow
const dotGradId = `dot-grad-${Math.random().toString(36).slice(2, 7)}`;
const dotGrad = defs.append("radialGradient")
.attr("id", dotGradId)
.attr("cx", "50%").attr("cy", "50%").attr("r", "50%");
dotGrad.append("stop").attr("offset", "0%")
.attr("stop-color", kitColor).attr("stop-opacity", 1);
dotGrad.append("stop").attr("offset", "70%")
.attr("stop-color", kitColor).attr("stop-opacity", 0.15);
dotGrad.append("stop").attr("offset", "100%")
.attr("stop-color", kitColor).attr("stop-opacity", 0);
svg.append("g")
.attr("clip-path", `url(#${clipId})`)
.selectAll("circle.heat-dot")
.data(projectedPoints)
.join("circle")
.attr("class", "heat-dot")
.attr("cx", d => d[0])
.attr("cy", d => d[1])
.attr("r", dotRadius)
.attr("fill", `url(#${dotGradId})`)
.attr("opacity", dotOpacity)
.style("pointer-events", "none");
// ── Pitch lines (drawn ON TOP of the heatmap) ──────────────
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
pitchG.selectAll("line.pitchline")
.data(getPitchLines)
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(x, y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(x, y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(x, y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(x, y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
pitchG.selectAll("circle.pitch")
.data(getPitchCircles)
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs)
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
const px = projectX(x, y);
const py = projectY(x, y);
let rot = -90;
if (y < 10 && x > 50) rot = 0;
else if (y > 90 && x > 50) rot = 180;
else if (y < 10 && x < 50) rot = 180;
else if (y > 90 && x < 50) rot = 0;
return `translate(${px}, ${py}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
return svg.node();
}Code
player_passmap = {
const p = radar_player;
const passes = xt_events.filter(e => {
if (e.Player !== p.Player) return false;
if (e.Event !== "Pass") return false;
if (e.PassType !== "Open Play" && e.PassType !== "Cross") return false;
if (e.x == null || e.y == null) return false;
return true;
});
if (passes.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No pass data for ${p.playerFullName}
</div>`;
}
const kit = kit_color_map.get(p.teamShortName);
const kitColor = kit?.HomeKit || "#4a90e2";
// ── Light theme ─────────────────────────────────────────────
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
// Bin grid: x is the long axis (along pitch length), y is across
const BINS_X = 6; // along the long axis (def → att)
const BINS_Y = 5; // across the pitch (right → left)
const W = pitchHeight;
const H = pitchWidth;
const HEADER_H = 14;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([0, 100]).range([H, 0]);
const projectX = (px, py) => xScale(py);
const projectY = (px, py) => yScale(px) + HEADER_H;
// ── Header ──────────────────────────────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 5)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 3.5)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Pass Starts`);
headerG.append("text")
.attr("x", W / 2).attr("y", 10)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.8)
.text(`${passes.length.toLocaleString()} Open Play Passes`);
// ── Compute bins ────────────────────────────────────────────
// binCounts[bx][by] where bx 0..BINS_X-1 along pitch length, by across
const binCounts = Array.from({ length: BINS_X }, () => new Array(BINS_Y).fill(0));
passes.forEach(e => {
const bx = Math.min(BINS_X - 1, Math.max(0, Math.floor((e.x / 100) * BINS_X)));
const by = Math.min(BINS_Y - 1, Math.max(0, Math.floor((e.y / 100) * BINS_Y)));
binCounts[bx][by]++;
});
const total = passes.length;
const maxCount = d3.max(binCounts.flat()) || 1;
// ── Bin pixel geometry ──────────────────────────────────────
// Each bin covers 100/BINS_X along x-data and 100/BINS_Y along y-data.
// We compute the bin's corner in projected (SVG) space.
const dxData = 100 / BINS_X;
const dyData = 100 / BINS_Y;
// ── Draw bins ───────────────────────────────────────────────
const binsG = svg.append("g");
for (let bx = 0; bx < BINS_X; bx++) {
for (let by = 0; by < BINS_Y; by++) {
const count = binCounts[bx][by];
if (count === 0) continue;
// Bin's data-space corners: from (bx*dxData, by*dyData) to (+dxData, +dyData)
const xDataLow = bx * dxData;
const xDataHigh = (bx + 1) * dxData;
const yDataLow = by * dyData;
const yDataHigh = (by + 1) * dyData;
// In our projection, higher x-data = top of pitch (so smaller y-pixel)
const x1 = projectX(0, yDataHigh);
const x2 = projectX(0, yDataLow);
const y1 = projectY(xDataHigh, 0);
const y2 = projectY(xDataLow, 0);
const rectX = Math.min(x1, x2);
const rectY = Math.min(y1, y2);
const rectW = Math.abs(x2 - x1);
const rectH = Math.abs(y2 - y1);
const pct = count / total;
const opacity = 0.15 + 0.75 * (count / maxCount); // sparse → faint, dense → solid
binsG.append("rect")
.attr("x", rectX).attr("y", rectY)
.attr("width", rectW).attr("height", rectH)
.attr("fill", kitColor)
.attr("fill-opacity", opacity)
.attr("stroke", "none");
// % label in bin centre
const pctText = (pct * 100).toFixed(1) + "%";
const cxBin = rectX + rectW / 2;
const cyBin = rectY + rectH / 2;
binsG.append("text")
.attr("x", cxBin).attr("y", cyBin)
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("font-size", 2.6)
.attr("font-weight", "bold")
.attr("fill", opacity > 0.5 ? "white" : TEXT_COLOR)
.style("paint-order", "stroke")
.style("stroke", opacity > 0.5 ? "#1a1d1c" : BG_COLOR)
.style("stroke-width", 0.4)
.text(pctText);
}
}
// ── Pitch lines (ON TOP of the bins) ────────────────────────
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
pitchG.selectAll("line.pitchline")
.data(getPitchLines)
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(x, y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(x, y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(x, y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(x, y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
pitchG.selectAll("circle.pitch")
.data(getPitchCircles)
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs)
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
const px = projectX(x, y);
const py = projectY(x, y);
let rot = -90;
if (y < 10 && x > 50) rot = 0;
else if (y > 90 && x > 50) rot = 180;
else if (y < 10 && x < 50) rot = 180;
else if (y > 90 && x < 50) rot = 0;
return `translate(${px}, ${py}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
return svg.node();
}Code
player_passendmap = {
const p = radar_player;
const passes = xt_events.filter(e => {
if (e.Player !== p.Player) return false;
if (e.Event !== "Pass") return false;
if (e.PassType !== "Open Play" && e.PassType !== "Cross") return false;
if (e.finalx == null || e.finaly == null) return false;
return true;
});
if (passes.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No pass-end data for ${p.playerFullName}
</div>`;
}
const kit = kit_color_map.get(p.teamShortName);
const kitColor = kit?.HomeKit || "#4a90e2";
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
const BINS_X = 6;
const BINS_Y = 5;
const W = pitchHeight;
const H = pitchWidth;
const HEADER_H = 14;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([0, 100]).range([H, 0]);
const projectX = (px, py) => xScale(py);
const projectY = (px, py) => yScale(px) + HEADER_H;
// ── Header ──────────────────────────────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 5)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 3.5)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Pass Ending`);
headerG.append("text")
.attr("x", W / 2).attr("y", 10)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.8)
.text(`${passes.length.toLocaleString()} Open Play Pass Endpoints`);
// ── Compute bins on finalx/finaly ───────────────────────────
const binCounts = Array.from({ length: BINS_X }, () => new Array(BINS_Y).fill(0));
passes.forEach(e => {
const bx = Math.min(BINS_X - 1, Math.max(0, Math.floor((e.finalx / 100) * BINS_X)));
const by = Math.min(BINS_Y - 1, Math.max(0, Math.floor((e.finaly / 100) * BINS_Y)));
binCounts[bx][by]++;
});
const total = passes.length;
const maxCount = d3.max(binCounts.flat()) || 1;
const dxData = 100 / BINS_X;
const dyData = 100 / BINS_Y;
// ── Draw bins ───────────────────────────────────────────────
const binsG = svg.append("g");
for (let bx = 0; bx < BINS_X; bx++) {
for (let by = 0; by < BINS_Y; by++) {
const count = binCounts[bx][by];
if (count === 0) continue;
const xDataLow = bx * dxData;
const xDataHigh = (bx + 1) * dxData;
const yDataLow = by * dyData;
const yDataHigh = (by + 1) * dyData;
const x1 = projectX(0, yDataHigh);
const x2 = projectX(0, yDataLow);
const y1 = projectY(xDataHigh, 0);
const y2 = projectY(xDataLow, 0);
const rectX = Math.min(x1, x2);
const rectY = Math.min(y1, y2);
const rectW = Math.abs(x2 - x1);
const rectH = Math.abs(y2 - y1);
const pct = count / total;
const opacity = 0.15 + 0.75 * (count / maxCount);
binsG.append("rect")
.attr("x", rectX).attr("y", rectY)
.attr("width", rectW).attr("height", rectH)
.attr("fill", kitColor)
.attr("fill-opacity", opacity)
.attr("stroke", "none");
const pctText = (pct * 100).toFixed(1) + "%";
const cxBin = rectX + rectW / 2;
const cyBin = rectY + rectH / 2;
binsG.append("text")
.attr("x", cxBin).attr("y", cyBin)
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("font-size", 2.6)
.attr("font-weight", "bold")
.attr("fill", opacity > 0.5 ? "white" : TEXT_COLOR)
.style("paint-order", "stroke")
.style("stroke", opacity > 0.5 ? "#1a1d1c" : BG_COLOR)
.style("stroke-width", 0.4)
.text(pctText);
}
}
// ── Pitch lines ─────────────────────────────────────────────
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
pitchG.selectAll("line.pitchline")
.data(getPitchLines)
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(x, y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(x, y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(x, y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(x, y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
pitchG.selectAll("circle.pitch")
.data(getPitchCircles)
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs)
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
const px = projectX(x, y);
const py = projectY(x, y);
let rot = -90;
if (y < 10 && x > 50) rot = 0;
else if (y > 90 && x > 50) rot = 180;
else if (y < 10 && x < 50) rot = 180;
else if (y > 90 && x < 50) rot = 0;
return `translate(${px}, ${py}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
return svg.node();
}Code
player_chances_map = {
const p = radar_player;
// ── Filter to this player's chance-creating actions ──────────
const CHANCE_RESULTS = new Set(["Big Chance", "Chance", "Assist"]);
const chances = xt_events.filter(e => {
if (e.Player !== p.Player) return false;
if (e.Event !== "Pass") return false;
if (!CHANCE_RESULTS.has(e.PassResult)) return false;
if (e.x == null || e.y == null || e.finalx == null || e.finaly == null) return false;
return true;
});
if (chances.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No chances created by ${p.playerFullName}
</div>`;
}
// ── Counts for header ────────────────────────────────────────
const bigCount = chances.filter(d => d.PassResult === "Big Chance").length;
const chanceCount = chances.filter(d => d.PassResult === "Chance").length;
const assistCount = chances.filter(d => d.PassResult === "Assist").length;
// ── Light theme ─────────────────────────────────────────────
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
// ── Geometry: attacking half only ────────────────────────────
const W = pitchHeight;
const H = pitchWidth / 2;
const HEADER_H = 22;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
// ── Scales ───────────────────────────────────────────────────
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([50, 100]).range([H, 0]);
const projectX = (x, y) => xScale(y);
const projectY = (x, y) => yScale(x) + HEADER_H;
// ── Header: title + legend ───────────────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 4)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 4)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Chances`);
headerG.append("text")
.attr("x", W / 2).attr("y", 9)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.8)
.text(`${bigCount} Big Chances · ${chanceCount} Chances · ${assistCount} Assists`);
// Legend at the top
const legendItems = [
{ type: "line", color: "#27ae60", dash: "none", label: "Open Play" },
{ type: "line", color: "#27ae60", dash: "1,1", label: "Cross" },
{ type: "line", color: "#27ae60", dash: "2.5,1", label: "Set Play" },
{ type: "dot", color: "#f1c40f", label: "Assist" },
];
const legendFontSize = 2.6;
const legendSampleW = 4;
const legendGap = 1.2;
const legendItemSpacing = 3;
const itemWidth = (it) =>
legendSampleW + legendGap + it.label.length * legendFontSize * 0.55;
const totalLegendW = legendItems.reduce((s, it) => s + itemWidth(it), 0)
+ legendItemSpacing * (legendItems.length - 1);
const legend = headerG.append("g")
.attr("transform", `translate(${(W - totalLegendW) / 2}, 17)`)
.attr("font-size", legendFontSize)
.attr("fill", TEXT_COLOR);
let cursor = 0;
legendItems.forEach(it => {
const g = legend.append("g").attr("transform", `translate(${cursor}, 0)`);
if (it.type === "line") {
g.append("line")
.attr("x1", 0).attr("y1", 0)
.attr("x2", legendSampleW).attr("y2", 0)
.attr("stroke", it.color)
.attr("stroke-width", 0.8)
.attr("stroke-dasharray", it.dash);
} else {
g.append("circle")
.attr("cx", legendSampleW / 2).attr("cy", 0).attr("r", 0.8)
.attr("fill", it.color);
}
g.append("text")
.attr("x", legendSampleW + legendGap)
.attr("y", 1)
.text(it.label);
cursor += itemWidth(it) + legendItemSpacing;
});
// ── Pitch primitives (attacking half) ────────────────────────
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
// Lines touching the attacking half
pitchG.selectAll("line.pitchline")
.data(getPitchLines.filter(d => {
const [x1] = toData(d.x1, d.y1);
const [x2] = toData(d.x2, d.y2);
return x1 >= 49.99 || x2 >= 49.99;
}))
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(Math.max(x, 50), y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(Math.max(x, 50), y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(Math.max(x, 50), y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(Math.max(x, 50), y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
// Circles strictly in the attacking half (excludes centre circle, keeps penalty spot)
pitchG.selectAll("circle.pitch")
.data(getPitchCircles.filter(d => {
const [x] = toData(d.cx, d.cy);
return x > 49.9;
}))
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
// Arcs in the attacking half — penalty arc + top corners
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs.filter(d => {
const [x] = toData(d.x, d.y);
return x > 49.99;
}))
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
let rot = -90;
if (y < 10) rot = 0;
if (y > 90) rot = 180;
return `translate(${projectX(x, y)}, ${projectY(x, y)}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
// ── Color & dash mappings ────────────────────────────────────
const colorOf = (d) => {
if (d.PassResult === "Assist") return "#f1c40f"; // yellow
return "#27ae60"; // Big Chance + Chance: green
};
const dashOf = (d) => {
if (d.PassType === "Corner") return "2.5,1";
if (d.PassType === "Free kick") return "2.5,1";
if (d.PassType === "Cross") return "1,1";
return "none";
};
// Draw weaker (Chance) first, then bigger ones on top so Assists/Big Chances pop.
const orderRank = { "Chance": 0, "Big Chance": 1, "Assist": 2 };
const sortedChances = [...chances].sort(
(a, b) => (orderRank[a.PassResult] ?? 0) - (orderRank[b.PassResult] ?? 0)
);
// ── Action lines ─────────────────────────────────────────────
svg.append("g")
.selectAll("line.action")
.data(sortedChances)
.join("line")
.attr("x1", d => projectX(d.x, d.y))
.attr("y1", d => projectY(d.x, d.y))
.attr("x2", d => projectX(d.finalx, d.finaly))
.attr("y2", d => projectY(d.finalx, d.finaly))
.attr("stroke", colorOf)
.attr("stroke-width", d => d.PassResult === "Assist" ? 1.0 : 0.7)
.attr("stroke-dasharray", dashOf)
.attr("opacity", 0.85)
.append("title")
.text(d =>
`${d.PassType} · ${d.PassResult}\n` +
`(${d.x.toFixed(0)}, ${d.y.toFixed(0)}) → (${d.finalx.toFixed(0)}, ${d.finaly.toFixed(0)})`
);
// ── End dots ─────────────────────────────────────────────────
svg.append("g")
.selectAll("circle.end")
.data(sortedChances)
.join("circle")
.attr("cx", d => projectX(d.finalx, d.finaly))
.attr("cy", d => projectY(d.finalx, d.finaly))
.attr("r", d => d.PassResult === "Assist" ? 1.1 : 0.8)
.attr("fill", colorOf)
.attr("stroke", BG_COLOR)
.attr("stroke-width", 0.25);
return svg.node();
}Code
player_shotmap = {
const p = radar_player;
// ── Filter to this player's shots ───────────────────────────
const SHOT_EVENTS = new Set(["AttemptSaved", "Miss", "Post", "PenaltyGoal", "Goal"]);
const GOAL_EVENTS = new Set(["Goal", "PenaltyGoal"]);
const shots = xt_events.filter(e => {
if (e.Player !== p.Player) return false;
if (!SHOT_EVENTS.has(e.Event)) return false;
if (e.x == null || e.y == null) return false;
return true;
});
if (shots.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No shots taken by ${p.playerFullName}
</div>`;
}
// ── Counts for header ────────────────────────────────────────
const totalShots = shots.length;
const totalGoals = shots.filter(s => GOAL_EVENTS.has(s.Event)).length;
const totalXG = shots.reduce((sum, s) => sum + (Number(s.xG) || 0), 0);
// ── Light theme ─────────────────────────────────────────────
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
// ── Geometry: attacking half only ────────────────────────────
const W = pitchHeight;
const H = pitchWidth / 2;
const HEADER_H = 22;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([50, 100]).range([H, 0]);
const projectX = (x, y) => xScale(y);
const projectY = (x, y) => yScale(x) + HEADER_H;
// ── Header: title + counts + legend ──────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 4)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 4)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Shots`);
headerG.append("text")
.attr("x", W / 2).attr("y", 9)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.8)
.text(`${totalShots} Shots · ${totalGoals} Goals · ${totalXG.toFixed(2)} xG`);
// Legend
const legendItems = [
{ color: "#27ae60", label: "Goal" },
{ color: "#e74c3c", label: "No goal" },
];
const legendFontSize = 2.8;
const legendSampleR = 1;
const legendGap = 1.2;
const legendItemSpacing = 5;
const itemWidth = (it) =>
legendSampleR * 2 + legendGap + it.label.length * legendFontSize * 0.55;
const totalLegendW = legendItems.reduce((s, it) => s + itemWidth(it), 0)
+ legendItemSpacing * (legendItems.length - 1);
const legend = headerG.append("g")
.attr("transform", `translate(${(W - totalLegendW) / 2}, 17)`)
.attr("font-size", legendFontSize)
.attr("fill", TEXT_COLOR);
let cursor = 0;
legendItems.forEach(it => {
const g = legend.append("g").attr("transform", `translate(${cursor}, 0)`);
g.append("circle")
.attr("cx", legendSampleR).attr("cy", 0)
.attr("r", legendSampleR)
.attr("fill", it.color);
g.append("text")
.attr("x", legendSampleR * 2 + legendGap)
.attr("y", 1)
.text(it.label);
cursor += itemWidth(it) + legendItemSpacing;
});
// ── Pitch primitives (attacking half) ────────────────────────
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
pitchG.selectAll("line.pitchline")
.data(getPitchLines.filter(d => {
const [x1] = toData(d.x1, d.y1);
const [x2] = toData(d.x2, d.y2);
return x1 >= 49.99 || x2 >= 49.99;
}))
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(Math.max(x, 50), y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(Math.max(x, 50), y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(Math.max(x, 50), y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(Math.max(x, 50), y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
pitchG.selectAll("circle.pitch")
.data(getPitchCircles.filter(d => {
const [x] = toData(d.cx, d.cy);
return x > 49.9;
}))
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs.filter(d => {
const [x] = toData(d.x, d.y);
return x > 49.99;
}))
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
let rot = -90;
if (y < 10) rot = 0;
if (y > 90) rot = 180;
return `translate(${projectX(x, y)}, ${projectY(x, y)}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
// ── Shot dots sized by xG ────────────────────────────────────
// Use sqrt scale so area scales linearly with xG (not radius).
const maxXG = d3.max(shots, s => Number(s.xG) || 0) || 0.5;
const xgScale = d3.scaleSqrt()
.domain([0, maxXG])
.range([0.3, 2.5]); // min radius for ~0 xG, max for the player's highest xG shot
const colorOf = (s) => GOAL_EVENTS.has(s.Event) ? "#27ae60" : "#e74c3c";
// Draw misses first, then goals on top so goals never get hidden.
const sortedShots = [...shots].sort((a, b) =>
(GOAL_EVENTS.has(a.Event) ? 1 : 0) - (GOAL_EVENTS.has(b.Event) ? 1 : 0)
);
svg.append("g")
.selectAll("circle.shot")
.data(sortedShots)
.join("circle")
.attr("cx", d => projectX(d.x, d.y))
.attr("cy", d => projectY(d.x, d.y))
.attr("r", d => xgScale(Number(d.xG) || 0))
.attr("fill", colorOf)
.attr("fill-opacity", 0.7)
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.2)
.append("title")
.text(d =>
`${d.Event}\n` +
`xG: ${(Number(d.xG) || 0).toFixed(2)}\n` +
`(${d.x.toFixed(0)}, ${d.y.toFixed(0)})`
);
return svg.node();
}Code
player_takeon_map = {
const p = radar_player;
// ── Take-ons ─────────────────────────────────────────────────
const takeons = xt_events
.filter(e =>
e.Player === p.Player &&
e.Event === "TakeOn" &&
e.x != null && e.y != null &&
e.ShotEndX != null && e.ShotEndY != null
)
.map(e => ({
x1: e.x, y1: e.y,
x2: e.ShotEndX, y2: e.ShotEndY,
success: e.TakeOn_Outcome === "Successful",
raw: e,
}));
if (takeons.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No take-ons for ${p.playerFullName}
</div>`;
}
// ── Counts ───────────────────────────────────────────────────
const total = takeons.length;
const successful = takeons.filter(t => t.success).length;
const successRate = (successful / total) * 100;
// ── Light theme ─────────────────────────────────────────────
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
// ── Geometry: attacking half only ────────────────────────────
const W = pitchHeight;
const H = pitchWidth / 2;
const HEADER_H = 22;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([50, 100]).range([H, 0]);
const projectX = (x, y) => xScale(y);
const projectY = (x, y) => yScale(x) + HEADER_H;
// ── Header ───────────────────────────────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 4)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 4)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Take-ons`);
headerG.append("text")
.attr("x", W / 2).attr("y", 9)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.8)
.text(`${successful}/${total} successful · ${successRate.toFixed(0)}%`);
// Legend
const legendItems = [
{ color: "#27ae60", label: "Successful" },
{ color: "#e74c3c", label: "Unsuccessful" },
];
const legendFontSize = 2.8;
const legendSampleR = 1;
const legendGap = 1.2;
const legendItemSpacing = 5;
const itemWidth = (it) =>
legendSampleR * 2 + legendGap + it.label.length * legendFontSize * 0.55;
const totalLegendW = legendItems.reduce((s, it) => s + itemWidth(it), 0)
+ legendItemSpacing * (legendItems.length - 1);
const legend = headerG.append("g")
.attr("transform", `translate(${(W - totalLegendW) / 2}, 17)`)
.attr("font-size", legendFontSize)
.attr("fill", TEXT_COLOR);
let cursor = 0;
legendItems.forEach(it => {
const g = legend.append("g").attr("transform", `translate(${cursor}, 0)`);
g.append("circle")
.attr("cx", legendSampleR).attr("cy", 0)
.attr("r", legendSampleR)
.attr("fill", it.color);
g.append("text")
.attr("x", legendSampleR * 2 + legendGap)
.attr("y", 1)
.text(it.label);
cursor += itemWidth(it) + legendItemSpacing;
});
// ── Pitch primitives (attacking half) ────────────────────────
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
pitchG.selectAll("line.pitchline")
.data(getPitchLines.filter(d => {
const [x1] = toData(d.x1, d.y1);
const [x2] = toData(d.x2, d.y2);
return x1 >= 49.99 || x2 >= 49.99;
}))
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(Math.max(x, 50), y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(Math.max(x, 50), y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(Math.max(x, 50), y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(Math.max(x, 50), y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
pitchG.selectAll("circle.pitch")
.data(getPitchCircles.filter(d => {
const [x] = toData(d.cx, d.cy);
return x > 49.9;
}))
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs.filter(d => {
const [x] = toData(d.x, d.y);
return x > 49.99;
}))
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
let rot = -90;
if (y < 10) rot = 0;
if (y > 90) rot = 180;
return `translate(${projectX(x, y)}, ${projectY(x, y)}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
// ── Style helpers ────────────────────────────────────────────
const colorOf = (t) => t.success ? "#27ae60" : "#e74c3c";
// Failures first, successes on top
const sortedTakeons = [...takeons].sort(
(a, b) => (a.success ? 1 : 0) - (b.success ? 1 : 0)
);
// ── Action lines ─────────────────────────────────────────────
svg.append("g")
.selectAll("line.action")
.data(sortedTakeons)
.join("line")
.attr("x1", t => projectX(t.x1, t.y1))
.attr("y1", t => projectY(t.x1, t.y1))
.attr("x2", t => projectX(t.x2, t.y2))
.attr("y2", t => projectY(t.x2, t.y2))
.attr("stroke", colorOf)
.attr("stroke-width", 0.7)
.attr("opacity", 0.85)
.append("title")
.text(t =>
`Take-on${t.success ? " (success)" : " (failed)"}\n` +
`(${t.x1.toFixed(0)}, ${t.y1.toFixed(0)}) → (${t.x2.toFixed(0)}, ${t.y2.toFixed(0)})`
);
// ── End markers ──────────────────────────────────────────────
svg.append("g")
.selectAll("circle.end")
.data(sortedTakeons)
.join("circle")
.attr("cx", t => projectX(t.x2, t.y2))
.attr("cy", t => projectY(t.x2, t.y2))
.attr("r", 0.9)
.attr("fill", colorOf)
.attr("stroke", BG_COLOR)
.attr("stroke-width", 0.25);
return svg.node();
}Code
viewof scatter_x_slice = Inputs.select(
selected_slices.length > 0 ? selected_slices : ALL_SLICES,
{
label: "X axis",
format: s => s.label.replace("\n", " "),
value: (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[0],
}
)
viewof scatter_y_slice = Inputs.select(
selected_slices.length > 0 ? selected_slices : ALL_SLICES,
{
label: "Y axis",
format: s => s.label.replace("\n", " "),
value: (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[1]
?? (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[0],
}
)Code
scatter_plot = {
const p = radar_player;
const xKey = scatter_x_slice.key;
const yKey = scatter_y_slice.key;
const xLabel = scatter_x_slice.label.replace("\n", " ");
const yLabel = scatter_y_slice.label.replace("\n", " ");
// ── Peer pool, same filters as radar's samePosFinal ──────────
const ageMinS = age_min;
const ageMaxS = age_max;
const allowedLeaguesR = new Set(leagues_include);
const excludedTeamsR = new Set(teams_exclude);
const samePos = processdata_z.filter(d => {
if (d.Pos_group !== p.Pos_group) return false;
const min = Number(d.Min);
if (!Number.isFinite(min)) return false;
if (min < minutes_min || min > minutes_max) return false;
const age = Number(d.Age);
if (Number.isFinite(age) && (age < age_min || age > age_max)) return false;
if (d.newestLeague && !allowedLeaguesR.has(d.newestLeague)) return false;
if (d.teamShortName && excludedTeamsR.has(d.teamShortName)) return false;
return true;
});
const samePosFinal = benchmark_mode === "vs_target_league"
? samePos.filter(d => d.newestLeague === target_league)
: samePos;
// Players plottable on both axes
const plotPlayers = samePosFinal.filter(d =>
Number.isFinite(Number(d[xKey])) && Number.isFinite(Number(d[yKey]))
);
if (plotPlayers.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No data to plot
</div>`;
}
// ── Top 10 most similar players (cosine sim on all selected slices) ─
const slices = selected_slices.length > 0 ? selected_slices : ALL_SLICES;
const sliceKeys = slices.map(s => s.key);
const targetVec = sliceKeys.map(k => Number(p[k]));
const allSimValid = targetVec.every(Number.isFinite);
const top10Ids = new Set();
if (allSimValid) {
const dot = (a, b) => a.reduce((s, v, i) => s + v * b[i], 0);
const mag = (v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0));
const tMag = mag(targetVec);
const ranked = plotPlayers
.filter(d =>
!(d.playerFullName === p.playerFullName && d.teamShortName === p.teamShortName) &&
sliceKeys.every(k => Number.isFinite(Number(d[k])))
)
.map(d => {
const v = sliceKeys.map(k => Number(d[k]));
const sim = tMag === 0 || mag(v) === 0
? 0
: dot(targetVec, v) / (tMag * mag(v));
return { d, sim };
})
.sort((a, b) => b.sim - a.sim)
.slice(0, 15);
for (const { d } of ranked) {
top10Ids.add(`${d.playerFullName}||${d.teamShortName}`);
}
}
const idOf = (d) => `${d.playerFullName}||${d.teamShortName}`;
const isTop10 = (d) => top10Ids.has(idOf(d));
const isSelected = (d) =>
d.playerFullName === p.playerFullName && d.teamShortName === p.teamShortName;
// ── Layout ───────────────────────────────────────────────────
const W = 640;
const H = 540;
const M = { top: 30, right: 30, bottom: 50, left: 60 };
const xExtent = d3.extent(plotPlayers, d => Number(d[xKey]));
const yExtent = d3.extent(plotPlayers, d => Number(d[yKey]));
const xPad = (xExtent[1] - xExtent[0]) * 0.05 || 0.5;
const yPad = (yExtent[1] - yExtent[0]) * 0.05 || 0.5;
const xScale = d3.scaleLinear()
.domain([xExtent[0] - xPad, xExtent[1] + xPad])
.range([M.left, W - M.right]);
const yScale = d3.scaleLinear()
.domain([yExtent[0] - yPad, yExtent[1] + yPad])
.range([H - M.bottom, M.top]);
// ── SVG container with absolutely-positioned tooltip ─────────
const container = d3.create("div")
.style("position", "relative")
.style("display", "block") // ← was "inline-block"
.style("width", "100%") // ← add this
.style("font-family", "system-ui, -apple-system, sans-serif");
const tooltip = container.append("div")
.style("position", "absolute")
.style("pointer-events", "none")
.style("background", "rgba(20,20,20,0.95)")
.style("color", "white")
.style("padding", "8px 12px")
.style("border-radius", "6px")
.style("font-size", "12px")
.style("line-height", "1.5")
.style("box-shadow", "0 2px 8px rgba(0,0,0,0.25)")
.style("opacity", "0")
.style("transition", "opacity 100ms ease")
.style("z-index", "999")
.style("white-space", "nowrap");
const svg = container.append("svg")
.attr("viewBox", `0 0 ${W} ${H}`)
.attr("preserveAspectRatio", "xMidYMid meet")
.style("width", "100%")
.style("height", "auto")
.style("background", "#fafafa")
.style("border", "1px solid #e0e0e0")
.style("border-radius", "8px");
// ── Quadrant lines at means (helpful reference) ──────────────
const xMean = d3.mean(plotPlayers, d => Number(d[xKey]));
const yMean = d3.mean(plotPlayers, d => Number(d[yKey]));
svg.append("line")
.attr("x1", xScale(xMean)).attr("x2", xScale(xMean))
.attr("y1", M.top).attr("y2", H - M.bottom)
.attr("stroke", "#ccc").attr("stroke-dasharray", "3,3");
svg.append("line")
.attr("x1", M.left).attr("x2", W - M.right)
.attr("y1", yScale(yMean)).attr("y2", yScale(yMean))
.attr("stroke", "#ccc").attr("stroke-dasharray", "3,3");
// ── Axes ─────────────────────────────────────────────────────
svg.append("g")
.attr("transform", `translate(0, ${H - M.bottom})`)
.call(d3.axisBottom(xScale).ticks(8))
.call(g => g.selectAll(".domain, line").attr("stroke", "#bbb"))
.call(g => g.selectAll("text").attr("font-size", 11).attr("fill", "#444"));
svg.append("g")
.attr("transform", `translate(${M.left}, 0)`)
.call(d3.axisLeft(yScale).ticks(8))
.call(g => g.selectAll(".domain, line").attr("stroke", "#bbb"))
.call(g => g.selectAll("text").attr("font-size", 11).attr("fill", "#444"));
// Axis labels
svg.append("text")
.attr("x", (M.left + W - M.right) / 2)
.attr("y", H - 12)
.attr("text-anchor", "middle")
.attr("font-size", 13)
.attr("font-weight", 600)
.attr("fill", "#222")
.text(xLabel);
svg.append("text")
.attr("transform", `translate(16, ${(M.top + H - M.bottom) / 2}) rotate(-90)`)
.attr("text-anchor", "middle")
.attr("font-size", 13)
.attr("font-weight", 600)
.attr("fill", "#222")
.text(yLabel);
// ── Plot dots (everyone, ordered so top10 + selected render on top) ─
const ordered = [...plotPlayers].sort((a, b) => {
const aRank = isSelected(a) ? 2 : isTop10(a) ? 1 : 0;
const bRank = isSelected(b) ? 2 : isTop10(b) ? 1 : 0;
return aRank - bRank;
});
// Base dots: everyone (kit color)
const dotR = 5;
svg.append("g")
.selectAll("circle.player-dot")
.data(ordered.filter(d => !isTop10(d) && !isSelected(d)))
.join("circle")
.attr("cx", d => xScale(Number(d[xKey])))
.attr("cy", d => yScale(Number(d[yKey])))
.attr("r", dotR)
.attr("fill", "#666")
.attr("fill-opacity", 0.5)
.attr("stroke", "#fff")
.attr("stroke-width", 1)
.style("cursor", "pointer")
.on("mouseenter", function(event, d) {
d3.select(this).attr("stroke-width", 2).attr("r", dotR + 1.5);
showTooltip(event, d);
})
.on("mousemove", moveTooltip)
.on("mouseleave", function() {
d3.select(this).attr("stroke-width", 1).attr("r", dotR);
hideTooltip();
});
// Top-10 + selected: player cutouts (clipPath-circled images)
const defs = svg.append("defs");
const cutoutPlayers = ordered.filter(d => isTop10(d) || isSelected(d));
cutoutPlayers.forEach((d, i) => {
const clipId = `scatter-clip-${i}-${Math.random().toString(36).slice(2,6)}`;
defs.append("clipPath").attr("id", clipId)
.append("circle")
.attr("cx", xScale(Number(d[xKey])))
.attr("cy", yScale(Number(d[yKey])))
.attr("r", 14);
d._clipId = clipId;
});
const cutoutG = svg.append("g")
.selectAll("g.cutout")
.data(cutoutPlayers)
.join("g")
.attr("class", "cutout")
.style("cursor", "pointer");
// Ring (kit color, thicker for the selected player)
cutoutG.append("circle")
.attr("cx", d => xScale(Number(d[xKey])))
.attr("cy", d => yScale(Number(d[yKey])))
.attr("r", 15)
.attr("fill", "#fff")
.attr("stroke", d => kit_color_map.get(d.teamShortName)?.HomeKit || "#888")
.attr("stroke-width", d => isSelected(d) ? 3.5 : 2);
// Cutout image
cutoutG.append("image")
.attr("href", n => {
const player = n?.d ?? n;
if (!player?.playerFullName) return "";
return getplayerURL(player.playerFullName, player.teamShortName);
})
.attr("x", d => xScale(Number(d[xKey])) - 14)
.attr("y", d => yScale(Number(d[yKey])) - 14)
.attr("width", 28)
.attr("height", 28)
.attr("clip-path", d => `url(#${d._clipId})`)
.attr("preserveAspectRatio", "xMidYMin slice")
.on("error", function(_, d) {
// Fallback to scout_name on 404
const fallback = d.scout_name ? getplayerURL(d.scout_name) : null;
if (fallback && this.dataset.fallbackTried !== "1") {
this.dataset.fallbackTried = "1";
d3.select(this).attr("href", fallback);
} else {
d3.select(this).style("display", "none");
}
});
cutoutG
.on("mouseenter", function(event, d) {
d3.select(this).select("circle").attr("stroke-width",
isSelected(d) ? 5 : 3.5);
showTooltip(event, d);
})
.on("mousemove", moveTooltip)
.on("mouseleave", function(_, d) {
d3.select(this).select("circle").attr("stroke-width",
isSelected(d) ? 3.5 : 2);
hideTooltip();
});
// ── Tooltip helpers ──────────────────────────────────────────
function showTooltip(event, d) {
const xVal = Number(d[xKey]).toFixed(2);
const yVal = Number(d[yKey]).toFixed(2);
const min = Number(d.Min) || 0;
tooltip.html(`
<div style="font-weight:600;margin-bottom:3px">${d.playerFullName}</div>
<div style="color:#bbb;margin-bottom:6px">${d.teamShortName} · ${d.pos ?? d.Pos_group}</div>
<div>Age <b>${d.Age ?? "—"}</b> · Min <b>${min.toLocaleString()}</b></div>
<div style="margin-top:4px;border-top:1px solid #444;padding-top:4px">
<div>${xLabel}: <b>${xVal}</b></div>
<div>${yLabel}: <b>${yVal}</b></div>
</div>
`).style("opacity", "1");
moveTooltip(event);
}
function moveTooltip(event) {
const rect = container.node().getBoundingClientRect();
const tipNode = tooltip.node();
const tipW = tipNode.offsetWidth;
const tipH = tipNode.offsetHeight;
const OFFSET = 12;
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
// Default: offset down-right of cursor
let left = cursorX + OFFSET;
let top = cursorY + OFFSET;
// Flip to left side if it would extend past container's right edge
if (left + tipW > rect.width) {
left = cursorX - tipW - OFFSET;
}
// Flip upward if it would extend past container's bottom edge
if (top + tipH > rect.height) {
top = cursorY - tipH - OFFSET;
}
// Final safety: never let it go past container's left or top
left = Math.max(4, left);
top = Math.max(4, top);
tooltip
.style("left", left + "px")
.style("top", top + "px");
}
function hideTooltip() {
tooltip.style("opacity", "0");
}
return container.node();
}Code
viewof beeswarm_slice_1 = Inputs.select(
selected_slices.length > 0 ? selected_slices : ALL_SLICES,
{
label: "Beeswarm 1",
format: s => s.label.replace("\n", " "),
value: (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[0],
}
)
viewof beeswarm_slice_2 = Inputs.select(
selected_slices.length > 0 ? selected_slices : ALL_SLICES,
{
label: "Beeswarm 2",
format: s => s.label.replace("\n", " "),
value: (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[1]
?? (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[0],
}
)
viewof beeswarm_slice_3 = Inputs.select(
selected_slices.length > 0 ? selected_slices : ALL_SLICES,
{
label: "Beeswarm 3",
format: s => s.label.replace("\n", " "),
value: (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[2]
?? (selected_slices.length > 0 ? selected_slices : ALL_SLICES)[0],
}
)Code
beeswarm_plots = {
const p = radar_player;
const chosenSlices = [beeswarm_slice_1, beeswarm_slice_2, beeswarm_slice_3];
// ── Peer pool (same logic as scatter) ────────────────────────
const ageMinB = age_min;
const ageMaxB = age_max;
const allowedLeaguesR = new Set(leagues_include);
const excludedTeamsR = new Set(teams_exclude);
const samePos = processdata_z.filter(d => {
if (d.Pos_group !== p.Pos_group) return false;
const min = Number(d.Min);
if (!Number.isFinite(min)) return false;
if (min < minutes_min || min > minutes_max) return false;
const age = Number(d.Age);
if (Number.isFinite(age) && (age < age_min || age > age_max)) return false;
if (d.newestLeague && !allowedLeaguesR.has(d.newestLeague)) return false;
if (d.teamShortName && excludedTeamsR.has(d.teamShortName)) return false;
return true;
});
const samePosFinal = benchmark_mode === "vs_target_league"
? samePos.filter(d => d.newestLeague === target_league)
: samePos;
if (samePosFinal.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No data
</div>`;
}
// ── Top 10 most similar players via cosine sim on selected_slices ─
const simSlices = selected_slices.length > 0 ? selected_slices : ALL_SLICES;
const simKeys = simSlices.map(s => s.key);
const targetVec = simKeys.map(k => Number(p[k]));
const allSimValid = targetVec.every(Number.isFinite);
const top10Ids = new Set();
if (allSimValid) {
const dot = (a, b) => a.reduce((s, v, i) => s + v * b[i], 0);
const mag = (v) => Math.sqrt(v.reduce((s, x) => s + x * x, 0));
const tMag = mag(targetVec);
samePosFinal
.filter(d =>
!(d.playerFullName === p.playerFullName && d.teamShortName === p.teamShortName) &&
simKeys.every(k => Number.isFinite(Number(d[k])))
)
.map(d => {
const v = simKeys.map(k => Number(d[k]));
const sim = tMag === 0 || mag(v) === 0
? 0
: dot(targetVec, v) / (tMag * mag(v));
return { d, sim };
})
.sort((a, b) => b.sim - a.sim)
.slice(0, 15)
.forEach(({ d }) => top10Ids.add(`${d.playerFullName}||${d.teamShortName}`));
}
const idOf = (d) => `${d.playerFullName}||${d.teamShortName}`;
const isTop10 = (d) => top10Ids.has(idOf(d));
const isSelected = (d) =>
d.playerFullName === p.playerFullName && d.teamShortName === p.teamShortName;
// ── Build one beeswarm panel ─────────────────────────────────
const panelW = 720;
const panelH = 200; // each row's height
const margin = { top: 28, right: 30, bottom: 26, left: 60 };
const innerH = panelH - margin.top - margin.bottom;
const yCenter = margin.top + innerH / 2;
// Tooltip (shared across all 3 panels)
const container = d3.create("div")
.style("position", "relative")
.style("display", "block") // ← was "inline-block"
.style("width", "100%") // ← add this
.style("font-family", "system-ui, -apple-system, sans-serif");
const tooltip = container.append("div")
.style("position", "absolute")
.style("pointer-events", "none")
.style("background", "rgba(20,20,20,0.95)")
.style("color", "white")
.style("padding", "8px 12px")
.style("border-radius", "6px")
.style("font-size", "12px")
.style("line-height", "1.5")
.style("box-shadow", "0 2px 8px rgba(0,0,0,0.25)")
.style("opacity", "0")
.style("transition", "opacity 100ms ease")
.style("z-index", "999")
.style("white-space", "nowrap");
function moveTooltip(event) {
const rect = container.node().getBoundingClientRect();
const tipNode = tooltip.node();
const tipW = tipNode.offsetWidth;
const tipH = tipNode.offsetHeight;
const OFFSET = 12;
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
// Default: offset down-right of cursor
let left = cursorX + OFFSET;
let top = cursorY + OFFSET;
// Flip to left side if it would extend past container's right edge
if (left + tipW > rect.width) {
left = cursorX - tipW - OFFSET;
}
// Flip upward if it would extend past container's bottom edge
if (top + tipH > rect.height) {
top = cursorY - tipH - OFFSET;
}
// Final safety: never let it go past container's left or top
left = Math.max(4, left);
top = Math.max(4, top);
tooltip
.style("left", left + "px")
.style("top", top + "px");
}
function showTooltip(event, d, slice) {
const val = Number(d[slice.key]).toFixed(2);
const min = Number(d.Min) || 0;
tooltip.html(`
<div style="font-weight:600;margin-bottom:3px">${d.playerFullName}</div>
<div style="color:#bbb;margin-bottom:6px">${d.teamShortName} · ${d.pos ?? d.Pos_group}</div>
<div>Age <b>${d.Age ?? "—"}</b> · Min <b>${min.toLocaleString()}</b></div>
<div style="margin-top:4px;border-top:1px solid #444;padding-top:4px">
${slice.label.replace("\n", " ")}: <b>${val}</b>
</div>
`).style("opacity", "1");
moveTooltip(event);
}
function hideTooltip() { tooltip.style("opacity", "0"); }
// Build one row per slice
function buildPanel(slice) {
const key = slice.key;
const label = slice.label.replace("\n", " ");
const players = samePosFinal.filter(d => Number.isFinite(Number(d[key])));
if (players.length === 0) return null;
const extent = d3.extent(players, d => Number(d[key]));
const pad = (extent[1] - extent[0]) * 0.05 || 0.5;
const xScale = d3.scaleLinear()
.domain([extent[0] - pad, extent[1] + pad])
.range([margin.left, panelW - margin.right]);
// d3.forceSimulation to lay out the dots without overlap
const dotR = 5;
const cutoutR = 13;
const nodes = players.map(d => ({
d,
x: xScale(Number(d[key])),
y: yCenter,
// give cutouts more "space" so they don't overlap with each other
r: (isTop10(d) || isSelected(d)) ? cutoutR : dotR,
}));
const sim = d3.forceSimulation(nodes)
.force("x", d3.forceX(n => xScale(Number(n.d[key]))).strength(1))
.force("y", d3.forceY(yCenter).strength(0.08)) // ← was 0.2, more vertical spread
.force("collide", d3.forceCollide(n => n.r + 1.2))
.stop();
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${panelW} ${panelH}`)
.attr("preserveAspectRatio", "xMidYMid meet")
.style("width", "100%")
.style("height", "auto")
.style("background", "#fafafa")
.style("border", "1px solid #e0e0e0")
.style("border-radius", "8px")
.style("display", "block")
.style("margin-bottom", "12px");
// Title
svg.append("text")
.attr("x", margin.left).attr("y", 18)
.attr("font-size", 13)
.attr("font-weight", 600)
.attr("fill", "#222")
.text(label);
// Axis
svg.append("g")
.attr("transform", `translate(0, ${panelH - margin.bottom})`)
.call(d3.axisBottom(xScale).ticks(8))
.call(g => g.selectAll(".domain, line").attr("stroke", "#bbb"))
.call(g => g.selectAll("text").attr("font-size", 10).attr("fill", "#444"));
// Mean reference line
const meanX = d3.mean(players, d => Number(d[key]));
svg.append("line")
.attr("x1", xScale(meanX)).attr("x2", xScale(meanX))
.attr("y1", margin.top).attr("y2", panelH - margin.bottom)
.attr("stroke", "#ccc").attr("stroke-dasharray", "3,3");
// Sort: plain → top-10 → selected (so selected is always topmost in z-order)
const sortedNodes = [...nodes].sort((a, b) => {
const aRank = isSelected(a.d) ? 2 : isTop10(a.d) ? 1 : 0;
const bRank = isSelected(b.d) ? 2 : isTop10(b.d) ? 1 : 0;
return aRank - bRank;
});
// Plain kit-color dots
svg.append("g")
.selectAll("circle.dot")
.data(sortedNodes.filter(n => !isTop10(n.d) && !isSelected(n.d)))
.join("circle")
.attr("cx", n => n.x).attr("cy", n => n.y)
.attr("r", dotR)
.attr("fill", "#666")
.attr("fill-opacity", 0.5)
.attr("stroke", "#fff").attr("stroke-width", 1)
.style("cursor", "pointer")
.on("mouseenter", function(event, n) {
d3.select(this).attr("stroke-width", 2).attr("r", dotR + 1.5);
showTooltip(event, n.d, slice);
})
.on("mousemove", moveTooltip)
.on("mouseleave", function() {
d3.select(this).attr("stroke-width", 1).attr("r", dotR);
hideTooltip();
});
// Cutouts for top-10 + selected
const defs = svg.append("defs");
const cutoutNodes = sortedNodes.filter(n => isTop10(n.d) || isSelected(n.d));
cutoutNodes.forEach((n, i) => {
const clipId = `bee-clip-${key}-${i}-${Math.random().toString(36).slice(2,6)}`;
defs.append("clipPath").attr("id", clipId)
.append("circle").attr("cx", n.x).attr("cy", n.y).attr("r", cutoutR);
n._clipId = clipId;
});
const cutoutG = svg.append("g")
.selectAll("g.cutout")
.data(cutoutNodes)
.join("g")
.style("cursor", "pointer");
cutoutG.append("circle")
.attr("cx", n => n.x).attr("cy", n => n.y)
.attr("r", cutoutR + 1)
.attr("fill", "#fff")
.attr("stroke", n => kit_color_map.get(n.d.teamShortName)?.HomeKit || "#888")
.attr("stroke-width", n => isSelected(n.d) ? 3 : 1.8);
cutoutG.append("image")
.attr("href", n => {
const player = n?.d ?? n;
if (!player?.playerFullName) return "";
return getplayerURL(player.playerFullName, player.teamShortName);
})
.attr("x", n => n.x - cutoutR)
.attr("y", n => n.y - cutoutR)
.attr("width", cutoutR * 2).attr("height", cutoutR * 2)
.attr("clip-path", n => `url(#${n._clipId})`)
.attr("preserveAspectRatio", "xMidYMin slice")
.on("error", function(_, n) {
const fb = n.d.scout_name ? getplayerURL(n.d.scout_name) : null;
if (fb && this.dataset.fbTried !== "1") {
this.dataset.fbTried = "1";
d3.select(this).attr("href", fb);
} else {
d3.select(this).style("display", "none");
}
});
cutoutG
.on("mouseenter", function(event, n) {
d3.select(this).select("circle")
.attr("stroke-width", isSelected(n.d) ? 4.5 : 3);
showTooltip(event, n.d, slice);
})
.on("mousemove", moveTooltip)
.on("mouseleave", function(_, n) {
d3.select(this).select("circle")
.attr("stroke-width", isSelected(n.d) ? 3 : 1.8);
hideTooltip();
});
return svg.node();
}
// Build & append all three panels
for (const slice of chosenSlices) {
const panel = buildPanel(slice);
if (panel) container.node().appendChild(panel);
}
return container.node();
}Code
player_defensive_map = {
const p = radar_player;
// ── Filter to defensive actions ─────────────────────────────
const DEF_EVENTS = new Set(["Aerial", "Tackle", "Challenge", "Interception", "BallRecovery"]);
const actions = xt_events.filter(e => {
if (e.Player !== p.Player) return false;
if (!DEF_EVENTS.has(e.Event)) return false;
if (e.x == null || e.y == null) return false;
return true;
});
if (actions.length === 0) {
return html`<div style="padding:20px;color:#888;font-family:system-ui;text-align:center">
No defensive actions for ${p.playerFullName}
</div>`;
}
// Event-type breakdown for header
const counts = {};
for (const e of actions) counts[e.Event] = (counts[e.Event] || 0) + 1;
const kit = kit_color_map.get(p.teamShortName);
const kitColor = kit?.HomeKit || "#4a90e2";
// ── Light theme ─────────────────────────────────────────────
const BG_COLOR = "#f5f7f4";
const LINE_COLOR = "#1a1d1c";
const TEXT_COLOR = "#1a1d1c";
const SUB_COLOR = "#666";
const BINS_X = 6; // along the long axis (own goal → opponent goal)
const BINS_Y = 5; // across the pitch
const W = pitchHeight;
const H = pitchWidth;
const HEADER_H = 14;
const PAD = 4;
const svg = d3.create("svg")
.attr("viewBox", `${-PAD} ${-PAD} ${W + 2 * PAD} ${H + HEADER_H + 2 * PAD}`)
.attr("width", 380)
.style("max-width", "100%")
.style("height", "auto")
.style("background", BG_COLOR);
const xScale = d3.scaleLinear().domain([0, 100]).range([W, 0]);
const yScale = d3.scaleLinear().domain([0, 100]).range([H, 0]);
const projectX = (px, py) => xScale(py);
const projectY = (px, py) => yScale(px) + HEADER_H;
// ── Header ──────────────────────────────────────────────────
const headerG = svg.append("g");
headerG.append("text")
.attr("x", W / 2).attr("y", 5)
.attr("text-anchor", "middle")
.attr("fill", TEXT_COLOR)
.attr("font-size", 3.5)
.attr("font-weight", "bold")
.text(`${p.playerFullName} — Defending`);
// 1. Calculate the combined group totals safely (using || 0 in case the key doesn't exist)
const tacklesCount = (counts["Tackle"] || 0) + (counts["Challenge"] || 0);
const recoveriesCount = (counts["BallRecovery"] || 0) + (counts["Interception"] || 0);
const aerialsCount = counts["Aerial"] || 0;
// 2. Build an array of objects representing your new groups
const combinedGroups = [
{ label: "Tackles", count: tacklesCount },
{ label: "Recoveries", count: recoveriesCount },
{ label: "Aerial", count: aerialsCount }
];
// 3. Filter out groups with 0 counts, map them to strings, and join them
const breakdown = combinedGroups
.filter(g => g.count > 0)
.map(g => `${g.count} ${g.label}`)
.join(" · ");
// 4. Append to your D3 header
headerG.append("text")
.attr("x", W / 2).attr("y", 10)
.attr("text-anchor", "middle")
.attr("fill", SUB_COLOR)
.attr("font-size", 2.6)
.text(`${actions.length.toLocaleString()} total${breakdown ? ` · ${breakdown}` : ""}`);
// ── Compute bin counts ──────────────────────────────────────
const binCounts = Array.from({ length: BINS_X }, () => new Array(BINS_Y).fill(0));
actions.forEach(e => {
const bx = Math.min(BINS_X - 1, Math.max(0, Math.floor((e.x / 100) * BINS_X)));
const by = Math.min(BINS_Y - 1, Math.max(0, Math.floor((e.y / 100) * BINS_Y)));
binCounts[bx][by]++;
});
const maxCount = d3.max(binCounts.flat()) || 1;
const total = actions.length;
// ── Bin geometry ────────────────────────────────────────────
const dxData = 100 / BINS_X;
const dyData = 100 / BINS_Y;
// ── Draw bins ───────────────────────────────────────────────
const binsG = svg.append("g");
for (let bx = 0; bx < BINS_X; bx++) {
for (let by = 0; by < BINS_Y; by++) {
const count = binCounts[bx][by];
if (count === 0) continue;
const xDataLow = bx * dxData;
const xDataHigh = (bx + 1) * dxData;
const yDataLow = by * dyData;
const yDataHigh = (by + 1) * dyData;
const x1 = projectX(0, yDataHigh);
const x2 = projectX(0, yDataLow);
const y1 = projectY(xDataHigh, 0);
const y2 = projectY(xDataLow, 0);
const rectX = Math.min(x1, x2);
const rectY = Math.min(y1, y2);
const rectW = Math.abs(x2 - x1);
const rectH = Math.abs(y2 - y1);
const opacity = 0.15 + 0.75 * (count / maxCount);
binsG.append("rect")
.attr("x", rectX).attr("y", rectY)
.attr("width", rectW).attr("height", rectH)
.attr("fill", kitColor)
.attr("fill-opacity", opacity)
.attr("stroke", "none");
const cxBin = rectX + rectW / 2;
const cyBin = rectY + rectH / 2;
binsG.append("text")
.attr("x", cxBin).attr("y", cyBin)
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("font-size", 3.2)
.attr("font-weight", "bold")
.attr("fill", opacity > 0.5 ? "white" : TEXT_COLOR)
.style("paint-order", "stroke")
.style("stroke", opacity > 0.5 ? "#1a1d1c" : BG_COLOR)
.style("stroke-width", 0.4)
.text(count);
}
}
// ── Pitch lines (ON TOP of the bins) ────────────────────────
const toData = (px, py) => [(px / pitchWidth) * 100, (py / pitchHeight) * 100];
const pitchG = svg.append("g");
pitchG.selectAll("line.pitchline")
.data(getPitchLines)
.join("line")
.attr("x1", d => { const [x, y] = toData(d.x1, d.y1); return projectX(x, y); })
.attr("y1", d => { const [x, y] = toData(d.x1, d.y1); return projectY(x, y); })
.attr("x2", d => { const [x, y] = toData(d.x2, d.y2); return projectX(x, y); })
.attr("y2", d => { const [x, y] = toData(d.x2, d.y2); return projectY(x, y); })
.attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
pitchG.selectAll("circle.pitch")
.data(getPitchCircles)
.join("circle")
.attr("cx", d => { const [x, y] = toData(d.cx, d.cy); return projectX(x, y); })
.attr("cy", d => { const [x, y] = toData(d.cx, d.cy); return projectY(x, y); })
.attr("r", d => (d.r / pitchWidth) * W)
.attr("fill", "none").attr("stroke", LINE_COLOR).attr("stroke-width", 0.3).attr("opacity", 0.8);
const arcGen = d3.arc();
pitchG.selectAll("path.pitch-arc")
.data(getArcs)
.join("path")
.attr("d", d => arcGen(d.arc))
.attr("transform", d => {
const [x, y] = toData(d.x, d.y);
const px = projectX(x, y);
const py = projectY(x, y);
let rot = -90;
if (y < 10 && x > 50) rot = 0;
else if (y > 90 && x > 50) rot = 180;
else if (y < 10 && x < 50) rot = 180;
else if (y > 90 && x < 50) rot = 0;
return `translate(${px}, ${py}) rotate(${rot})`;
})
.attr("fill", "none")
.attr("stroke", LINE_COLOR)
.attr("stroke-width", 0.3)
.attr("opacity", 0.8);
return svg.node();
}Code
scout_dashboard_inputs = [
{ label: "Player", node: viewof radar_player },
{ label: "Slices", node: viewof selected_slices },
{ label: "Age min", node: viewof age_min },
{ label: "Age max", node: viewof age_max },
{label: "Minutes min", node: viewof minutes_min },
{ label: "Minutes max", node: viewof minutes_max },
{ label: "Leagues", node: viewof leagues_include },
{ label: "Exclude teams", node: viewof teams_exclude },
{ label: "Benchmark", node: viewof benchmark_mode },
{ label: "Target league", node: viewof target_league },
{ label: "Scatter X", node: viewof scatter_x_slice },
{ label: "Scatter Y", node: viewof scatter_y_slice },
{ label: "Beeswarm 1", node: viewof beeswarm_slice_1 },
{ label: "Beeswarm 2", node: viewof beeswarm_slice_2 },
{ label: "Beeswarm 3", node: viewof beeswarm_slice_3 },
{label : "Position Group" , node: viewof radar_pos}
]Code
scout_controls_bar = {
const bar = document.createElement("div");
bar.style.background = "#fafafa";
bar.style.border = "1px solid #e0e0e0";
bar.style.borderRadius = "6px";
bar.style.padding = "14px 16px";
bar.style.fontFamily = "-apple-system, system-ui, sans-serif";
bar.style.display = "flex";
bar.style.flexDirection = "column";
bar.style.gap = "12px";
bar.style.marginBottom = "10px";
const byLabel = Object.fromEntries(scout_dashboard_inputs.map(x => [x.label, x.node]));
const renderSection = (title, items) => {
const section = document.createElement("div");
section.style.display = "flex";
section.style.flexDirection = "column";
section.style.gap = "8px";
const heading = document.createElement("div");
heading.style.fontSize = "11px";
heading.style.fontWeight = "600";
heading.style.color = "#888";
heading.style.textTransform = "uppercase";
heading.style.letterSpacing = "0.6px";
heading.textContent = title;
section.appendChild(heading);
const row = document.createElement("div");
row.style.display = "flex";
row.style.flexWrap = "wrap";
row.style.gap = "16px";
row.style.alignItems = "flex-end";
items.forEach(node => {
if (!node) return;
const w = document.createElement("div");
w.style.display = "flex";
w.style.flexDirection = "column";
w.appendChild(node);
row.appendChild(w);
});
section.appendChild(row);
return section;
};
const sep = () => {
const hr = document.createElement("hr");
hr.style.border = "0";
hr.style.borderTop = "1px solid #e0e0e0";
hr.style.margin = "4px 0";
return hr;
};
bar.appendChild(sep());
bar.appendChild(renderSection("Data Configuration", [
byLabel["Age min"], byLabel["Age max"],
byLabel["Minutes min"], byLabel["Minutes max"],
byLabel["Leagues"], byLabel["Exclude teams"],
]));
bar.appendChild(sep());
bar.appendChild(renderSection("Benchmark Mode & Target League", [
byLabel["Benchmark"], byLabel["Target league"],
]));
bar.appendChild(sep());
bar.appendChild(renderSection("Metrics for Scatterplot & Beeswarm Distributions", [
byLabel["Scatter X"], byLabel["Scatter Y"], byLabel["Beeswarm 1"], byLabel["Beeswarm 2"], byLabel["Beeswarm 3"],
]));
bar.appendChild(renderSection("Select Position, Player & Slices", [
byLabel["Position Group"], byLabel["Player"], byLabel["Slices"],
]));
return bar;
}Code
scout_dashboard = {
let cloneCounter = 0;
const cloneSVGSafe = (node) => {
if (!node) return document.createElement("div");
const clone = node.cloneNode(true);
const prefix = `c${cloneCounter++}_${Math.random().toString(36).slice(2, 7)}`;
const idMap = new Map();
clone.querySelectorAll("[id]").forEach(el => {
const oldId = el.getAttribute("id");
const newId = `${prefix}_${oldId}`;
idMap.set(oldId, newId);
el.setAttribute("id", newId);
});
const allEls = [clone, ...clone.querySelectorAll("*")];
allEls.forEach(el => {
const urlAttrs = ["fill", "stroke", "clip-path", "mask", "filter", "marker-end", "marker-start", "marker-mid"];
urlAttrs.forEach(attr => {
const val = el.getAttribute(attr);
if (val && val.includes("url(#")) {
const newVal = val.replace(/url\(#([^)]+)\)/g, (match, oldId) => {
const newId = idMap.get(oldId);
return newId ? `url(#${newId})` : match;
});
el.setAttribute(attr, newVal);
}
});
["href", "xlink:href"].forEach(attr => {
const val = el.getAttribute(attr);
if (val && val.startsWith("#")) {
const oldId = val.slice(1);
const newId = idMap.get(oldId);
if (newId) el.setAttribute(attr, `#${newId}`);
}
});
});
const allSVGs = clone.tagName === "svg" ? [clone] : [...clone.querySelectorAll("svg")];
allSVGs.forEach(svg => {
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.style.width = "100%";
svg.style.height = "100%"; // ← fill height too
svg.style.maxWidth = "100%";
svg.style.maxHeight = "100%";
svg.style.display = "block";
if (!svg.getAttribute("preserveAspectRatio")) {
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
}
});
if (clone.tagName !== "svg") {
clone.style.maxWidth = "100%";
clone.style.boxSizing = "border-box";
}
return clone;
};
// For interactive cells, we MOVE the original node (preserves event handlers).
// The original location in the notebook will lose the viz, but reactivity stays intact.
const useOriginal = (node) => {
if (!node) return null;
// Apply the same responsive normalization to the original SVGs inside
const allSVGs = node.tagName === "svg" ? [node] : [...node.querySelectorAll("svg")];
allSVGs.forEach(svg => {
if (!svg.style.width) {
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.style.width = "100%";
svg.style.height = "auto";
svg.style.maxWidth = "100%";
svg.style.display = "block";
}
if (!svg.getAttribute("preserveAspectRatio")) {
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
}
});
if (node.tagName !== "svg") {
node.style.maxWidth = "100%";
node.style.boxSizing = "border-box";
}
return node;
};
const stub = (label) => {
const d = document.createElement("div");
d.style.color = "#888";
d.style.fontSize = "11px";
d.style.padding = "20px";
d.style.textAlign = "center";
d.style.fontFamily = "system-ui";
d.style.border = "1px dashed #ccc";
d.style.borderRadius = "4px";
d.textContent = label;
return d;
};
// ── Root ─────────────────────────────────────────────────────
const root = document.createElement("div");
root.style.background = "#eef0ee";
root.style.padding = "10px";
root.style.fontFamily = "-apple-system, system-ui, sans-serif";
root.style.display = "flex";
root.style.flexDirection = "column";
root.style.gap = "10px";
root.style.width = "100%";
root.style.boxSizing = "border-box";
// ── Controls bar ─────────────────────────────────────────────
// ── Controls bar with grouped sections ──────────────────────
// const controlsBar = document.createElement("div");
// controlsBar.style.background = "#fafafa";
// controlsBar.style.border = "1px solid #e0e0e0";
// controlsBar.style.borderRadius = "6px";
// controlsBar.style.padding = "14px 16px";
// controlsBar.style.fontFamily = "-apple-system, system-ui, sans-serif";
// controlsBar.style.display = "flex";
// controlsBar.style.flexDirection = "column";
// controlsBar.style.gap = "12px";
// // Pull view nodes from scout_dashboard_inputs by their label
// const byLabel = Object.fromEntries(scout_dashboard_inputs.map(x => [x.label, x.node]));
// const renderSection = (title, items) => {
// const section = document.createElement("div");
// section.style.display = "flex";
// section.style.flexDirection = "column";
// section.style.gap = "8px";
// const heading = document.createElement("div");
// heading.style.fontSize = "11px";
// heading.style.fontWeight = "600";
// heading.style.color = "#888";
// heading.style.textTransform = "uppercase";
// heading.style.letterSpacing = "0.6px";
// heading.textContent = title;
// section.appendChild(heading);
// const row = document.createElement("div");
// row.style.display = "flex";
// row.style.flexWrap = "wrap";
// row.style.gap = "16px";
// row.style.alignItems = "flex-end";
// items.forEach(node => {
// if (!node) return;
// const w = document.createElement("div");
// w.style.display = "flex";
// w.style.flexDirection = "column";
// w.appendChild(node);
// row.appendChild(w);
// });
// section.appendChild(row);
// return section;
// };
// const sep = () => {
// const hr = document.createElement("hr");
// hr.style.border = "0";
// hr.style.borderTop = "1px solid #e0e0e0";
// hr.style.margin = "4px 0";
// return hr;
// };
// controlsBar.appendChild(sep());
// controlsBar.appendChild(renderSection("Data Configuration", [
// byLabel["Age min"],
// byLabel["Age max"],
// byLabel["Leagues"],
// byLabel["Exclude teams"],
// ]));
// controlsBar.appendChild(sep());
// controlsBar.appendChild(renderSection("Benchmark Mode & Target League (e.g. Move Player to another League)", [
// byLabel["Benchmark"],
// byLabel["Target league"],
// ]));
// controlsBar.appendChild(sep());
// controlsBar.appendChild(renderSection("Metrics for Scatterplot & Beeswarm Distributions", [
// byLabel["Scatter X"],
// byLabel["Scatter Y"],
// byLabel["Beeswarm 1"],
// byLabel["Beeswarm 2"],
// byLabel["Beeswarm 3"],
// ]));
// controlsBar.appendChild(renderSection("Select Position, Player & Slices", [
// byLabel["Position Group"],
// byLabel["Player"],
// byLabel["Slices"],
// ]));
// root.appendChild(controlsBar);
// ── Grid layout ──────────────────────────────────────────────
const grid = document.createElement("div");
grid.style.display = "grid";
grid.style.gap = "8px";
grid.style.gridTemplateColumns = "0.9fr 1.2fr 1.1fr 1.1fr 1.1fr";
grid.style.gridTemplateRows = "auto auto auto";
grid.style.gridTemplateAreas = `
"card radar radar radar bc"
"sim sim hc ac ct"
"bees bees scatter scatter defense"
`;
grid.style.width = "100%";
grid.style.boxSizing = "border-box";
const wrap = (area, child, opts = {}) => {
const w = document.createElement("div");
w.style.gridArea = area;
w.style.background = "#fafafa";
w.style.border = "1px solid #e0e0e0";
w.style.borderRadius = "6px";
w.style.padding = "8px";
w.style.display = "flex";
w.style.alignItems = opts.align || "flex-start";
w.style.justifyContent = "center";
w.style.minHeight = opts.minHeight || "120px";
w.style.minWidth = "0";
w.style.maxWidth = "100%";
w.style.overflow = opts.overflow || "hidden";
w.style.boxSizing = "border-box";
if (child) w.appendChild(child);
return w;
};
const wrapStack = (area, children) => {
const outer = document.createElement("div");
outer.style.gridArea = area;
outer.style.display = "flex";
outer.style.flexDirection = "column";
outer.style.gap = "8px";
outer.style.minWidth = "0";
outer.style.boxSizing = "border-box";
children.forEach(item => {
// Support both raw nodes and {node, flex} objects
const node = item.node ?? item;
const flexVal = item.flex ?? "1 1 0"; // ← default 1:1, can be overridden
const inner = document.createElement("div");
inner.style.background = "#fafafa";
inner.style.border = "1px solid #e0e0e0";
inner.style.borderRadius = "6px";
inner.style.padding = "8px";
inner.style.display = "flex";
inner.style.alignItems = "flex-start";
inner.style.justifyContent = "center";
inner.style.minHeight = "120px";
inner.style.minWidth = "0";
inner.style.overflow = "hidden";
inner.style.boxSizing = "border-box";
inner.style.flex = flexVal; // ← uses the per-child flex
if (node) inner.appendChild(node);
outer.appendChild(inner);
});
return outer;
};
// For INTERACTIVE cells, use the original node (preserves listeners).
const placeOriginal = (area, chart, label, opts) => {
grid.appendChild(wrap(area, chart ? useOriginal(chart) : stub(label), opts));
};
// For STATIC SVGs (no JS handlers), clone is fine.
const placeClone = (area, chart, label, opts) => {
grid.appendChild(wrap(area, chart ? cloneSVGSafe(chart) : stub(label), opts));
};
const placeStackOriginal = (area, items) => {
grid.appendChild(wrapStack(area, items.map(({ chart, label }) =>
chart ? useOriginal(chart) : stub(label)
)));
};
const placeStackClone = (area, items) => {
grid.appendChild(wrapStack(area, items.map(({ chart, label, flex }) => ({
node: chart ? cloneSVGSafe(chart) : stub(label),
flex,
}))));
};
// ── Interactive (move original) ──────────────────────────────
placeOriginal("radar", typeof radar_chart !== "undefined" ? radar_chart : null, "radar_chart");
placeOriginal("sim", typeof similar_players !== "undefined" ? similar_players : null, "similar_players", { overflow: "auto" });
placeOriginal("bees", typeof beeswarm_plots !== "undefined" ? beeswarm_plots : null, "beeswarm_plots", { overflow: "auto" });
placeOriginal("scatter", typeof scatter_plot !== "undefined" ? scatter_plot : null, "scatter_plot", { overflow: "auto" });
// ── Static SVGs (clone is fine, no handlers) ─────────────────
placeClone("defense", typeof player_defensive_map !== "undefined" ? player_defensive_map : null, "player_defensive_map");
// ── Stacked (takeon+heatmap) — pure SVGs, clone is fine ──────
placeStackClone("ct", [
{ chart: typeof player_takeon_map !== "undefined" ? player_takeon_map : null, label: "player_takeon_map", flex: "1 1 0" },
{ chart: typeof player_heatmap !== "undefined" ? player_heatmap : null, label: "player_heatmap", flex: "1.5 1 0" },
]);
// ── Stacked (chances+passmap) — also pure SVGs ─────────────
placeStackClone("hc", [
{ chart: typeof player_shotmap !== "undefined" ? player_shotmap : null, label: "player_shotmap", flex: "1 1 0" },
{ chart: typeof player_passmap !== "undefined" ? player_passmap : null, label: "player_passmap", flex: "1.5 1 0" },
]);
placeStackClone("ac", [
{ chart: typeof player_chances_map !== "undefined" ? player_chances_map : null, label: "player_chances_map", flex: "1 1 0" },
{ chart: typeof player_passendmap !== "undefined" ? player_passendmap : null, label: "player_passendmap", flex: "1.5 1 0" },
]);
placeStackClone("bc", [
{ chart: typeof player_positions_pitch !== "undefined" ? player_positions_pitch : null, label: "player_positions_pitch", flex: "3 1 0" },
{ chart: typeof player_positions_table !== "undefined" ? player_positions_table : null, label: "player_positions_table", flex: "1 1 0" },
]);
placeStackClone("card", [
{chart: typeof player_info_card !== "undefined" ? player_info_card : null, label: "player_info_card", flex: "1.3 1 0" },
{ chart: typeof player_slice_scores !== "undefined" ? player_slice_scores : null, label: "player_slice_scores", flex: "1 1 0" },
])
// pt isn't used in this layout — removed.
root.appendChild(grid);
return root;
}Code
//| echo: false
DashboardDownloader = {
const sourceCellValue = scout_dashboard;
const wrapper = html`<div style="display:flex; gap:8px; padding:8px; align-items:center;"></div>`;
const status = html`<span style="color:#888; font-size:12px;"></span>`;
const pngBtn = html`<button style="padding:10px 14px; background:#3498db; color:white; border:none; border-radius:4px; cursor:pointer; font-weight:bold;">
Download PNG
</button>`;
pngBtn.onclick = async () => {
status.textContent = "Loading library…";
try {
// Load html2canvas dynamically from CDN
const html2canvas = await import("https://cdn.jsdelivr.net/npm/html2canvas-pro@1.5.8/+esm")
.then(m => m.default);
status.textContent = "Rendering dashboard…";
// html2canvas can't read remote images without CORS — we need to inline them first.
// Walk every <image> in every nested SVG and convert remote hrefs to data URIs.
const allImages = sourceCellValue.querySelectorAll("image");
const cache = new Map();
const toDataURI = async (url) => {
if (cache.has(url)) return cache.get(url);
try {
const res = await fetch(url, { mode: "cors" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const dataURI = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
cache.set(url, dataURI);
return dataURI;
} catch (err) {
console.warn("Could not inline", url, err);
cache.set(url, null);
return null;
}
};
// Inline all SVG <image> hrefs
for (const img of allImages) {
const href = img.getAttribute("href") || img.getAttribute("xlink:href");
if (!href || href.startsWith("data:")) continue;
const dataURI = await toDataURI(href);
if (dataURI) {
img.setAttribute("href", dataURI);
img.removeAttribute("xlink:href");
}
}
// Rasterize with html2canvas
const canvas = await html2canvas(sourceCellValue, {
backgroundColor: "#0d0f0e",
scale: 2, // resolution multiplier; bump to 3+ for higher quality
useCORS: true,
logging: false,
allowTaint: false
});
status.textContent = "Saving…";
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "ScoutDashboard.png";
link.click();
URL.revokeObjectURL(url);
status.textContent = "Done.";
}, "image/png");
} catch (err) {
console.error(err);
status.textContent = "Error: " + err.message;
}
};
wrapper.appendChild(pngBtn);
wrapper.appendChild(status);
return wrapper;
}Code
//| echo: false
DashboardDownloader2 = {
// ← change this to the name of your visualization cell
const sourceCellValue = scout_dashboard;
const wrapper = html`<div style="display:flex; gap:8px; padding:8px; align-items:center;"></div>`;
const status = html`<span style="color:#888; font-size:12px;"></span>`;
const svgBtn = html`<button style="padding:10px 14px; background:#2ecc71; color:white; border:none; border-radius:4px; cursor:pointer; font-weight:bold;">
Download SVG
</button>`;
const pngBtn = html`<button style="padding:10px 14px; background:#3498db; color:white; border:none; border-radius:4px; cursor:pointer; font-weight:bold;">
Download PNG
</button>`;
// Fetch a URL and convert it to a base64 data URI
const toDataURI = async (url) => {
const res = await fetch(url, { mode: "cors" });
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
const blob = await res.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
// Walk the SVG, find every <image>, replace its href with a base64 data URI
const inlineImages = async (svgNode) => {
const images = svgNode.querySelectorAll("image");
const cache = new Map();
for (const img of images) {
const href = img.getAttribute("href") || img.getAttribute("xlink:href");
if (!href || href.startsWith("data:")) continue;
try {
if (!cache.has(href)) {
cache.set(href, await toDataURI(href));
}
img.setAttribute("href", cache.get(href));
img.removeAttribute("xlink:href");
} catch (err) {
console.warn("Could not inline", href, err);
}
}
};
// Convert <line marker-end="url(#...)"> into <line> + a <circle> at its endpoint.
// This avoids canvas-rendering issues where SVG markers are dropped during rasterization.
const bakeMarkers = (svgNode) => {
const lines = svgNode.querySelectorAll("line[marker-end]");
for (const line of lines) {
const markerRef = line.getAttribute("marker-end");
const match = markerRef && markerRef.match(/#([^)]+)/);
if (!match) continue;
const markerId = match[1];
const marker = svgNode.querySelector(`marker#${CSS.escape(markerId)}`);
if (!marker) continue;
const markerCircle = marker.querySelector("circle");
if (!markerCircle) continue;
const fill = markerCircle.getAttribute("fill") || "black";
const markerWidth = parseFloat(marker.getAttribute("markerWidth")) || 6;
const r = markerWidth / 6; // calibrate dot size — bump up/down if needed
const x2 = parseFloat(line.getAttribute("x2"));
const y2 = parseFloat(line.getAttribute("y2"));
const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
dot.setAttribute("cx", x2);
dot.setAttribute("cy", y2);
dot.setAttribute("r", r);
dot.setAttribute("fill", fill);
line.parentNode.appendChild(dot);
line.removeAttribute("marker-end");
}
};
// Clone, namespace, bake markers, inline images. Returns serialized SVG string.
const prepareSVG = async () => {
const clone = sourceCellValue.cloneNode(true);
bakeMarkers(clone);
await inlineImages(clone);
return new XMLSerializer().serializeToString(clone);
};
svgBtn.onclick = async () => {
status.textContent = "Preparing SVG…";
try {
const source = await prepareSVG();
const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "ScoutDashboard.svg";
link.click();
URL.revokeObjectURL(url);
status.textContent = "Done.";
} catch (err) {
status.textContent = "Error: " + err.message;
}
};
pngBtn.onclick = async () => {
status.textContent = "Preparing PNG…";
try {
const source = await prepareSVG();
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
let vbW, vbH;
const viewBox = sourceCellValue.getAttribute("viewBox");
if (viewBox) {
const [, , w, h] = viewBox.split(/\s+/).map(Number);
vbW = w;
vbH = h;
} else {
// fallback for SVGs without a viewBox
vbW =
parseFloat(sourceCellValue.getAttribute("width")) ||
sourceCellValue.clientWidth ||
sourceCellValue.getBoundingClientRect().width;
vbH =
parseFloat(sourceCellValue.getAttribute("height")) ||
sourceCellValue.clientHeight ||
sourceCellValue.getBoundingClientRect().height;
};
const SCALE = 3;
const canvas = document.createElement("canvas");
canvas.width = vbW * SCALE;
canvas.height = vbH * SCALE;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#2E3532";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = svgUrl;
});
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(svgUrl);
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "ScoutDashboard.png";
link.click();
URL.revokeObjectURL(url);
status.textContent = "Done.";
}, "image/png");
} catch (err) {
status.textContent = "Error: " + err.message;
}
};
wrapper.appendChild(svgBtn);
wrapper.appendChild(pngBtn);
wrapper.appendChild(status);
return wrapper;
}