One Million Digits of Pi
Palindromes in the first million digits of Pi.
By Manish Datt
TidyTuesday dataset of 2026-03-24
Pi Digits Data
Palindrome graph
Total Palindromes
-
Unique Lengths
-
Max Length
-
Plotting code
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pi Digits Table</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Tabulator CSS -->
<link href="https://unpkg.com/tabulator-tables@6.2.0/dist/css/tabulator.min.css" rel="stylesheet">
<!-- Tabulator JS -->
<script type="text/javascript" src="https://unpkg.com/tabulator-tables@6.2.0/dist/js/tabulator.min.js"></script>
<!-- Highcharts -->
<script src="https://cdn.jsdelivr.net/npm/highcharts@11/highcharts.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/highcharts@11/modules/broken-axis.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/highcharts@11/modules/exporting.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/highcharts@11/modules/export-data.js" defer></script>
<style>
/* Tailwind-styled Tabulator */
.tabulator {
width: 100%;
height: auto;
}
.tabulator-row {
border-bottom: 1px solid #e5e7eb;
}
.tabulator-header-cell {
background-color: #f3f4f6;
font-weight: 600;
color: #1f2937;
border-bottom: 2px solid #d1d5db;
}
.tabulator-cell {
padding: 12px;
color: #374151;
}
.tabulator-row:hover {
background-color: #f9fafb;
}
#pi-digits-table {
width: 100%;
}
</style>
</head>
<body class="bg-gray-50">
<div class="min-h-screen p-8">
<div class="max-w-6xl mx-auto w-full overflow-hidden">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Pi Digits Data</h1>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div id="pi-digits-table"></div>
</div>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-gray-900">Palindrome graph</h2>
<button id="download-chart-btn" class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200">
Download SVG
</button>
</div>
<div class="bg-white rounded-lg shadow-md p-6" style="max-width: 800px;">
<div id="palindrome-nontrivial-chart" style="height: 400px;"></div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mt-6 w-full">
<div class="bg-white rounded-lg shadow-md p-4 border-l-4 border-blue-500">
<p class="text-gray-600 text-sm font-medium">Total Palindromes</p>
<p class="text-3xl font-bold text-gray-900" id="total-palindromes">-</p>
</div>
<div class="bg-white rounded-lg shadow-md p-4 border-l-4 border-green-500">
<p class="text-gray-600 text-sm font-medium">Unique Lengths</p>
<p class="text-3xl font-bold text-gray-900" id="unique-lengths">-</p>
</div>
<div class="bg-white rounded-lg shadow-md p-4 border-l-4 border-purple-500">
<p class="text-gray-600 text-sm font-medium">Max Length</p>
<p class="text-3xl font-bold text-gray-900" id="max-length">-</p>
</div>
</div>
</div>
</div>
</div>
<script>
// ===== GLOBAL CHART REFERENCE =====
let globalChart = null;
// ===== PALINDROME DETECTION FUNCTIONS =====
function isTrivialPalindrome(s) {
// A trivial palindrome has all characters the same
const uniqueChars = new Set(s);
return uniqueChars.size === 1;
}
function expandAroundCenter(str, left, right) {
// Expand around center and return palindrome
while (left >= 0 && right < str.length && str[left] === str[right]) {
left--;
right++;
}
return str.substring(left + 1, right);
}
function findAllNontrivialPalindromes(str, minLength = 2) {
// Find all non-trivial palindromes (excluding repeated digits)
const palindromes = [];
let trivialCount = 0;
for (let i = 0; i < str.length; i++) {
// Odd length palindromes (single character center)
const p1 = expandAroundCenter(str, i, i);
if (p1.length >= minLength) {
if (isTrivialPalindrome(p1)) {
trivialCount++;
} else {
palindromes.push({
palindrome: p1,
length: p1.length,
position: str.indexOf(p1)
});
}
}
// Even length palindromes (two character center)
const p2 = expandAroundCenter(str, i, i + 1);
if (p2.length >= minLength) {
if (isTrivialPalindrome(p2)) {
trivialCount++;
} else {
palindromes.push({
palindrome: p2,
length: p2.length,
position: str.indexOf(p2)
});
}
}
if ((i + 1) % 100000 === 0) {
console.log(`Searched up to position ${i + 1}... Found ${palindromes.length} non-trivial palindromes`);
}
}
console.log(`Total trivial palindromes excluded: ${trivialCount}`);
return palindromes;
}
function calculatePalindromeStats(palindromes) {
// Calculate frequency by length and find longest
const frequencyByLength = {};
let longestPalindrome = '';
let maxLength = 0;
const longestByLength = {}; // Track longest palindrome for each length
const positionByLength = {}; // Track position for each length
for (const pal of palindromes) {
const len = pal.length;
frequencyByLength[len] = (frequencyByLength[len] || 0) + 1;
// Track longest palindrome for this length with position
if (!longestByLength[len]) {
longestByLength[len] = pal.palindrome;
positionByLength[len] = {
start: pal.position,
end: pal.position + len - 1
};
}
if (len > maxLength) {
maxLength = len;
longestPalindrome = pal.palindrome;
}
}
return {
frequencyByLength,
longestPalindrome,
maxLength,
longestByLength,
positionByLength,
total: palindromes.length
};
}
// ===== MAIN EXECUTION =====
fetch("pi_digits.csv")
.then(response => response.text())
.then(csvText => {
// Parse CSV manually
const lines = csvText.trim().split('\n');
const headers = lines[0].split(',');
const data = lines.slice(1).map(line => {
const values = line.split(',');
return {
digit_position: parseInt(values[0]),
digit: parseInt(values[1])
};
});
// Initialize Tabulator table
var table = new Tabulator("#pi-digits-table", {
data: data,
pagination: "local",
paginationSize: 10,
layout: "fitData",
columns: [
{ title: "Position", field: "digit_position", sorter: "number", width: 120 },
{ title: "Digit", field: "digit", sorter: "number", width: 100 }
],
headerSort: true
});
// ===== PALINDROME ANALYSIS - Compute using JS =====
console.log('Computing non-trivial palindromes from CSV data...');
// Create decimal string from CSV data
const piDecimalStr = data.map(d => d.digit).join('');
console.log(`Total pi digits: ${piDecimalStr.length}`);
// Find all non-trivial palindromes
const startTime = performance.now();
const allNontrivialPalindromes = findAllNontrivialPalindromes(piDecimalStr, 2);
const endTime = performance.now();
console.log(`Found ${allNontrivialPalindromes.length} non-trivial palindromes in ${(endTime - startTime).toFixed(2)}ms`);
// Calculate statistics
const stats = calculatePalindromeStats(allNontrivialPalindromes);
console.log(`Longest non-trivial palindrome: ${stats.longestPalindrome} (${stats.maxLength} digits)`);
// Prepare frequency data
const frequencyNontrivial = stats.frequencyByLength;
const lengthsNT = Object.keys(frequencyNontrivial).map(Number).sort((a, b) => a - b);
// ===== NON-TRIVIAL PALINDROMES CHART =====
const chartLengthsNT = lengthsNT.map(String);
const chartFrequenciesNT = lengthsNT.map(len => frequencyNontrivial[len]);
// Prepare data for Highcharts
const chartData = chartLengthsNT.map((length, index) => ({
name: length,
y: chartFrequenciesNT[index],
percentage: ((chartFrequenciesNT[index] / stats.total) * 100).toFixed(2)
}));
// Ensure Highcharts is loaded before using it
const createChart = () => {
if (typeof Highcharts !== 'undefined') {
globalChart = Highcharts.chart('palindrome-nontrivial-chart', {
chart: {
type: 'column',
backgroundColor: '#F4FFF0',
marginTop: 20
},
title: {
text: 'There are about 100K palindromes (excluding repeated digits) in the first million digits of pi.',
margin: 1,
},
xAxis: {
categories: chartLengthsNT,
title: {
text: 'Palindrome Length'
},
crosshair: true
},
yAxis: {
title: {
text: 'Counts'
},
tickInterval: 10000,
endOnTick: false,
breaks: [{
from: 10000,
to: 70000,
breakSize: 2000
}],
labels: {
formatter: function() {
const formatted = (this.value / 1000) + 'K';
if (this.value === 10000 || this.value === 70000) {
return '<b>' + formatted + '</b>';
}
return formatted;
}
}
},
tooltip: {
headerFormat: '<b>Length: {point.x}</b><br/>',
pointFormat: 'Count: {point.y:,.0f}<br/>Percentage: {point.percentage}%',
shared: false
},
legend: {
enabled: false
},
credits: {
enabled: false
},
exporting: {
enabled: false
},
plotOptions: {
column: {
dataLabels: {
enabled: true,
formatter: function() {
return this.y.toLocaleString();
},
style: {
fontSize: '14px',
fontWeight: 'bold'
}
},
colorByPoint: false
}
},
series: [{
name: 'Frequency',
data: chartData.map(d => d.y),
color: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
stops: [
[0, '#f59e0b'],
[0.5, '#ec4899'],
[1, '#8b5cf6']
]
}
}]
});
// Add annotation text using renderer
// 13-digit palindrome (top)
const pos13 = stats.positionByLength[stats.maxLength];
globalChart.renderer.text(
stats.longestPalindrome,
globalChart.plotLeft + globalChart.plotWidth - 50, // absolute x (right side)
globalChart.plotTop + 150, // absolute y (top)
false // html: false - use plain SVG text
).attr({
'text-anchor': 'end',
'font-size': '14px',
'font-weight': 'bold',
'fill': '#333333'
}).add();
// Add position label below the palindrome
globalChart.renderer.text(
`[${pos13.start+1}:${pos13.end+1}]`,
globalChart.plotLeft + globalChart.plotWidth - 50, // absolute x (right side)
globalChart.plotTop + 165, // absolute y (below text)
false // html: false - use plain SVG text
).attr({
'text-anchor': 'end',
'font-size': '12px',
'fill': '#333333'
}).add();
// 12-digit palindrome (below 13-digit)
if (stats.longestByLength[12]) {
const pos12 = stats.positionByLength[12];
globalChart.renderer.text(
stats.longestByLength[12],
globalChart.plotLeft + globalChart.plotWidth - 100, // absolute x (right side)
globalChart.plotTop + 215, // absolute y (below top)
false // html: false - use plain SVG text
).attr({
'text-anchor': 'end',
'font-size': '14px',
'font-weight': 'bold',
'fill': '#333333'
}).add();
// Add position label below the palindrome
globalChart.renderer.text(
`[${pos12.start+1}:${pos12.end+1}]`,
globalChart.plotLeft + globalChart.plotWidth - 100, // absolute x (right side)
globalChart.plotTop + 230, // absolute y (below text)
false // html: false - use plain SVG text
).attr({
'text-anchor': 'end',
'font-size': '12px',
'fill': '#333333'
}).add();
}
// Delay arrow drawing to ensure chart is fully rendered
setTimeout(() => {
// Draw arrow from 13-digit bar to annotation
const barIndex13 = lengthsNT.indexOf(stats.maxLength); // Find actual index of 13-digit
console.log(`Drawing 13 arrow - barIndex13: ${barIndex13}, points length: ${globalChart.series[0].points.length}`);
if (barIndex13 >= 0 && globalChart.series[0].points[barIndex13]) {
const barPoint13 = globalChart.series[0].points[barIndex13];
console.log(`barPoint13 plotX: ${barPoint13.plotX}, plotY: ${barPoint13.plotY}`);
const arrowStartX13 = globalChart.plotLeft + barPoint13.plotX;
const arrowStartY13 = globalChart.plotTop + barPoint13.plotY - 25;
const arrowEndX13 = globalChart.plotLeft + globalChart.plotWidth - 50;
const arrowEndY13 = globalChart.plotTop + 170;
// Draw line for 13-digit
globalChart.renderer.path([
'M', arrowStartX13, arrowStartY13,
'L', arrowEndX13, arrowEndY13
]).attr({
stroke: '#666666',
'stroke-width': 2,
'stroke-dasharray': '5,5'
}).add();
// Draw arrowhead for 13-digit
let dx13 = arrowEndX13 - arrowStartX13;
let dy13 = arrowEndY13 - arrowStartY13;
let angle13 = Math.atan2(dy13, dx13);
const arrowSize = 8;
let tipX13 = arrowEndX13;
let tipY13 = arrowEndY13;
let leftX13 = tipX13 - arrowSize * Math.cos(angle13 - Math.PI / 6);
let leftY13 = tipY13 - arrowSize * Math.sin(angle13 - Math.PI / 6);
let rightX13 = tipX13 - arrowSize * Math.cos(angle13 + Math.PI / 6);
let rightY13 = tipY13 - arrowSize * Math.sin(angle13 + Math.PI / 6);
globalChart.renderer.path([
'M', leftX13, leftY13,
'L', tipX13, tipY13,
'L', rightX13, rightY13,
'Z'
]).attr({
fill: '#666666',
stroke: '#666666'
}).add();
} else {
console.log('13-digit bar point not found');
}
// 12-digit arrow
if (stats.longestByLength[12]) {
const barIndex12 = lengthsNT.indexOf(12); // Find actual index of 12-digit
console.log(`Drawing 12 arrow - barIndex12: ${barIndex12}, points length: ${globalChart.series[0].points.length}`);
if (barIndex12 >= 0 && globalChart.series[0].points[barIndex12]) {
const barPoint12 = globalChart.series[0].points[barIndex12];
console.log(`barPoint12 plotX: ${barPoint12.plotX}, plotY: ${barPoint12.plotY}`);
const arrowStartX12 = globalChart.plotLeft + barPoint12.plotX;
const arrowStartY12 = globalChart.plotTop + barPoint12.plotY - 25;
const arrowEndX12 = globalChart.plotLeft + globalChart.plotWidth - 100;
const arrowEndY12 = globalChart.plotTop + 240;
// Draw line for 12-digit
globalChart.renderer.path([
'M', arrowStartX12, arrowStartY12,
'L', arrowEndX12, arrowEndY12
]).attr({
stroke: '#666666',
'stroke-width': 2,
'stroke-dasharray': '5,5'
}).add();
// Draw arrowhead for 12-digit
let dx12 = arrowEndX12 - arrowStartX12;
let dy12 = arrowEndY12 - arrowStartY12;
let angle12 = Math.atan2(dy12, dx12);
const arrowSize = 8;
let tipX12 = arrowEndX12;
let tipY12 = arrowEndY12;
let leftX12 = tipX12 - arrowSize * Math.cos(angle12 - Math.PI / 6);
let leftY12 = tipY12 - arrowSize * Math.sin(angle12 - Math.PI / 6);
let rightX12 = tipX12 - arrowSize * Math.cos(angle12 + Math.PI / 6);
let rightY12 = tipY12 - arrowSize * Math.sin(angle12 + Math.PI / 6);
globalChart.renderer.path([
'M', leftX12, leftY12,
'L', tipX12, tipY12,
'L', rightX12, rightY12,
'Z'
]).attr({
fill: '#666666',
stroke: '#666666'
}).add();
} else {
console.log('12-digit bar point not found');
}
}
}, 100); // Wait 100ms for chart to fully render
// Populate summary cards
document.getElementById('total-palindromes').textContent = stats.total.toLocaleString();
document.getElementById('unique-lengths').textContent = Object.keys(stats.frequencyByLength).length;
document.getElementById('max-length').textContent = stats.maxLength;
// Add download button event listener
document.getElementById('download-chart-btn').addEventListener('click', () => {
if (globalChart) {
/**
* Highcharts getSVG() may not include all dynamically added renderer elements
* (arrows and text annotations). This is a known limitation.
* The rendered chart is visible on screen but export doesn't capture
* elements added via chart.renderer after chart initialization.
*/
globalChart.downloadSVG = globalChart.downloadSVG || function() {
// Get the chart container and clone the current SVG with all rendered elements
const chartSVG = this.getSVG();
// Parse the SVG to create a more complete export
let svgContent = chartSVG;
// Get all SVG elements from the chart container
const chartContainer = document.getElementById('palindrome-nontrivial-chart');
if (chartContainer) {
const existingSVG = chartContainer.querySelector('svg');
if (existingSVG) {
// The visible SVG includes all renderer elements
svgContent = existingSVG.outerHTML;
}
}
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'palindrome-chart.svg';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
globalChart.downloadSVG();
}
});
} else {
// Retry if Highcharts not loaded yet
setTimeout(createChart, 100);
}
};
createChart();
})
.catch(error => console.error("Error loading CSV:", error));
</script>
</body>