Public
Edited
Aug 9, 2023
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
circleBracket.update(mode)
Insert cell
titleString = (d, i) => d[1] ?
`#${d[1]?.seed} ${d[1]?.name}
${Math.round(d[1]?.winProb * 1000) / 10}% win probability
against ${d[1]?.opponentSeed ? `#${d[1].opponentSeed} ${d[1].opponentName}` : '???'}
in the ${roundNames[d[0]]}`
: null
Insert cell
roundNames = [
'Round of 64',
'Round of 32',
'Sweet 16',
'Elite 8',
'Final Four',
'Championship'
]
Insert cell
matchSlice = (round, team, double, mode) => {
const roundList = teamRounds[gender][round];
const sliceSize = (Math.PI * 2) / roundList.length;
let startAngle = sliceSize * team,
endAngle = sliceSize * (team + 1);
if (double) {
startAngle *= 2;
endAngle *= 2;
}
if (mode == "Weighted" && !double) {
if (team % 2) {
// second team, adjust start angle
startAngle = sliceSize * (team - 2 * (roundList[team]?.winProb ?? 0.5) + 1);
} else {
// first team, adjust end angle
endAngle = sliceSize * (team + 2 * (roundList[team]?.winProb ?? 0.5));
}
}
return d3.arc()({
innerRadius: roundScale(round),
outerRadius: roundScale(round) + roundScale.bandwidth(),
startAngle,
endAngle
});
}
Insert cell
teamImageTransform = (round, team, mode) => {
if (round == 6) {
return `scale(1.5)`
}
const sliceSize = 360 / teamRounds[gender][round].length;
const winProb = teamRounds[gender][round][team]?.winProb
let teamOffset = 0.5;
let scale = 1
if (mode == "Weighted") {
teamOffset = team % 2 ? 1 - winProb : winProb
scale = d3.min([
bracketHeight * Math.PI / teamRounds[gender][round].length * winProb * 2 / 50,
1
])
}
const rotation = sliceSize * (team + teamOffset) - 90;
return `
rotate(${rotation})
translate(${[roundScale(round) + roundScale.bandwidth() / 2, 0]})
rotate(${-rotation})
scale(${scale})
`;
}
Insert cell
roundScale = d3
.scaleBand()
.domain(teamRounds.Mens.map((_, i) => i))
.range([bracketHeight / 2 - 25, 0])
.paddingInner(.2)
Insert cell
imageSize = roundScale.bandwidth() * .75
Insert cell
bracketHeight = width + 60
Insert cell
Insert cell
Insert cell
Insert cell
backgroundMap = {
const m = {};
const mensPalettes = await Promise.all(
teamRounds.Mens[0].map(t => extractPalette(logoUrl(t.id)))
);
const womensPalettes = await Promise.all(
teamRounds.Womens[0].map(t => extractPalette(logoUrl(t.id)))
);
mensPalettes.forEach(
(_, i) => (m[teamRounds.Mens[0][i].id] = mensPalettes[i][0])
);
womensPalettes.forEach(
(_, i) => (m[teamRounds.Womens[0][i].id] = womensPalettes[i][0])
);
return m;
}
Insert cell
teamRounds = {
// build bracket layout
const rounds = { Mens: [], Womens: [] };
let roundSize = 64;
while (roundSize >= 1) {
rounds.Mens.push(new Array(roundSize).fill(null));
rounds.Womens.push(new Array(roundSize).fill(null));
roundSize /= 2;
}
const placeTeams = (teams, gender) => {
teams[gender].forEach(team => {
let rd = 1;
while (rd <= 7) {
if (team[`rd${rd}_win`] == 1) {
const placementIdx = findPlacementForTeam(
rd,
team.team_region,
+team.team_seed.replace("a", "").replace("b", ""),
gender
);
rounds[gender][rd - 1][placementIdx] = {
seed: +team.team_seed.replace("a", "").replace("b", ""),
name: team.team_name,
id: team.team_id,
winProb: findWinProb(rd, team.team_name, gender)
};
}
rd += 1;
}
});
// set opponents
rounds[gender].forEach(round =>
round.forEach((team, tIdx) => {
if (team) {
team.opponentName = round[tIdx + (tIdx % 2 ? -1 : 1)]?.name ?? '???'
team.opponentSeed = round[tIdx + (tIdx % 2 ? -1 : 1)]?.seed ?? null
}
})
);
};

placeTeams(teams, 'Mens');
placeTeams(teams, 'Womens');

return rounds;
}
Insert cell
findPlacementForTeam = (round, region, seed, gender) => {
const roundSize = 2 ** (7 - round);
const regionIndex = regionOrder[gender].findIndex((d) => d == region);
const seedIndex = seedOrder.findIndex((d) => d == seed);

const regionOffset = Math.floor(regionIndex * (roundSize / 4));
let seedOffset = seedIndex >> (round - 1);
// if traditional structure, the 3rd and 4th regions should have seed order reversed
if (regionIndex > 1 && traditionalLeft && round < 6) {
seedOffset = Math.floor(roundSize / 4) - seedOffset - 1;
}

return regionOffset + seedOffset;
}
Insert cell
findWinProb = (round, teamName, gender) => {
if (teamName == null) {
return 0.5;
}
if (round == 7) {
return 1;
}
const forcasts = fiveThirtyEightData.forecasts[gender.toLowerCase()];
if (!forcasts.day1) {
forcasts.day1 = forcasts.first_run;
}
const dailyForcasts = Object.entries(forcasts)
.filter(d => d[0].startsWith('day'))
.sort((a, b) =>
d3.ascending(+a[0].replace('day', ''), +b[0].replace('day', ''))
);
dailyForcasts.push([`day${dailyForcasts.length + 1}`, forcasts.current_run]);

return dailyForcasts
.map(d => d[1].teams.find(t => t.team_name == teamName))
.filter(d => d.results_to == round)
.slice(-1)[0][`rd${round + 1}_win`];
}
Insert cell
teams = ({
Mens: fiveThirtyEightData.forecasts.mens.current_run.teams.filter(
d => d.rd1_win
),
Womens: fiveThirtyEightData.forecasts.womens.current_run.teams.filter(
d => d.rd1_win
)
})
Insert cell
fiveThirtyEightData = d3.json(fiveThirtyEightUrl)
Insert cell
traditionalLeft = true
Insert cell
seedOrder = [1, 16, 8, 9, 5, 12, 4, 13, 6, 11, 3, 14, 7, 10, 2, 15]
Insert cell
regionOrder = ({'Mens': ['South', 'Midwest', 'East', 'West'], 'Womens': ['River Walk', 'Mercado', 'Hemisfair', 'Alamo']})
Insert cell
Insert cell
fiveThirtyEightUrl = "https://projects.fivethirtyeight.com/march-madness-api/2021/madness.json"
Insert cell
logoUrl = (id, w = 36, h = 36) =>
`https://secure.espncdn.com/combiner/i?img=/i/teamlogos/ncaa/500/${id}.png&w=${w}&h=${h}&scale=crop`
Insert cell
Insert cell
import { Radio } from '@observablehq/inputs'
Insert cell
import { extractPalette } from '@makio135/give-me-colors'
Insert cell
d3 = require('d3')
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more