diff --git a/frontend/public/assets/js/radar.js b/frontend/public/assets/js/radar.js
new file mode 100644
index 0000000..f5974e6
--- /dev/null
+++ b/frontend/public/assets/js/radar.js
@@ -0,0 +1,223 @@
+const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+function deltaE(rgbA, rgbB) {
+ let labA = rgb2lab(rgbA);
+ let labB = rgb2lab(rgbB);
+ let deltaL = labA[0] - labB[0];
+ let deltaA = labA[1] - labB[1];
+ let deltaB = labA[2] - labB[2];
+ let c1 = Math.sqrt(labA[1] * labA[1] + labA[2] * labA[2]);
+ let c2 = Math.sqrt(labB[1] * labB[1] + labB[2] * labB[2]);
+ let deltaC = c1 - c2;
+ let deltaH = deltaA * deltaA + deltaB * deltaB - deltaC * deltaC;
+ deltaH = deltaH < 0 ? 0 : Math.sqrt(deltaH);
+ let sc = 1.0 + 0.045 * c1;
+ let sh = 1.0 + 0.015 * c1;
+ let deltaLKlsl = deltaL / (1.0);
+ let deltaCkcsc = deltaC / (sc);
+ let deltaHkhsh = deltaH / (sh);
+ let i = deltaLKlsl * deltaLKlsl + deltaCkcsc * deltaCkcsc + deltaHkhsh * deltaHkhsh;
+ return i < 0 ? 0 : Math.sqrt(i);
+}
+
+function rgb2lab(rgb){
+ let r = rgb[0] / 255, g = rgb[1] / 255, b = rgb[2] / 255, x, y, z;
+ r = (r > 0.04045) ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92;
+ g = (g > 0.04045) ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92;
+ b = (b > 0.04045) ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92;
+ x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
+ y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000;
+ z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
+ x = (x > 0.008856) ? Math.pow(x, 1/3) : (7.787 * x) + 16/116;
+ y = (y > 0.008856) ? Math.pow(y, 1/3) : (7.787 * y) + 16/116;
+ z = (z > 0.008856) ? Math.pow(z, 1/3) : (7.787 * z) + 16/116;
+ return [(116 * y) - 16, 500 * (x - y), 200 * (y - z)]
+}
+
+function hexToRgb(hex) {
+ var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return result ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16)
+ } : null;
+}
+
+
+const compColor = (isDarkMode) ? hexToRgb("#000000") : hexToRgb("#FFFFFF")
+let dEBGTeam = deltaE([primaryColor.r, primaryColor.g, primaryColor.b], [compColor.r, compColor.g, compColor.b])
+let dEBGAlt = deltaE([altColor.r, altColor.g, altColor.b], [compColor.r, compColor.g, compColor.b])
+
+var teamColor = primaryColor;
+if (dEBGTeam > 49) {
+ teamColor = primaryColor
+ console.log(`set team color to primary ${JSON.stringify(primaryColor)} because no similarity to background`)
+} else if (dEBGTeam <= 49 && dEBGAlt > 49) {
+ teamColor = altColor
+ console.log(`set team color to alt ${JSON.stringify(altColor)} because of similarity to background`)
+} else if (dEBGTeam <= 49 && dEBGAlt <= 49) {
+ teamColor = hexToRgb("#CCCCCC")
+ console.log(`set team color to emergency ${JSON.stringify(teamColor)} because of both colors' similarity to background`)
+} else {
+ teamColor = primaryColor
+ console.log(`set team color to primary ${JSON.stringify(primaryColor)} because backup`)
+}
+
+Chart.defaults.global.defaultFontColor = (isDarkMode) ? '#e8e6e3' : '#525252';
+
+function generatePercentile(input, max = 134) {
+ if (!input) {
+ return 0;
+ }
+ let value = (parseFloat(max) - parseFloat(input)) / parseFloat(max)
+ let step = Math.round(value * 100)
+ return step
+}
+
+function retrieveValue(dictionary, key) {
+ const subKeys = key.split('.')
+ let sub = dictionary;
+ for (const k of subKeys) {
+ // if (!sub) {
+ // console.log(k)
+ // }
+ sub = sub[k];
+ }
+ return sub;
+}
+
+function generateRadarPercentiles(titleKey) {
+ const key = titleKey.toLocaleLowerCase()
+ return [
+ { title: 'EPA/Play', key: "overall.epaPerPlay", percentile: generatePercentile(breakdown[key].overall.epaPerPlayRank, 134), value: roundNumber(retrieveValue(breakdown[key], "overall.epaPerPlay"), 2, 2) },
+ { title: 'Early Downs EPA/Play', key: "overall.earlyDownEPAPerPlay", percentile: generatePercentile(breakdown[key].overall.earlyDownEPAPerPlayRank, 134), value: roundNumber(retrieveValue(breakdown[key], "overall.earlyDownEPAPerPlay"), 2, 2) },
+ { title: 'Late Downs SR%', key: "overall.lateDownSuccessRate", percentile: generatePercentile(breakdown[key].overall.lateDownSuccessRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "overall.lateDownSuccessRate") * 100, 2, 1)}%` },
+ { title: 'Avg Distance (3rd)', key: "overall.thirdDownDistance", percentile: generatePercentile(breakdown[key].overall.thirdDownDistanceRank, 134), value: roundNumber(retrieveValue(breakdown[key], "overall.thirdDownDistance"), 2, 2) },
+ { title: 'Rush EPA/Play', key: "rushing.epaPerPlay", percentile: generatePercentile(breakdown[key].rushing.epaPerPlayRank, 134), value: roundNumber(retrieveValue(breakdown[key], "rushing.epaPerPlay"), 2, 2) },
+ { title: 'Stuff %', key: "rushing.stuffedPlayRate", percentile: generatePercentile(breakdown[key].rushing.stuffedPlayRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "rushing.stuffedPlayRate") * 100, 2, 1)}%` },
+ { title: 'Line Yards', key: "rushing.lineYards", percentile: generatePercentile(breakdown[key].rushing.lineYardsRank, 134), value: roundNumber(retrieveValue(breakdown[key], "rushing.lineYards"), 2, 2) },
+ { title: 'Opportunity %', key: "rushing.opportunityRate", percentile: generatePercentile(breakdown[key].rushing.opportunityRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "rushing.opportunityRate") * 100, 2, 1)}%` },
+ { title: 'Explosive %', key: "overall.explosiveRate", percentile: generatePercentile(breakdown[key].overall.explosiveRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "overall.explosiveRate") * 100, 2, 1)}%` },
+ { title: 'Pass Expl %', key: "passing.explosiveRate", percentile: generatePercentile(breakdown[key].passing.explosiveRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "passing.explosiveRate") * 100, 2, 1)}%` },
+ { title: 'Rush Expl %', key: "rushing.explosiveRate", percentile: generatePercentile(breakdown[key].rushing.explosiveRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "rushing.explosiveRate") * 100, 2, 1)}%` },
+ { title: 'Non-Expl EPA/Play', key: "overall.nonExplosiveEpaPerPlay", percentile: generatePercentile(breakdown[key].overall.nonExplosiveEpaPerPlayRank, 134), value: roundNumber(retrieveValue(breakdown[key], "overall.nonExplosiveEpaPerPlay"), 2, 2) },
+ { title: 'Pass EPA/Play', key: "passing.epaPerPlay", percentile: generatePercentile(breakdown[key].passing.epaPerPlayRank, 134), value: roundNumber(retrieveValue(breakdown[key], "passing.epaPerPlay"), 2, 2) },
+ { title: 'Yds/DB', key: "passing.yardsPerPlay", percentile: generatePercentile(breakdown[key].passing.yardsPerPlayRank, 134), value: roundNumber(retrieveValue(breakdown[key], "passing.yardsPerPlay"), 2, 2) },
+ { title: 'Pass SR%', key: "passing.successRate", percentile: generatePercentile(breakdown[key].passing.successRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "passing.successRate") * 100, 2, 1)}%` },
+ { title: 'Havoc %', key: "overall.havocRate", percentile: generatePercentile(breakdown[key].overall.havocRateRank, 134), value: `${roundNumber(retrieveValue(breakdown[key], "overall.havocRate") * 100, 2, 1)}%` },
+ ]
+}
+
+function generateDataset(titleKey) {
+ const teamPercentilesDataset = generateRadarPercentiles(titleKey)
+ return {
+ labels: teamPercentilesDataset.map(p => p.title),
+ datasets: [{
+ label: teamPercentilesDataset.map(p => `Raw: ${p.value}`),
+ data: teamPercentilesDataset.map(p => p.percentile),
+ fill: true,
+ backgroundColor: `rgba(${teamColor.r}, ${teamColor.g}, ${teamColor.b}, 0.2)`,
+ borderColor: `rgb(${teamColor.r}, ${teamColor.g}, ${teamColor.b})`,
+ pointBackgroundColor: `rgb(${teamColor.r}, ${teamColor.g}, ${teamColor.b})`,
+ pointBorderColor: '#fff',
+ pointHoverBackgroundColor: '#fff',
+ pointHoverBorderColor: `rgb(${teamColor.r}, ${teamColor.g}, ${teamColor.b})`
+ }]
+ };
+}
+
+ function getCurrentViewport() {
+ // https://stackoverflow.com/a/8876069
+ const width = Math.max(
+ document.documentElement.clientWidth,
+ window.innerWidth || 0
+ )
+ if (width <= 576) return 'xs'
+ if (width <= 768) return 'sm'
+ if (width <= 992) return 'md'
+ if (width <= 1200) return 'lg'
+ return 'xl'
+ }
+
+ Chart.plugins.register([
+ {
+ afterDraw: (chart) => {
+ let viewport = getCurrentViewport()
+ if (viewport == "xl" || viewport == "lg") {
+ let sizeWidth = chart.ctx.canvas.clientWidth;
+ let sizeHeight = chart.ctx.canvas.clientHeight;
+ let imgSize = 25.0;
+
+ chart.ctx.save()
+ chart.ctx.textAlign = "right"
+ chart.ctx.font = "8px Helvetica";
+ chart.ctx.fillStyle = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#e8e6e3' : '#525252';
+ chart.ctx.fillText("Metrics shown as percentiles. From GameOnPaper.com, by Akshay Easwaran (@akeaswaran)\nand Saiem Gilani (@saiemgilani)", sizeWidth - (imgSize / 4.0), 7.75 * (sizeHeight / 8))
+ chart.ctx.restore();
+ }
+ }
+ }
+]);
+
+function getNumberWithOrdinal(n) {
+ var s = ["th", "st", "nd", "rd"];
+ v = n % 100;
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
+}
+
+function roundNumber(value, power10, fixed) {
+ return (Math.round(parseFloat(value || 0) * (Math.pow(10, power10))) / (Math.pow(10, power10))).toFixed(fixed)
+}
+
+function generateConfig(data, title) {
+ return {
+ type: 'radar',
+ data: data,
+ fill: true,
+ options: {
+ title: {
+ display: true,
+ text: title,
+ fontColor: (isDarkMode) ? "white" : "black",
+ fontSize: 15,
+ fontFamily: '"Chivo", "Fira Mono", serif'
+ },
+ legend: false,
+ responsive: true,
+ elements: {
+ line: {
+ borderWidth: 3
+ }
+ },
+ tooltips: {
+ callbacks: {
+ title: function(tooltipItem, data) {
+ console.log(tooltipItem)
+ console.log(data.labels)
+ const label = data.labels[tooltipItem[0].index]
+ return `${label}: ${getNumberWithOrdinal(tooltipItem[0].value)} %tile`
+ },
+ label: function(tooltipItem, data) {
+ return data.datasets[tooltipItem.datasetIndex].label[tooltipItem.index]
+ }
+ }
+ },
+ scale: {
+ angleLines: {
+ color: (isDarkMode) ? "#8D8D8D" : "#E5E5E5"
+ },
+ gridLines: {
+ color: (isDarkMode) ? "#8D8D8D" : "#E5E5E5"
+ },
+ ticks: {
+ min: 0,
+ max: 100,
+ suggestedMin: 0,
+ suggestedMax: 100,
+ stepSize: 25,
+ backdropColor: (isDarkMode) ? 'rgb(56, 61, 63)' : 'rgba(255, 255, 255, 0.75)'
+ }
+ }
+ },
+ }
+}
\ No newline at end of file
diff --git a/frontend/views/pages/cfb/team.ejs b/frontend/views/pages/cfb/team.ejs
index 6dc9665..d229679 100644
--- a/frontend/views/pages/cfb/team.ejs
+++ b/frontend/views/pages/cfb/team.ejs
@@ -395,160 +395,14 @@ function hexToRgb(hex) {
+
+