Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
<title>Data Analysis</title> | |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script> | |
<style> | |
* { margin: 0; padding: 0; box-sizing: border-box; } | |
html, body { max-width: 100vw; } | |
body { | |
font-family: 'Inter', sans-serif; | |
background-color: #ffffff; | |
height: 100vh; | |
display: flex; | |
flex-direction: column; | |
overflow: hidden; | |
} | |
header { | |
background: linear-gradient(to right, #4299e1, #63b3ed); | |
padding: 24px 30px; | |
display: flex; justify-content: space-between; align-items: center; | |
box-shadow: 0 4px 8px rgba(0,0,0,0.08); | |
flex-shrink: 0; | |
min-height: 90px; | |
} | |
.filename { | |
color: white; | |
font-size: 1.65rem; | |
font-weight: 700; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
max-width: 60vw; | |
letter-spacing: 0.3px; | |
} | |
.menu-buttons { display: flex; gap: 14px; } | |
.menu-buttons button { | |
background: linear-gradient(to bottom right, #63b3ed, #4299e1); | |
color: white; | |
font-size: 1.15rem; | |
padding: 12px 24px; | |
border-radius: 8px; | |
border: none; | |
cursor: pointer; | |
font-weight: 600; | |
transition: all 0.3s ease; | |
box-shadow: 0 2px 6px rgba(0,0,0,0.1); | |
letter-spacing: 0.3px; | |
} | |
.menu-buttons button:hover { | |
transform: translateY(-1px); | |
box-shadow: 0 4px 10px rgba(0,0,0,0.15); | |
} | |
.menu-buttons button.active { | |
background: white ; | |
color: #2b6cb0 ; | |
border: 2px solid white; | |
box-shadow: 0 2px 8px rgba(0,0,0,0.12); | |
} | |
main { padding: 0; flex: 1; overflow: auto; display: flex; flex-direction: column; background-color: #ffffff; } | |
.table-wrap { width: 100vw; height: 100%; overflow: auto; padding: 24px; background-color: #ffffff; } | |
.hidden { display: none; } | |
.box { | |
text-align: center; padding: 36px; background: white; border-radius: 8px; | |
box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin: 36px auto; width: min(720px, 92vw); | |
} | |
.status { font-weight: 600; } | |
.status.loading { color: #22577A; } | |
.status.error { color: #e53e3e; } | |
/* Table */ | |
.csv-table { | |
border-collapse: collapse; border: 3px solid #000; table-layout: fixed; | |
width: max-content; min-width: calc(100vw - 48px); background-color: #ffffff; | |
} | |
.csv-table thead { background: #ffffff; border-bottom: 3px solid #000; position: sticky; top: 0; z-index: 5; } | |
.csv-table th, .csv-table td { | |
padding: 10px 12px; border: 2px solid #000; font-size: 0.92rem; color: #2d3748; | |
white-space: nowrap; width: 9cm; min-width: 9cm; max-width: 9cm; overflow: hidden; text-overflow: ellipsis; background: #ffffff; | |
} | |
.csv-table tbody tr:hover td { background-color: #f9f9f9; } | |
/* Filter row styling */ | |
.csv-table .filter-row td { | |
background: #f7fafc; | |
padding: 0 ; | |
} | |
/* Spacer row styling - 5.3cm height */ | |
.csv-table .spacer-row td { | |
height: 5.3cm ; | |
padding: 8px ; | |
vertical-align: middle; | |
background: #fff; | |
position: relative; | |
} | |
/* Chart container styling */ | |
.chart-container { | |
width: 100%; | |
height: 100%; | |
position: relative; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.chart-container canvas { | |
max-width: 100% ; | |
max-height: 100% ; | |
} | |
/* Filter trigger */ | |
.dd-trigger { | |
display: block; | |
width: 100%; | |
height: 100%; | |
padding: 10px 32px 10px 12px; | |
font-size: 0.92rem; | |
border: 0; | |
border-radius: 0; | |
background: #fff; | |
outline: none; | |
cursor: pointer; | |
text-align: left; | |
box-sizing: border-box; | |
position: relative; | |
} | |
/* Down arrow icon */ | |
.dd-trigger::after { | |
content: "▼"; | |
position: absolute; | |
right: 10px; | |
top: 50%; | |
transform: translateY(-50%); | |
font-size: 0.7rem; | |
color: #333; | |
pointer-events: none; | |
} | |
/* Floating menu */ | |
.float-menu { | |
position: fixed; | |
background: #fff; | |
border: 2px solid #000; | |
border-radius: 6px; | |
max-height: 260px; | |
overflow: auto; | |
overscroll-behavior: contain; | |
z-index: 999999; | |
box-shadow: 0 10px 24px rgba(0,0,0,0.12); | |
display: none; | |
min-width: 200px; | |
} | |
.float-option { | |
padding: 8px 10px; | |
cursor: pointer; | |
white-space: nowrap; | |
user-select: none; | |
border-bottom: 1px solid #000; | |
} | |
.float-option:last-child { border-bottom: none; } | |
.float-option:hover { background: #8A8AFF; } | |
/* Searchable dropdown styles */ | |
.searchable-dropdown-wrapper { | |
position: relative; | |
flex: 1; | |
} | |
.searchable-dropdown-input { | |
width: 100%; | |
padding: 12px 24px 12px 12px; | |
font-size: 1rem; | |
border: 2px solid #cbd5e0; | |
border-radius: 8px; | |
background: white; | |
cursor: pointer; | |
outline: none; | |
} | |
.searchable-dropdown-input::placeholder { | |
color: #718096; | |
} | |
.searchable-dropdown-input:focus { | |
border-color: #667eea; | |
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |
} | |
.searchable-dropdown-arrow { | |
position: absolute; | |
right: 8px; | |
top: 50%; | |
transform: translateY(-50%); | |
pointer-events: none; | |
font-size: 0.6rem; | |
color: #4a5568; | |
transition: transform 0.2s; | |
} | |
.searchable-dropdown-arrow.open { | |
transform: translateY(-50%) rotate(180deg); | |
} | |
.searchable-dropdown-menu { | |
position: absolute; | |
top: calc(100% + 4px); | |
left: 0; | |
right: 0; | |
background: white; | |
border: 1px solid #e2e8f0; | |
border-radius: 8px; | |
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); | |
z-index: 1000; | |
display: none; | |
max-height: 300px; | |
overflow: hidden; | |
} | |
.searchable-dropdown-menu.open { | |
display: block; | |
} | |
.searchable-dropdown-search { | |
padding: 10px; | |
border-bottom: 1px solid #e2e8f0; | |
background: #f7fafc; | |
} | |
.searchable-dropdown-search input { | |
width: 100%; | |
padding: 8px 12px; | |
border: 1px solid #cbd5e0; | |
border-radius: 4px; | |
font-size: 0.9rem; | |
outline: none; | |
} | |
.searchable-dropdown-search input:focus { | |
border-color: #667eea; | |
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); | |
} | |
.searchable-dropdown-options { | |
max-height: 240px; | |
overflow-y: auto; | |
overscroll-behavior: contain; | |
} | |
.searchable-dropdown-option { | |
padding: 10px 12px; | |
cursor: pointer; | |
transition: background 0.2s; | |
border-bottom: 1px solid #e2e8f0; | |
} | |
.searchable-dropdown-option:last-child { | |
border-bottom: none; | |
} | |
.searchable-dropdown-option:hover { | |
background: #8A8AFF; | |
color: white; | |
} | |
.searchable-dropdown-option.selected { | |
background: #e6f3ff; | |
font-weight: 600; | |
} | |
.searchable-dropdown-option.hidden { | |
display: none; | |
} | |
.searchable-dropdown-no-results { | |
padding: 20px; | |
text-align: center; | |
color: #718096; | |
font-style: italic; | |
} | |
/* Comparison chart area */ | |
.comparison-chart-area { | |
padding: 30px; | |
background: linear-gradient(135deg, #f7fafc 0%, #ffffff 100%); | |
border-radius: 12px; | |
box-shadow: 0 4px 12px rgba(0,0,0,0.08); | |
margin-left: 40px; | |
height: fit-content; | |
} | |
.chart-section { | |
background: white; | |
border-radius: 8px; | |
padding: 20px; | |
box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
} | |
.chart-title { | |
font-size: 1.5rem; | |
font-weight: 700; | |
color: #2d3748; | |
margin-bottom: 20px; | |
text-align: center; | |
} | |
#comparisonChart { | |
max-height: 500px; | |
} | |
.no-selection-message { | |
text-align: center; | |
color: #718096; | |
font-size: 1.1rem; | |
padding: 40px; | |
background: #f7fafc; | |
border-radius: 8px; | |
margin: 20px 0; | |
} | |
</style> | |
</head> | |
<body> | |
<header> | |
<div class="filename" id="filename">Loading…</div> | |
<div class="menu-buttons"> | |
<button id="prepareBtn" class="active" onclick="showPrepare()">Prepare</button> | |
<button id="predictBtn" onclick="showPredict()">Predict</button> | |
<button id="reportBtn" onclick="showReport()">Report</button> | |
</div> | |
</header> | |
<main> | |
<section id="prepareView" class="table-wrap"> | |
<div id="tableMount"> | |
<div class="box"> | |
<div class="status loading">Loading CSV data…</div> | |
</div> | |
</div> | |
</section> | |
<section id="predictView" class="table-wrap hidden"> | |
<div style="padding: 36px; max-width: 100%; margin: 0; display: flex; gap: 40px;"> | |
<div style="flex: 0 0 auto;"> | |
<h2 style="color: #7c3aed; font-size: 2.5rem; font-weight: 700; margin-bottom: 20px; text-align: left; display: inline-block;">Compare your Data:</h2> | |
<hr style="border: none; height: 3px; background-color: #667eea; margin-bottom: 30px; width: 100%; max-width: 410px;"> | |
<div id="dropdownContainer" style="display: flex; flex-direction: column; gap: 15px; max-width: 700px;"> | |
<div style="display: flex; gap: 15px; align-items: center;"> | |
<select class="compare-dropdown type-selector" style="padding: 12px; font-size: 1rem; border: 2px solid #cbd5e0; border-radius: 8px; background: white; cursor: pointer; width: 150px;"> | |
<option value="rows">Rows</option> | |
<option value="columns">Columns</option> | |
</select> | |
<div class="searchable-dropdown-wrapper" style="flex: 1;"> | |
<input type="text" class="searchable-dropdown-input" readonly placeholder="Select or search..." style="width: 100%; padding: 12px 24px 12px 12px; font-size: 1rem; border: 2px solid #cbd5e0; border-radius: 8px; background: white; cursor: pointer; outline: none;"> | |
<span class="searchable-dropdown-arrow">▼</span> | |
<div class="searchable-dropdown-menu"> | |
<div class="searchable-dropdown-search"> | |
<input type="text" placeholder="Search..." class="search-input"> | |
</div> | |
<div class="searchable-dropdown-options"></div> | |
</div> | |
</div> | |
<button class="delete-btn" style="width: 35px; height: 35px; border-radius: 50%; background: linear-gradient(135deg, #ff4444, #cc0000); color: white; border: none; cursor: pointer; font-size: 1.2rem; font-weight: bold; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(255, 68, 68, 0.3); transition: all 0.3s ease;">×</button> | |
</div> | |
<div style="display: flex; gap: 15px; align-items: center;"> | |
<select class="compare-dropdown type-selector" style="padding: 12px; font-size: 1rem; border: 2px solid #cbd5e0; border-radius: 8px; background: white; cursor: pointer; width: 150px;"> | |
<option value="columns">Columns</option> | |
<option value="rows">Rows</option> | |
</select> | |
<div class="searchable-dropdown-wrapper" style="flex: 1;"> | |
<input type="text" class="searchable-dropdown-input" readonly placeholder="Select or search..." style="width: 100%; padding: 12px 24px 12px 12px; font-size: 1rem; border: 2px solid #cbd5e0; border-radius: 8px; background: white; cursor: pointer; outline: none;"> | |
<span class="searchable-dropdown-arrow">▼</span> | |
<div class="searchable-dropdown-menu"> | |
<div class="searchable-dropdown-search"> | |
<input type="text" placeholder="Search..." class="search-input"> | |
</div> | |
<div class="searchable-dropdown-options"></div> | |
</div> | |
</div> | |
<button class="delete-btn" style="width: 35px; height: 35px; border-radius: 50%; background: linear-gradient(135deg, #ff4444, #cc0000); color: white; border: none; cursor: pointer; font-size: 1.2rem; font-weight: bold; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(255, 68, 68, 0.3); transition: all 0.3s ease;">×</button> | |
</div> | |
</div> | |
<button id="addDropdownBtn" style="margin-top: 20px; background: linear-gradient(135deg, #97e614 0%, #97e614 100%); color: #000; border: none; padding: 12px 24px; font-size: 1.5rem; font-weight: 700; border-radius: 8px; cursor: pointer; box-shadow: 0 4px 12px rgba(151, 230, 20, 0.2); transition: all 0.3s ease;"> | |
+ | |
</button> | |
</div> | |
<div class="comparison-chart-area" id="comparisonChartArea" style="flex: 1; display: none;"> | |
<div class="chart-section"> | |
<div class="chart-title" id="chartTitle">Comparison Chart</div> | |
<canvas id="comparisonChart"></canvas> | |
</div> | |
</div> | |
</div> | |
</section> | |
<section id="reportView" class="table-wrap hidden"><div class="box">Report view</div></section> | |
</main> | |
<script> | |
// ========== Data Structures (Python-like organization) ========== | |
const DataProcessor = { | |
globalData: { headers: [], rows: [] }, | |
chartInstances: {}, | |
comparisonChartInstance: null, | |
// Color palettes - same as Python dict structure | |
colorPalettes: { | |
categorical: ['#E69F00', '#56B4E9', '#009E73', '#F0E442', '#0072B2', '#D55E00', '#CC79A7', '#000000'], | |
sequential: ['#FFEDA0', '#FED976', '#FEB24C', '#FD8D3C', '#FC4E2A', '#E31A1C', '#BD0026', '#800026'], | |
diverging: ['#053061', '#2166AC', '#4393C3', '#92C5DE', '#F7F7F7', '#FDDBC7', '#F4A582', '#D6604D', '#67001F'], | |
ordinal: ['#FFE6E6', '#FF9999', '#FF3333', '#CC0000'], | |
binary: ['#1B9E77', '#7570B3'], | |
comparison: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F'] | |
}, | |
axisColors: { | |
red: '#FF0000', | |
blue: '#0066FF', | |
green: '#00CC00', | |
purple: '#9900FF', | |
orange: '#FF6600', | |
cyan: '#00CCCC', | |
magenta: '#FF00FF', | |
yellow: '#FFD700' | |
} | |
}; | |
// ========== CSV Parser (like pandas.read_csv) ========== | |
class CSVParser { | |
static parse(text) { | |
const rows = []; | |
let row = [], cell = '', i = 0, inside = false; | |
while (i < text.length) { | |
const ch = text[i]; | |
if (inside) { | |
if (ch === '"') { | |
if (text[i+1] === '"') { | |
cell += '"'; | |
i += 2; | |
continue; | |
} | |
inside = false; | |
i++; | |
continue; | |
} | |
cell += ch; | |
i++; | |
continue; | |
} else { | |
if (ch === '"') { | |
inside = true; | |
i++; | |
continue; | |
} | |
if (ch === ',') { | |
row.push(cell); | |
cell = ''; | |
i++; | |
continue; | |
} | |
if (ch === '\n') { | |
row.push(cell); | |
rows.push(row); | |
row = []; | |
cell = ''; | |
i++; | |
continue; | |
} | |
if (ch === '\r') { | |
i++; | |
continue; | |
} | |
cell += ch; | |
i++; | |
} | |
} | |
row.push(cell); | |
rows.push(row); | |
// Remove empty rows | |
while (rows.length && rows[rows.length-1].every(v => v.trim() === '')) { | |
rows.pop(); | |
} | |
if (!rows.length) return { headers: [], rows: [] }; | |
// Extract headers and create row objects | |
const headers = rows[0].map(h => h.trim()); | |
const data = rows.slice(1).map(r => { | |
const obj = {}; | |
headers.forEach((h, idx) => obj[h] = (r[idx] ?? '').trim()); | |
return obj; | |
}); | |
return { headers, rows: data }; | |
} | |
} | |
// ========== Data Type Detection (like pandas dtype) ========== | |
class DataTypeDetector { | |
static detect(columnName, values) { | |
const nonEmpty = values.filter(v => v && v.trim() !== ''); | |
if (nonEmpty.length === 0) return 'empty'; | |
// Check if binary | |
const uniqueVals = [...new Set(nonEmpty)]; | |
if (uniqueVals.length === 2) return 'binary'; | |
// Check if numeric | |
const numericValues = nonEmpty.filter(v => !isNaN(parseFloat(v)) && isFinite(v)); | |
if (numericValues.length > nonEmpty.length * 0.8) { | |
const nums = numericValues.map(v => parseFloat(v)); | |
// Check if year | |
if (nums.every(n => n >= 1900 && n <= 2100 && Number.isInteger(n))) { | |
return 'time'; | |
} | |
return 'numeric'; | |
} | |
// Check if date/time | |
const datePatterns = [ | |
/^\d{4}-\d{2}-\d{2}/, | |
/^\d{2}\/\d{2}\/\d{4}/, | |
/^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}/ | |
]; | |
if (nonEmpty.some(v => datePatterns.some(p => p.test(v)))) { | |
return 'time'; | |
} | |
// Check if ordinal | |
const ordinalPatterns = ['low', 'medium', 'high', 'small', 'large', 'poor', 'good', 'excellent']; | |
const lowerValues = uniqueVals.map(v => v.toLowerCase()); | |
if (uniqueVals.length <= 10 && | |
lowerValues.some(v => ordinalPatterns.some(p => v.includes(p)))) { | |
return 'ordinal'; | |
} | |
return 'categorical'; | |
} | |
} | |
// ========== Comparison Chart Generator ========== | |
class ComparisonChartGenerator { | |
// Generate high contrast colors with guaranteed uniqueness | |
static generateColor(index) { | |
// High contrast colors optimized for visibility and distinction | |
const highContrastColors = [ | |
'#FF0000', // Pure Red | |
'#0000FF', // Pure Blue | |
'#00AA00', // Green | |
'#FF00FF', // Magenta | |
'#FF8800', // Orange | |
'#00CCCC', // Cyan | |
'#8B008B', // Dark Magenta | |
'#FFD700', // Gold | |
'#000080', // Navy Blue | |
'#DC143C', // Crimson | |
'#228B22', // Forest Green | |
'#4B0082', // Indigo | |
'#FF1493', // Deep Pink | |
'#006400', // Dark Green | |
'#FF4500', // Orange Red | |
'#9400D3', // Violet | |
'#00CED1', // Dark Turquoise | |
'#B22222', // Fire Brick | |
'#2F4F4F', // Dark Slate Gray | |
'#FF6347' // Tomato | |
]; | |
// Ensure index is a valid number | |
const colorIndex = parseInt(index) || 0; | |
if (colorIndex < highContrastColors.length) { | |
return highContrastColors[colorIndex]; | |
} | |
// Generate additional high contrast colors for indices beyond predefined | |
// Use a deterministic approach to ensure consistency | |
const baseHue = (colorIndex * 47) % 360; // Prime number for better distribution | |
const saturation = 100; // Maximum saturation for vivid colors | |
const lightness = 35 + (colorIndex % 3) * 15; // Vary between 35%, 50%, 65% | |
return `hsl(${baseHue}, ${saturation}%, ${lightness}%)`; | |
} | |
static generate(selections) { | |
// Filter out empty selections and ensure each has a unique index | |
const validSelections = selections | |
.map((sel, originalIndex) => ({ ...sel, originalIndex })) | |
.filter(sel => sel.type && sel.value); | |
if (validSelections.length === 0) { | |
return null; | |
} | |
// Determine the type of comparison | |
const types = validSelections.map(s => s.type); | |
const allColumns = types.every(t => t === 'columns'); | |
const allRows = types.every(t => t === 'rows'); | |
if (allColumns) { | |
return this.generateColumnComparison(validSelections); | |
} else if (allRows) { | |
return this.generateRowComparison(validSelections); | |
} else { | |
return this.generateMixedComparison(validSelections); | |
} | |
} | |
static generateColumnComparison(selectionsWithIndex) { | |
const datasets = []; | |
const columns = selectionsWithIndex.map(s => s.value); | |
// Determine if all columns are numeric | |
const columnTypes = {}; | |
columns.forEach(col => { | |
const values = DataProcessor.globalData.rows.map(r => r[col] || ''); | |
columnTypes[col] = DataTypeDetector.detect(col, values); | |
}); | |
const allNumeric = columns.every(col => columnTypes[col] === 'numeric'); | |
if (allNumeric) { | |
// For numeric columns, show values across data points | |
const labels = DataProcessor.globalData.rows.map((r, i) => `Point ${i + 1}`); | |
selectionsWithIndex.forEach((sel) => { | |
const col = sel.value; | |
const colorIndex = sel.originalIndex; | |
const data = DataProcessor.globalData.rows.map(r => { | |
const val = r[col]; | |
return val && !isNaN(parseFloat(val)) ? parseFloat(val) : null; | |
}); | |
const color = this.generateColor(colorIndex); | |
datasets.push({ | |
label: col, | |
data: data, | |
borderColor: color, | |
backgroundColor: color, | |
tension: 0.2, | |
fill: false, | |
borderWidth: 3, | |
pointRadius: 5, | |
pointHoverRadius: 7, | |
pointBackgroundColor: '#ffffff', | |
pointBorderColor: color, | |
pointBorderWidth: 3, | |
spanGaps: true | |
}); | |
}); | |
return { | |
type: 'line', | |
data: { | |
labels: labels.slice(0, Math.min(50, labels.length)), | |
datasets: datasets.map(d => ({...d, data: d.data.slice(0, Math.min(50, d.data.length))})) | |
}, | |
options: this.getLineChartOptions('Values', 'Data Points') | |
}; | |
} else { | |
// For categorical or mixed columns, show distribution | |
const allValues = new Set(); | |
const columnCounts = {}; | |
columns.forEach(col => { | |
const values = DataProcessor.globalData.rows.map(r => r[col] || ''); | |
columnCounts[col] = {}; | |
if (columnTypes[col] === 'numeric') { | |
const numericVals = values.filter(v => v && !isNaN(parseFloat(v))).map(v => parseFloat(v)); | |
if (numericVals.length > 0) { | |
const min = Math.min(...numericVals); | |
const max = Math.max(...numericVals); | |
const binCount = 5; | |
const binSize = (max - min) / binCount; | |
for (let i = 0; i < binCount; i++) { | |
const binMin = min + i * binSize; | |
const binMax = binMin + binSize; | |
const binLabel = `${binMin.toFixed(1)}-${binMax.toFixed(1)}`; | |
allValues.add(binLabel); | |
columnCounts[col][binLabel] = numericVals.filter(v => | |
v >= binMin && (i === binCount - 1 ? v <= binMax : v < binMax) | |
).length; | |
} | |
} | |
} else { | |
values.forEach(v => { | |
if (v) { | |
allValues.add(v); | |
columnCounts[col][v] = (columnCounts[col][v] || 0) + 1; | |
} | |
}); | |
} | |
}); | |
const labels = Array.from(allValues).sort().slice(0, 20); | |
selectionsWithIndex.forEach((sel) => { | |
const col = sel.value; | |
const colorIndex = sel.originalIndex; | |
const color = this.generateColor(colorIndex); | |
datasets.push({ | |
label: col, | |
data: labels.map(label => columnCounts[col][label] || 0), | |
borderColor: color, | |
backgroundColor: color, | |
tension: 0.2, | |
fill: false, | |
borderWidth: 3, | |
pointRadius: 5, | |
pointHoverRadius: 7, | |
pointBackgroundColor: '#ffffff', | |
pointBorderColor: color, | |
pointBorderWidth: 3 | |
}); | |
}); | |
return { | |
type: 'line', | |
data: { | |
labels: labels, | |
datasets: datasets | |
}, | |
options: this.getLineChartOptions('Count', 'Categories') | |
}; | |
} | |
} | |
static generateRowComparison(selectionsWithIndex) { | |
const datasets = []; | |
selectionsWithIndex.forEach((sel) => { | |
const value = sel.value; | |
const colorIndex = sel.originalIndex; | |
const counts = DataProcessor.globalData.headers.map(header => { | |
return DataProcessor.globalData.rows.filter(row => | |
row[header] === value | |
).length; | |
}); | |
const color = this.generateColor(colorIndex); | |
datasets.push({ | |
label: value, | |
data: counts, | |
borderColor: color, | |
backgroundColor: color, | |
tension: 0.2, | |
fill: false, | |
borderWidth: 3, | |
pointRadius: 5, | |
pointHoverRadius: 7, | |
pointBackgroundColor: '#ffffff', | |
pointBorderColor: color, | |
pointBorderWidth: 3 | |
}); | |
}); | |
return { | |
type: 'line', | |
data: { | |
labels: DataProcessor.globalData.headers, | |
datasets: datasets | |
}, | |
options: this.getLineChartOptions('Occurrences', 'Columns') | |
}; | |
} | |
static generateMixedComparison(selectionsWithIndex) { | |
const datasets = []; | |
const labels = []; | |
const maxPoints = 20; | |
selectionsWithIndex.forEach((sel) => { | |
const colorIndex = sel.originalIndex; | |
const color = this.generateColor(colorIndex); | |
if (sel.type === 'columns') { | |
const values = DataProcessor.globalData.rows.map(r => r[sel.value] || ''); | |
const dataType = DataTypeDetector.detect(sel.value, values); | |
if (dataType === 'numeric') { | |
const data = DataProcessor.globalData.rows.slice(0, maxPoints).map((r, i) => { | |
if (labels.length <= i) labels.push(`Point ${i + 1}`); | |
const val = r[sel.value]; | |
return val && !isNaN(parseFloat(val)) ? parseFloat(val) : null; | |
}); | |
datasets.push({ | |
label: `${sel.value} (Column)`, | |
data: data, | |
borderColor: color, | |
backgroundColor: color, | |
tension: 0.2, | |
fill: false, | |
borderWidth: 3, | |
pointRadius: 5, | |
pointHoverRadius: 7, | |
pointBackgroundColor: '#ffffff', | |
pointBorderColor: color, | |
pointBorderWidth: 3, | |
spanGaps: true | |
}); | |
} else { | |
const counts = {}; | |
values.forEach(v => { | |
if (v) counts[v] = (counts[v] || 0) + 1; | |
}); | |
const sortedValues = Object.keys(counts).sort((a, b) => counts[b] - counts[a]).slice(0, maxPoints); | |
sortedValues.forEach((v, i) => { | |
if (labels.length <= i) labels.push(v); | |
}); | |
datasets.push({ | |
label: `${sel.value} (Column)`, | |
data: labels.map(l => counts[l] || 0), | |
borderColor: color, | |
backgroundColor: color, | |
tension: 0.2, | |
fill: false, | |
borderWidth: 3, | |
pointRadius: 5, | |
pointHoverRadius: 7, | |
pointBackgroundColor: '#ffffff', | |
pointBorderColor: color, | |
pointBorderWidth: 3 | |
}); | |
} | |
} else { | |
const occurrences = DataProcessor.globalData.headers.slice(0, maxPoints).map((header, i) => { | |
if (labels.length <= i) labels.push(header); | |
return DataProcessor.globalData.rows.filter(row => | |
row[header] === sel.value | |
).length; | |
}); | |
datasets.push({ | |
label: `"${sel.value}" (Row Value)`, | |
data: occurrences, | |
borderColor: color, | |
backgroundColor: color, | |
tension: 0.2, | |
fill: false, | |
borderWidth: 3, | |
pointRadius: 5, | |
pointHoverRadius: 7, | |
pointBackgroundColor: '#ffffff', | |
pointBorderColor: color, | |
pointBorderWidth: 3 | |
}); | |
} | |
}); | |
return { | |
type: 'line', | |
data: { | |
labels: labels, | |
datasets: datasets | |
}, | |
options: this.getLineChartOptions('Value', 'Data Points / Categories') | |
}; | |
} | |
static getLineChartOptions(yLabel, xLabel) { | |
return { | |
responsive: true, | |
maintainAspectRatio: false, | |
plugins: { | |
title: { | |
display: false | |
}, | |
legend: { | |
position: 'top', | |
align: 'end', | |
labels: { | |
padding: 15, | |
font: { size: 11, weight: '600' }, // Bolder for better readability | |
color: '#2d3748', // Darker for contrast | |
usePointStyle: true, | |
pointStyle: 'circle', | |
boxWidth: 15 | |
} | |
}, | |
tooltip: { | |
mode: 'index', | |
intersect: false, | |
backgroundColor: 'rgba(255, 255, 255, 0.95)', | |
borderColor: '#2d3748', | |
borderWidth: 2, | |
titleColor: '#1a202c', | |
bodyColor: '#2d3748', | |
titleFont: { size: 13, weight: '700' }, | |
bodyFont: { size: 12, weight: '600' }, | |
padding: 10, | |
cornerRadius: 6, | |
displayColors: true, | |
usePointStyle: true | |
} | |
}, | |
scales: { | |
y: { | |
beginAtZero: true, | |
grid: { | |
color: '#e2e8f0', | |
drawBorder: true, | |
lineWidth: 1 | |
}, | |
ticks: { | |
color: '#2d3748', // Darker for better contrast | |
font: { size: 11, weight: '500' }, | |
padding: 8 | |
}, | |
title: { | |
display: true, | |
text: yLabel, | |
color: '#2d3748', | |
font: { size: 12, weight: '600' } | |
}, | |
border: { | |
display: true, | |
color: '#718096' | |
} | |
}, | |
x: { | |
grid: { | |
color: '#e2e8f0', | |
drawBorder: true, | |
lineWidth: 1 | |
}, | |
ticks: { | |
color: '#2d3748', | |
maxRotation: 45, | |
minRotation: 0, | |
font: { size: 11, weight: '500' }, | |
padding: 8, | |
autoSkip: true, | |
maxTicksLimit: 15 | |
}, | |
title: { | |
display: true, | |
text: xLabel, | |
color: '#2d3748', | |
font: { size: 12, weight: '600' } | |
}, | |
border: { | |
display: true, | |
color: '#718096' | |
} | |
} | |
}, | |
interaction: { | |
mode: 'nearest', | |
axis: 'x', | |
intersect: false | |
} | |
}; | |
} | |
} | |
// ========== Chart Factory (like matplotlib/seaborn) ========== | |
class ChartFactory { | |
static create(canvas, column, dataType, data) { | |
const ctx = canvas.getContext('2d'); | |
const values = data.map(r => r[column] || ''); | |
// Calculate value counts (like pandas value_counts) | |
const counts = {}; | |
values.forEach(v => { | |
if (v) counts[v] = (counts[v] || 0) + 1; | |
}); | |
// Calculate percentages | |
const total = values.filter(v => v).length; | |
const percentages = {}; | |
Object.keys(counts).forEach(k => { | |
percentages[k] = ((counts[k] / total) * 100).toFixed(1); | |
}); | |
let chartConfig; | |
// Select chart type based on data type | |
switch(dataType) { | |
case 'numeric': | |
chartConfig = this.createHistogram(column, values, counts, percentages); | |
break; | |
case 'time': | |
chartConfig = this.createLineChart(column, values, counts, percentages); | |
break; | |
case 'binary': | |
chartConfig = this.createPieChart(column, counts, percentages); | |
break; | |
case 'ordinal': | |
chartConfig = this.createOrderedBar(column, counts, percentages); | |
break; | |
case 'categorical': | |
if (Object.keys(counts).length <= 5) { | |
chartConfig = this.createDonutChart(column, counts, percentages); | |
} else { | |
chartConfig = this.createBarChart(column, counts, percentages); | |
} | |
break; | |
default: | |
chartConfig = this.createBarChart(column, counts, percentages); | |
} | |
// Set default styles | |
Chart.defaults.color = '#000000'; | |
Chart.defaults.font.weight = 'bold'; | |
return new Chart(ctx, chartConfig); | |
} | |
static createHistogram(column, values, counts, percentages) { | |
const numericVals = values.filter(v => v && !isNaN(parseFloat(v))).map(v => parseFloat(v)); | |
const min = Math.min(...numericVals); | |
const max = Math.max(...numericVals); | |
const binCount = Math.min(10, Math.ceil(Math.sqrt(numericVals.length))); | |
const binSize = (max - min) / binCount; | |
const bins = Array(binCount).fill(0); | |
const binLabels = []; | |
for (let i = 0; i < binCount; i++) { | |
const binMin = min + i * binSize; | |
const binMax = binMin + binSize; | |
binLabels.push(`${binMin.toFixed(1)}-${binMax.toFixed(1)}`); | |
numericVals.forEach(v => { | |
if (v >= binMin && (i === binCount - 1 ? v <= binMax : v < binMax)) { | |
bins[i]++; | |
} | |
}); | |
} | |
const total = numericVals.length; | |
const binPercentages = bins.map(b => ((b / total) * 100).toFixed(1)); | |
return { | |
type: 'bar', | |
data: { | |
labels: binLabels, | |
datasets: [{ | |
label: `${column} Distribution`, | |
data: bins, | |
backgroundColor: DataProcessor.colorPalettes.sequential, | |
borderColor: '#333', | |
borderWidth: 1 | |
}] | |
}, | |
options: this.getChartOptions('histogram', binPercentages) | |
}; | |
} | |
static createBarChart(column, counts, percentages) { | |
const sortedKeys = Object.keys(counts).sort((a, b) => counts[b] - counts[a]).slice(0, 10); | |
return { | |
type: 'bar', | |
data: { | |
labels: sortedKeys, | |
datasets: [{ | |
label: column, | |
data: sortedKeys.map(k => counts[k]), | |
backgroundColor: DataProcessor.colorPalettes.categorical.slice(0, sortedKeys.length), | |
borderColor: '#333', | |
borderWidth: 1 | |
}] | |
}, | |
options: this.getChartOptions('bar', sortedKeys.map(k => percentages[k])) | |
}; | |
} | |
static createPieChart(column, counts, percentages) { | |
const keys = Object.keys(counts); | |
return { | |
type: 'pie', | |
data: { | |
labels: keys, | |
datasets: [{ | |
data: keys.map(k => counts[k]), | |
backgroundColor: DataProcessor.colorPalettes.binary, | |
borderColor: '#000', | |
borderWidth: 2 | |
}] | |
}, | |
options: this.getChartOptions('pie', keys.map(k => percentages[k])) | |
}; | |
} | |
static createDonutChart(column, counts, percentages) { | |
const keys = Object.keys(counts); | |
return { | |
type: 'doughnut', | |
data: { | |
labels: keys, | |
datasets: [{ | |
data: keys.map(k => counts[k]), | |
backgroundColor: DataProcessor.colorPalettes.categorical.slice(0, keys.length), | |
borderColor: '#000', | |
borderWidth: 2 | |
}] | |
}, | |
options: this.getChartOptions('donut', keys.map(k => percentages[k])) | |
}; | |
} | |
static createLineChart(column, values, counts, percentages) { | |
const sortedKeys = Object.keys(counts).sort(); | |
return { | |
type: 'line', | |
data: { | |
labels: sortedKeys, | |
datasets: [{ | |
label: `${column} Trend`, | |
data: sortedKeys.map(k => counts[k]), | |
borderColor: '#0072B2', | |
backgroundColor: 'rgba(0, 114, 178, 0.1)', | |
tension: 0.4, | |
fill: true, | |
borderWidth: 3 | |
}] | |
}, | |
options: this.getChartOptions('line', sortedKeys.map(k => percentages[k])) | |
}; | |
} | |
static createOrderedBar(column, counts, percentages) { | |
const ordinalOrder = ['low', 'medium', 'high', 'small', 'medium', 'large', 'poor', 'fair', 'good', 'excellent']; | |
const keys = Object.keys(counts); | |
const sortedKeys = keys.sort((a, b) => { | |
const aIdx = ordinalOrder.findIndex(o => a.toLowerCase().includes(o)); | |
const bIdx = ordinalOrder.findIndex(o => b.toLowerCase().includes(o)); | |
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; | |
return a.localeCompare(b); | |
}); | |
return { | |
type: 'bar', | |
data: { | |
labels: sortedKeys, | |
datasets: [{ | |
label: column, | |
data: sortedKeys.map(k => counts[k]), | |
backgroundColor: DataProcessor.colorPalettes.ordinal.slice(0, sortedKeys.length), | |
borderColor: '#333', | |
borderWidth: 1 | |
}] | |
}, | |
options: { | |
...this.getChartOptions('orderedBar', sortedKeys.map(k => percentages[k])), | |
indexAxis: 'y' | |
} | |
}; | |
} | |
static getChartOptions(type, percentagesList) { | |
const baseOptions = { | |
responsive: true, | |
maintainAspectRatio: false, | |
plugins: { | |
legend: { | |
display: type === 'pie' || type === 'donut', | |
position: 'bottom', | |
labels: { | |
color: '#000000', | |
padding: 10, | |
font: { | |
weight: 'bold', | |
size: type === 'donut' ? 10 : 12 | |
} | |
} | |
}, | |
tooltip: { | |
callbacks: { | |
label: function(context) { | |
const index = context.dataIndex; | |
const count = type === 'pie' || type === 'donut' ? | |
context.parsed : context.parsed.y || context.parsed.x; | |
const percentage = percentagesList[index]; | |
return `${count} (${percentage}%)`; | |
} | |
}, | |
backgroundColor: 'rgba(255, 255, 255, 0.95)', | |
borderColor: '#000000', | |
borderWidth: 2, | |
bodyColor: '#000000', | |
titleColor: '#000000', | |
bodyFont: { weight: 'bold', size: 14 }, | |
titleFont: { weight: 'bold', size: 14 }, | |
padding: 12, | |
cornerRadius: 6, | |
displayColors: false | |
} | |
} | |
}; | |
if (type !== 'pie' && type !== 'donut') { | |
const yAxisColor = type === 'histogram' ? 'red' : | |
type === 'orderedBar' ? 'magenta' : | |
type === 'line' ? 'purple' : 'green'; | |
const xAxisColor = type === 'histogram' ? 'blue' : | |
type === 'orderedBar' ? 'yellow' : | |
type === 'line' ? 'cyan' : 'orange'; | |
baseOptions.scales = { | |
y: { | |
beginAtZero: true, | |
ticks: { color: '#000000', font: { weight: 'bold' } }, | |
title: { | |
display: true, | |
text: type === 'orderedBar' ? 'Categories' : 'Count', | |
color: '#000000', | |
font: { weight: 'bold' } | |
}, | |
grid: { color: DataProcessor.axisColors[yAxisColor], lineWidth: 0.5 }, | |
border: { color: DataProcessor.axisColors[yAxisColor], width: 3 } | |
}, | |
x: { | |
ticks: { | |
color: '#000000', | |
maxRotation: 45, | |
minRotation: 45, | |
font: { weight: 'bold' } | |
}, | |
title: { | |
display: true, | |
text: type === 'histogram' ? 'Range' : | |
type === 'orderedBar' ? 'Count' : | |
type === 'line' ? 'Values' : 'Categories', | |
color: '#000000', | |
font: { weight: 'bold' } | |
}, | |
grid: { color: DataProcessor.axisColors[xAxisColor], lineWidth: 0.5 }, | |
border: { color: DataProcessor.axisColors[xAxisColor], width: 3 } | |
} | |
}; | |
} | |
return baseOptions; | |
} | |
} | |
// ========== Table Renderer (like pandas DataFrame display) ========== | |
class TableRenderer { | |
static render(dataset, fileLabel = 'CSV') { | |
const { headers, rows } = dataset; | |
DataProcessor.globalData = dataset; | |
const mount = document.getElementById('tableMount'); | |
const uniques = this.getUniqueValues(headers, rows); | |
let html = '<table class="csv-table"><thead><tr>'; | |
headers.forEach(h => html += `<th>${h}</th>`); | |
html += '</tr></thead><tbody>'; | |
// Filter row | |
html += '<tr class="filter-row">'; | |
headers.forEach(h => { | |
html += `<td><button type="button" class="dd-trigger" data-col="${h}" data-value="All">`; | |
html += `<span style="font-weight: bold;">All</span></button></td>`; | |
}); | |
html += '</tr>'; | |
// Chart row | |
html += '<tr class="spacer-row">'; | |
headers.forEach((h, idx) => { | |
html += `<td><div class="chart-container"><canvas id="chart-${idx}"></canvas></div></td>`; | |
}); | |
html += '</tr>'; | |
// Data rows | |
rows.forEach(r => { | |
html += '<tr class="data-row">'; | |
headers.forEach(h => { | |
const val = r[h] ?? ''; | |
html += `<td title="${val.replace(/"/g,'"')}">${val}</td>`; | |
}); | |
html += '</tr>'; | |
}); | |
html += '</tbody></table>'; | |
mount.innerHTML = html; | |
document.getElementById('filename').textContent = fileLabel; | |
// Setup filters and charts | |
FilterManager.setup(headers, uniques); | |
ChartManager.updateAll(); | |
} | |
static getUniqueValues(headers, rows) { | |
const uniqueMap = {}; | |
headers.forEach(h => { | |
const values = new Set(); | |
rows.forEach(r => { | |
const val = (r[h] ?? '').trim(); | |
if (val !== '') values.add(val); | |
}); | |
uniqueMap[h] = Array.from(values).sort((a, b) => a.localeCompare(b)); | |
}); | |
return uniqueMap; | |
} | |
} | |
// ========== Filter Manager (like pandas query/filter) ========== | |
class FilterManager { | |
static portal = null; | |
static portalOwner = null; | |
static setup(headers, uniques) { | |
// Create portal element | |
if (!this.portal) { | |
this.portal = document.createElement('div'); | |
this.portal.className = 'float-menu'; | |
document.body.appendChild(this.portal); | |
} | |
const triggers = Array.from(document.querySelectorAll('.dd-trigger')); | |
const optionsByCol = {}; | |
headers.forEach(h => { | |
optionsByCol[h] = ['All', ...(uniques[h] || [])]; | |
}); | |
triggers.forEach(btn => { | |
const col = btn.getAttribute('data-col'); | |
const labelSpan = btn.querySelector('span'); | |
btn.addEventListener('mouseenter', () => { | |
if (this.portal.style.display === 'block') this.closePortal(); | |
this.openPortal(btn, optionsByCol[col], btn.getAttribute('data-value'), (val) => { | |
btn.setAttribute('data-value', val); | |
labelSpan.textContent = val; | |
labelSpan.style.fontWeight = val === 'All' ? 'bold' : 'normal'; | |
this.applyFilters(); | |
ChartManager.updateAll(); | |
}); | |
}); | |
}); | |
} | |
static openPortal(triggerBtn, options, currentValue, onSelect) { | |
this.portal.innerHTML = options.map(v => { | |
const style = v === 'All' ? 'style="font-weight: bold;"' : ''; | |
return `<div class="float-option" data-value="${v.replace(/"/g,'"')}" ${style}>${v}</div>`; | |
}).join(''); | |
const rect = triggerBtn.getBoundingClientRect(); | |
this.portal.style.width = rect.width + 'px'; | |
this.portal.style.minWidth = rect.width + 'px'; | |
this.portal.style.left = rect.left + 'px'; | |
this.portal.style.display = 'block'; | |
const menuH = Math.min(this.portal.scrollHeight, 260); | |
let top = rect.bottom + 4; | |
if (top + menuH > window.innerHeight - 8) { | |
top = Math.max(8, rect.top - 4 - menuH); | |
} | |
this.portal.style.top = top + 'px'; | |
this.portalOwner = { triggerBtn, onSelect }; | |
Array.from(this.portal.querySelectorAll('.float-option')).forEach(el => { | |
el.addEventListener('click', () => { | |
const val = el.getAttribute('data-value'); | |
this.closePortal(); | |
onSelect(val); | |
}); | |
}); | |
this.portal.addEventListener('mouseleave', () => this.closePortal(), { once: true }); | |
triggerBtn.addEventListener('mouseleave', () => { | |
setTimeout(() => { | |
if (!this.portal.matches(':hover') && !triggerBtn.matches(':hover')) { | |
this.closePortal(); | |
} | |
}, 120); | |
}); | |
setTimeout(() => { | |
window.addEventListener('mousedown', (e) => { | |
if (!this.portal.contains(e.target) && e.target !== triggerBtn) { | |
this.closePortal(); | |
} | |
}, { once: true }); | |
window.addEventListener('keydown', (e) => { | |
if (e.key === 'Escape') this.closePortal(); | |
}, { once: true }); | |
}, 0); | |
} | |
static closePortal() { | |
this.portal.style.display = 'none'; | |
this.portalOwner = null; | |
this.portal.innerHTML = ''; | |
} | |
static applyFilters() { | |
const headers = Array.from(document.querySelectorAll('thead th')).map(th => th.textContent); | |
const activeFilters = {}; | |
document.querySelectorAll('.dd-trigger').forEach(btn => { | |
const col = btn.getAttribute('data-col'); | |
const val = btn.getAttribute('data-value'); | |
if (val && val !== 'All') activeFilters[col] = val; | |
}); | |
const rows = Array.from(document.querySelectorAll('tr.data-row')); | |
rows.forEach(tr => { | |
const tds = Array.from(tr.querySelectorAll('td')); | |
let shouldShow = true; | |
for (const [col, val] of Object.entries(activeFilters)) { | |
const idx = headers.indexOf(col); | |
if (idx < 0) continue; | |
if ((tds[idx]?.textContent ?? '').trim() !== val) { | |
shouldShow = false; | |
break; | |
} | |
} | |
tr.style.display = shouldShow ? '' : 'none'; | |
}); | |
} | |
static getFilteredData() { | |
const activeFilters = {}; | |
document.querySelectorAll('.dd-trigger').forEach(btn => { | |
const col = btn.getAttribute('data-col'); | |
const val = btn.getAttribute('data-value'); | |
if (val && val !== 'All') activeFilters[col] = val; | |
}); | |
return DataProcessor.globalData.rows.filter(row => { | |
for (const [col, val] of Object.entries(activeFilters)) { | |
if ((row[col] || '').trim() !== val) return false; | |
} | |
return true; | |
}); | |
} | |
} | |
// ========== Chart Manager ========== | |
class ChartManager { | |
static updateAll() { | |
const filteredData = FilterManager.getFilteredData(); | |
DataProcessor.globalData.headers.forEach((header, idx) => { | |
const chartId = `chart-${idx}`; | |
// Destroy existing chart | |
if (DataProcessor.chartInstances[chartId]) { | |
DataProcessor.chartInstances[chartId].destroy(); | |
delete DataProcessor.chartInstances[chartId]; | |
} | |
// Create new chart | |
const canvas = document.getElementById(chartId); | |
if (canvas) { | |
const values = filteredData.map(r => r[header] || ''); | |
const dataType = DataTypeDetector.detect(header, values); | |
DataProcessor.chartInstances[chartId] = ChartFactory.create( | |
canvas, header, dataType, filteredData | |
); | |
} | |
}); | |
} | |
} | |
// ========== Searchable Dropdown Manager ========== | |
class SearchableDropdown { | |
constructor(wrapper) { | |
this.wrapper = wrapper; | |
this.input = wrapper.querySelector('.searchable-dropdown-input'); | |
this.arrow = wrapper.querySelector('.searchable-dropdown-arrow'); | |
this.menu = wrapper.querySelector('.searchable-dropdown-menu'); | |
this.searchInput = wrapper.querySelector('.search-input'); | |
this.optionsContainer = wrapper.querySelector('.searchable-dropdown-options'); | |
this.typeSelector = wrapper.parentElement.querySelector('.type-selector'); | |
this.selectedValue = ''; | |
this.options = []; | |
this.isOpen = false; | |
this.init(); | |
} | |
init() { | |
// Click on input to open dropdown | |
this.input.addEventListener('click', () => this.toggle()); | |
// Search functionality | |
this.searchInput.addEventListener('input', (e) => this.filterOptions(e.target.value)); | |
// Close when clicking outside | |
document.addEventListener('click', (e) => { | |
if (!this.wrapper.contains(e.target)) { | |
this.close(); | |
} | |
}); | |
// Prevent menu from closing when clicking inside | |
this.menu.addEventListener('click', (e) => e.stopPropagation()); | |
// Type selector change | |
if (this.typeSelector) { | |
this.typeSelector.addEventListener('change', () => { | |
this.loadOptions(); | |
this.selectedValue = ''; | |
this.input.value = ''; | |
// Trigger auto-generation | |
window.generateComparisonChart(); | |
}); | |
} | |
// Load initial options | |
this.loadOptions(); | |
} | |
loadOptions() { | |
const selectedType = this.typeSelector ? this.typeSelector.value : 'columns'; | |
this.options = []; | |
if (selectedType === 'columns') { | |
this.options = DataProcessor.globalData.headers; | |
} else if (selectedType === 'rows') { | |
const allValues = new Set(); | |
DataProcessor.globalData.rows.forEach(row => { | |
DataProcessor.globalData.headers.forEach(header => { | |
const value = row[header]; | |
if (value && value.trim() !== '') { | |
allValues.add(value.trim()); | |
} | |
}); | |
}); | |
this.options = Array.from(allValues).sort((a, b) => a.localeCompare(b)); | |
} | |
this.renderOptions(); | |
} | |
renderOptions(filteredOptions = null) { | |
const optionsToRender = filteredOptions || this.options; | |
if (optionsToRender.length === 0) { | |
this.optionsContainer.innerHTML = '<div class="searchable-dropdown-no-results">No results found</div>'; | |
return; | |
} | |
this.optionsContainer.innerHTML = optionsToRender.map(option => ` | |
<div class="searchable-dropdown-option ${option === this.selectedValue ? 'selected' : ''}" data-value="${option}"> | |
${option} | |
</div> | |
`).join(''); | |
// Add click handlers to options | |
this.optionsContainer.querySelectorAll('.searchable-dropdown-option').forEach(optionEl => { | |
optionEl.addEventListener('click', () => { | |
this.selectOption(optionEl.dataset.value); | |
}); | |
}); | |
} | |
filterOptions(searchTerm) { | |
const filtered = this.options.filter(option => | |
option.toLowerCase().includes(searchTerm.toLowerCase()) | |
); | |
this.renderOptions(filtered); | |
} | |
selectOption(value) { | |
this.selectedValue = value; | |
this.input.value = value; | |
this.close(); | |
// Trigger auto-generation | |
window.generateComparisonChart(); | |
} | |
toggle() { | |
if (this.isOpen) { | |
this.close(); | |
} else { | |
this.open(); | |
} | |
} | |
open() { | |
this.isOpen = true; | |
this.menu.classList.add('open'); | |
this.arrow.classList.add('open'); | |
this.searchInput.value = ''; | |
this.searchInput.focus(); | |
this.renderOptions(); | |
} | |
close() { | |
this.isOpen = false; | |
this.menu.classList.remove('open'); | |
this.arrow.classList.remove('open'); | |
} | |
} | |
// ========== View Controllers ========== | |
function showPrepare() { | |
document.getElementById('prepareView').classList.remove('hidden'); | |
document.getElementById('predictView').classList.add('hidden'); | |
document.getElementById('reportView').classList.add('hidden'); | |
setActive('prepareBtn'); | |
} | |
function showPredict() { | |
document.getElementById('prepareView').classList.add('hidden'); | |
document.getElementById('predictView').classList.remove('hidden'); | |
document.getElementById('reportView').classList.add('hidden'); | |
setActive('predictBtn'); | |
} | |
function showReport() { | |
document.getElementById('prepareView').classList.add('hidden'); | |
document.getElementById('predictView').classList.add('hidden'); | |
document.getElementById('reportView').classList.remove('hidden'); | |
setActive('reportBtn'); | |
} | |
function setActive(btnId) { | |
document.querySelectorAll('.menu-buttons button').forEach(b => b.classList.remove('active')); | |
document.getElementById(btnId).classList.add('active'); | |
} | |
// ========== Comparison Chart Generation ========== | |
window.generateComparisonChart = function() { | |
const selections = []; | |
const dropdownPairs = document.querySelectorAll('#dropdownContainer > div'); | |
dropdownPairs.forEach(pair => { | |
const typeSelector = pair.querySelector('.type-selector'); | |
const inputField = pair.querySelector('.searchable-dropdown-input'); | |
if (typeSelector && inputField && inputField.value) { | |
selections.push({ | |
type: typeSelector.value, | |
value: inputField.value | |
}); | |
} | |
}); | |
// Check if we have any valid selections | |
if (selections.length === 0) { | |
document.getElementById('comparisonChartArea').style.display = 'none'; | |
return; | |
} | |
// Generate the chart config | |
const chartConfig = ComparisonChartGenerator.generate(selections); | |
if (!chartConfig) { | |
document.getElementById('comparisonChartArea').style.display = 'none'; | |
return; | |
} | |
// Show the chart area | |
document.getElementById('comparisonChartArea').style.display = 'block'; | |
// Destroy existing chart if exists | |
if (DataProcessor.comparisonChartInstance) { | |
DataProcessor.comparisonChartInstance.destroy(); | |
DataProcessor.comparisonChartInstance = null; | |
} | |
// Create new chart | |
const canvas = document.getElementById('comparisonChart'); | |
const ctx = canvas.getContext('2d'); | |
DataProcessor.comparisonChartInstance = new Chart(ctx, chartConfig); | |
}; | |
// ========== Main Initialization ========== | |
document.addEventListener('DOMContentLoaded', async () => { | |
let csvText = null, label = 'CSV'; | |
try { | |
const ss = window.sessionStorage; | |
const cached = ss.getItem('csvData'); | |
const name = ss.getItem('csvFileName'); | |
if (cached) { | |
csvText = cached; | |
label = name || label; | |
} | |
} catch(e) { | |
console.error('SessionStorage error:', e); | |
} | |
const mount = document.getElementById('tableMount'); | |
if (!csvText) { | |
mount.innerHTML = '<div class="box"><div class="status error">No CSV found</div></div>'; | |
return; | |
} | |
const data = CSVParser.parse(csvText); | |
TableRenderer.render(data, label); | |
// Initialize searchable dropdowns | |
function initializeDropdownPair(container) { | |
const wrappers = container.querySelectorAll('.searchable-dropdown-wrapper'); | |
wrappers.forEach(wrapper => { | |
if (!wrapper.hasAttribute('data-initialized')) { | |
wrapper.setAttribute('data-initialized', 'true'); | |
new SearchableDropdown(wrapper); | |
} | |
}); | |
// Setup delete buttons | |
const deleteButtons = container.querySelectorAll('.delete-btn'); | |
deleteButtons.forEach(btn => { | |
if (!btn.hasAttribute('data-initialized')) { | |
btn.setAttribute('data-initialized', 'true'); | |
btn.addEventListener('click', function() { | |
const dropdownPair = this.parentElement; | |
dropdownPair.remove(); | |
// Re-generate chart after deletion | |
window.generateComparisonChart(); | |
}); | |
} | |
}); | |
} | |
// Initialize existing dropdowns | |
const dropdownContainer = document.getElementById('dropdownContainer'); | |
if (dropdownContainer) { | |
initializeDropdownPair(dropdownContainer); | |
} | |
// Add dropdown functionality for Predict tab | |
const addDropdownBtn = document.getElementById('addDropdownBtn'); | |
if (addDropdownBtn) { | |
addDropdownBtn.addEventListener('click', () => { | |
const dropdownContainer = document.getElementById('dropdownContainer'); | |
const existingDropdowns = dropdownContainer.querySelectorAll('.type-selector'); | |
// Determine which option to show first based on the count | |
const defaultOption = existingDropdowns.length % 2 === 0 ? 'rows' : 'columns'; | |
const alternateOption = defaultOption === 'rows' ? 'columns' : 'rows'; | |
const newDropdownPair = document.createElement('div'); | |
newDropdownPair.style.cssText = 'display: flex; gap: 15px; align-items: center;'; | |
const typeDropdown = document.createElement('select'); | |
typeDropdown.className = 'compare-dropdown type-selector'; | |
typeDropdown.style.cssText = 'padding: 12px; font-size: 1rem; border: 2px solid #cbd5e0; border-radius: 8px; background: white; cursor: pointer; width: 150px;'; | |
typeDropdown.innerHTML = `<option value="${defaultOption}">${defaultOption.charAt(0).toUpperCase() + defaultOption.slice(1)}</option><option value="${alternateOption}">${alternateOption.charAt(0).toUpperCase() + alternateOption.slice(1)}</option>`; | |
const dropdownWrapper = document.createElement('div'); | |
dropdownWrapper.className = 'searchable-dropdown-wrapper'; | |
dropdownWrapper.style.cssText = 'flex: 1;'; | |
dropdownWrapper.innerHTML = ` | |
<input type="text" class="searchable-dropdown-input" readonly placeholder="Select or search..." style="width: 100%; padding: 12px 24px 12px 12px; font-size: 1rem; border: 2px solid #cbd5e0; border-radius: 8px; background: white; cursor: pointer; outline: none;"> | |
<span class="searchable-dropdown-arrow">▼</span> | |
<div class="searchable-dropdown-menu"> | |
<div class="searchable-dropdown-search"> | |
<input type="text" placeholder="Search..." class="search-input"> | |
</div> | |
<div class="searchable-dropdown-options"></div> | |
</div> | |
`; | |
const deleteBtn = document.createElement('button'); | |
deleteBtn.className = 'delete-btn'; | |
deleteBtn.style.cssText = 'width: 35px; height: 35px; border-radius: 50%; background: linear-gradient(135deg, #ff4444, #cc0000); color: white; border: none; cursor: pointer; font-size: 1.2rem; font-weight: bold; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(255, 68, 68, 0.3); transition: all 0.3s ease;'; | |
deleteBtn.innerHTML = '×'; | |
newDropdownPair.appendChild(typeDropdown); | |
newDropdownPair.appendChild(dropdownWrapper); | |
newDropdownPair.appendChild(deleteBtn); | |
dropdownContainer.appendChild(newDropdownPair); | |
// Initialize the new dropdown | |
new SearchableDropdown(dropdownWrapper); | |
// Setup delete button | |
deleteBtn.addEventListener('click', function() { | |
newDropdownPair.remove(); | |
window.generateComparisonChart(); | |
}); | |
}); | |
} | |
}); | |
</script> | |
</body> | |
</html> |