initial commit
This commit is contained in:
831
frontend/app.js
Normal file
831
frontend/app.js
Normal file
@@ -0,0 +1,831 @@
|
||||
/**
|
||||
* School Performance Compass - Frontend Application
|
||||
* Interactive UK School Data Visualization
|
||||
*/
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
// State
|
||||
let allSchools = [];
|
||||
let selectedSchools = [];
|
||||
let comparisonChart = null;
|
||||
let schoolDetailChart = null;
|
||||
let currentSchoolData = null;
|
||||
|
||||
// Chart colors
|
||||
const CHART_COLORS = [
|
||||
'#e07256', // coral
|
||||
'#2d7d7d', // teal
|
||||
'#c9a227', // gold
|
||||
'#7b68a6', // purple
|
||||
'#3498db', // blue
|
||||
'#27ae60', // green
|
||||
'#e74c3c', // red
|
||||
'#9b59b6', // violet
|
||||
];
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
schoolSearch: document.getElementById('school-search'),
|
||||
localAuthorityFilter: document.getElementById('local-authority-filter'),
|
||||
typeFilter: document.getElementById('type-filter'),
|
||||
schoolsGrid: document.getElementById('schools-grid'),
|
||||
compareSearch: document.getElementById('compare-search'),
|
||||
compareResults: document.getElementById('compare-results'),
|
||||
selectedSchools: document.getElementById('selected-schools'),
|
||||
chartsSection: document.getElementById('charts-section'),
|
||||
metricSelect: document.getElementById('metric-select'),
|
||||
comparisonChart: document.getElementById('comparison-chart'),
|
||||
comparisonTable: document.getElementById('comparison-table'),
|
||||
tableHeader: document.getElementById('table-header'),
|
||||
tableBody: document.getElementById('table-body'),
|
||||
rankingMetric: document.getElementById('ranking-metric'),
|
||||
rankingYear: document.getElementById('ranking-year'),
|
||||
rankingsList: document.getElementById('rankings-list'),
|
||||
modal: document.getElementById('school-modal'),
|
||||
modalClose: document.getElementById('modal-close'),
|
||||
modalSchoolName: document.getElementById('modal-school-name'),
|
||||
modalMeta: document.getElementById('modal-meta'),
|
||||
modalStats: document.getElementById('modal-stats'),
|
||||
schoolDetailChart: document.getElementById('school-detail-chart'),
|
||||
addToCompare: document.getElementById('add-to-compare'),
|
||||
};
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
async function init() {
|
||||
await loadFilters();
|
||||
await loadSchools();
|
||||
await loadRankingYears();
|
||||
await loadRankings();
|
||||
setupEventListeners();
|
||||
}
|
||||
|
||||
// API Functions
|
||||
async function fetchAPI(endpoint) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilters() {
|
||||
const data = await fetchAPI('/api/filters');
|
||||
if (!data) return;
|
||||
|
||||
// Populate school type filter
|
||||
data.school_types.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type;
|
||||
option.textContent = type;
|
||||
elements.typeFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSchools() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const search = elements.schoolSearch.value.trim();
|
||||
if (search) params.append('search', search);
|
||||
|
||||
const localAuthority = elements.localAuthorityFilter.value;
|
||||
if (localAuthority) params.append('local_authority', localAuthority);
|
||||
|
||||
const type = elements.typeFilter.value;
|
||||
if (type) params.append('school_type', type);
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/api/schools${queryString ? '?' + queryString : ''}`;
|
||||
|
||||
const data = await fetchAPI(endpoint);
|
||||
if (!data) {
|
||||
showEmptyState(elements.schoolsGrid, 'Unable to load schools');
|
||||
return;
|
||||
}
|
||||
|
||||
allSchools = data.schools;
|
||||
renderSchools(allSchools);
|
||||
}
|
||||
|
||||
async function loadSchoolDetails(urn) {
|
||||
const data = await fetchAPI(`/api/schools/${urn}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadComparison() {
|
||||
if (selectedSchools.length === 0) return null;
|
||||
|
||||
const urns = selectedSchools.map(s => s.urn).join(',');
|
||||
const data = await fetchAPI(`/api/compare?urns=${urns}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadRankingYears() {
|
||||
const data = await fetchAPI('/api/filters');
|
||||
if (!data) return;
|
||||
|
||||
elements.rankingYear.innerHTML = '';
|
||||
data.years.sort((a, b) => b - a).forEach(year => {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}`;
|
||||
elements.rankingYear.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRankings() {
|
||||
const metric = elements.rankingMetric.value;
|
||||
const year = elements.rankingYear.value;
|
||||
|
||||
let endpoint = `/api/rankings?metric=${metric}&limit=20`;
|
||||
if (year) endpoint += `&year=${year}`;
|
||||
|
||||
const data = await fetchAPI(endpoint);
|
||||
if (!data) {
|
||||
showEmptyState(elements.rankingsList, 'Unable to load rankings');
|
||||
return;
|
||||
}
|
||||
|
||||
renderRankings(data.rankings, metric);
|
||||
}
|
||||
|
||||
// Render Functions
|
||||
function renderSchools(schools) {
|
||||
if (schools.length === 0) {
|
||||
showEmptyState(elements.schoolsGrid, 'No primary schools found matching your criteria');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.schoolsGrid.innerHTML = schools.map(school => `
|
||||
<div class="school-card" data-urn="${school.urn}">
|
||||
<h3 class="school-name">${escapeHtml(school.school_name)}</h3>
|
||||
<div class="school-meta">
|
||||
<span class="school-tag">${escapeHtml(school.local_authority || '')}</span>
|
||||
<span class="school-tag type">${escapeHtml(school.school_type || '')}</span>
|
||||
</div>
|
||||
<div class="school-address">${escapeHtml(school.address || '')}</div>
|
||||
<div class="school-stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value">Primary</div>
|
||||
<div class="stat-label">Phase</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">KS2</div>
|
||||
<div class="stat-label">Data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const urn = parseInt(card.dataset.urn);
|
||||
openSchoolModal(urn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRankings(rankings, metric) {
|
||||
if (rankings.length === 0) {
|
||||
showEmptyState(elements.rankingsList, 'No ranking data available for this year/metric');
|
||||
return;
|
||||
}
|
||||
|
||||
const metricLabels = {
|
||||
// Expected
|
||||
rwm_expected_pct: 'RWM %',
|
||||
reading_expected_pct: 'Reading %',
|
||||
writing_expected_pct: 'Writing %',
|
||||
maths_expected_pct: 'Maths %',
|
||||
gps_expected_pct: 'GPS %',
|
||||
science_expected_pct: 'Science %',
|
||||
// Higher
|
||||
rwm_high_pct: 'RWM Higher %',
|
||||
reading_high_pct: 'Reading Higher %',
|
||||
writing_high_pct: 'Writing Higher %',
|
||||
maths_high_pct: 'Maths Higher %',
|
||||
gps_high_pct: 'GPS Higher %',
|
||||
// Progress
|
||||
reading_progress: 'Reading Progress',
|
||||
writing_progress: 'Writing Progress',
|
||||
maths_progress: 'Maths Progress',
|
||||
// Averages
|
||||
reading_avg_score: 'Reading Avg',
|
||||
maths_avg_score: 'Maths Avg',
|
||||
gps_avg_score: 'GPS Avg',
|
||||
// Gender
|
||||
rwm_expected_boys_pct: 'Boys RWM %',
|
||||
rwm_expected_girls_pct: 'Girls RWM %',
|
||||
rwm_high_boys_pct: 'Boys Higher %',
|
||||
rwm_high_girls_pct: 'Girls Higher %',
|
||||
// Equity
|
||||
rwm_expected_disadvantaged_pct: 'Disadvantaged %',
|
||||
rwm_expected_non_disadvantaged_pct: 'Non-Disadv %',
|
||||
disadvantaged_gap: 'Disadv Gap',
|
||||
// Context
|
||||
disadvantaged_pct: '% Disadvantaged',
|
||||
eal_pct: '% EAL',
|
||||
sen_support_pct: '% SEN',
|
||||
stability_pct: '% Stable',
|
||||
// 3-Year
|
||||
rwm_expected_3yr_pct: 'RWM 3yr %',
|
||||
reading_avg_3yr: 'Reading 3yr',
|
||||
maths_avg_3yr: 'Maths 3yr',
|
||||
};
|
||||
|
||||
elements.rankingsList.innerHTML = rankings.map((school, index) => {
|
||||
const value = school[metric];
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
const isProgress = metric.includes('progress');
|
||||
const isScore = metric.includes('_avg_');
|
||||
let displayValue;
|
||||
if (isProgress) {
|
||||
displayValue = (value >= 0 ? '+' : '') + value.toFixed(1);
|
||||
} else if (isScore) {
|
||||
displayValue = value.toFixed(0);
|
||||
} else {
|
||||
displayValue = value.toFixed(0) + '%';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="ranking-item" data-urn="${school.urn}">
|
||||
<div class="ranking-position ${index < 3 ? 'top-3' : ''}">${index + 1}</div>
|
||||
<div class="ranking-info">
|
||||
<div class="ranking-name">${escapeHtml(school.school_name)}</div>
|
||||
<div class="ranking-location">${escapeHtml(school.local_authority || '')}</div>
|
||||
</div>
|
||||
<div class="ranking-score">
|
||||
<div class="ranking-score-value">${displayValue}</div>
|
||||
<div class="ranking-score-label">${metricLabels[metric] || metric}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).filter(Boolean).join('');
|
||||
|
||||
// Add click handlers
|
||||
elements.rankingsList.querySelectorAll('.ranking-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const urn = parseInt(item.dataset.urn);
|
||||
openSchoolModal(urn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedSchools() {
|
||||
if (selectedSchools.length === 0) {
|
||||
elements.selectedSchools.innerHTML = `
|
||||
<div class="empty-selection">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="6" y="10" width="36" height="28" rx="2"/>
|
||||
<path d="M6 18h36"/>
|
||||
<circle cx="14" cy="14" r="2" fill="currentColor"/>
|
||||
<circle cx="22" cy="14" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Search and add schools to compare</p>
|
||||
</div>
|
||||
`;
|
||||
elements.chartsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.selectedSchools.innerHTML = selectedSchools.map((school, index) => `
|
||||
<div class="selected-school-tag" style="border-left: 3px solid ${CHART_COLORS[index % CHART_COLORS.length]}">
|
||||
<span>${escapeHtml(school.school_name)}</span>
|
||||
<button class="remove" data-urn="${school.urn}" title="Remove">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 4L4 12M4 4l8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add remove handlers
|
||||
elements.selectedSchools.querySelectorAll('.remove').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const urn = parseInt(btn.dataset.urn);
|
||||
removeFromComparison(urn);
|
||||
});
|
||||
});
|
||||
|
||||
elements.chartsSection.style.display = 'block';
|
||||
updateComparisonChart();
|
||||
}
|
||||
|
||||
async function updateComparisonChart() {
|
||||
if (selectedSchools.length === 0) return;
|
||||
|
||||
const data = await loadComparison();
|
||||
if (!data) return;
|
||||
|
||||
const metric = elements.metricSelect.value;
|
||||
const metricLabels = {
|
||||
// Expected Standard
|
||||
rwm_expected_pct: 'Reading, Writing & Maths Combined (%)',
|
||||
reading_expected_pct: 'Reading Expected Standard (%)',
|
||||
writing_expected_pct: 'Writing Expected Standard (%)',
|
||||
maths_expected_pct: 'Maths Expected Standard (%)',
|
||||
gps_expected_pct: 'GPS Expected Standard (%)',
|
||||
science_expected_pct: 'Science Expected Standard (%)',
|
||||
// Higher Standard
|
||||
rwm_high_pct: 'RWM Combined Higher Standard (%)',
|
||||
reading_high_pct: 'Reading Higher Standard (%)',
|
||||
writing_high_pct: 'Writing Greater Depth (%)',
|
||||
maths_high_pct: 'Maths Higher Standard (%)',
|
||||
gps_high_pct: 'GPS Higher Standard (%)',
|
||||
// Progress
|
||||
reading_progress: 'Reading Progress Score',
|
||||
writing_progress: 'Writing Progress Score',
|
||||
maths_progress: 'Maths Progress Score',
|
||||
// Averages
|
||||
reading_avg_score: 'Reading Average Scaled Score',
|
||||
maths_avg_score: 'Maths Average Scaled Score',
|
||||
gps_avg_score: 'GPS Average Scaled Score',
|
||||
// Gender
|
||||
rwm_expected_boys_pct: 'RWM Expected % (Boys)',
|
||||
rwm_expected_girls_pct: 'RWM Expected % (Girls)',
|
||||
rwm_high_boys_pct: 'RWM Higher % (Boys)',
|
||||
rwm_high_girls_pct: 'RWM Higher % (Girls)',
|
||||
// Equity
|
||||
rwm_expected_disadvantaged_pct: 'RWM Expected % (Disadvantaged)',
|
||||
rwm_expected_non_disadvantaged_pct: 'RWM Expected % (Non-Disadvantaged)',
|
||||
disadvantaged_gap: 'Disadvantaged Gap vs National',
|
||||
// Context
|
||||
disadvantaged_pct: '% Disadvantaged Pupils',
|
||||
eal_pct: '% EAL Pupils',
|
||||
sen_support_pct: '% SEN Support',
|
||||
stability_pct: '% Pupil Stability',
|
||||
// 3-Year
|
||||
rwm_expected_3yr_pct: 'RWM Expected % (3-Year Avg)',
|
||||
reading_avg_3yr: 'Reading Score (3-Year Avg)',
|
||||
maths_avg_3yr: 'Maths Score (3-Year Avg)',
|
||||
};
|
||||
|
||||
// Prepare chart data - iterate in same order as selectedSchools for color consistency
|
||||
const datasets = [];
|
||||
const allYears = new Set();
|
||||
|
||||
selectedSchools.forEach((school, index) => {
|
||||
const schoolData = data.comparison[school.urn];
|
||||
if (!schoolData) return;
|
||||
|
||||
const yearlyData = schoolData.yearly_data;
|
||||
yearlyData.forEach(d => allYears.add(d.year));
|
||||
|
||||
const sortedData = yearlyData.sort((a, b) => a.year - b.year);
|
||||
|
||||
datasets.push({
|
||||
label: schoolData.school_info.school_name,
|
||||
data: sortedData.map(d => ({ x: d.year, y: d[metric] })),
|
||||
borderColor: CHART_COLORS[index % CHART_COLORS.length],
|
||||
backgroundColor: CHART_COLORS[index % CHART_COLORS.length] + '20',
|
||||
borderWidth: 3,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
});
|
||||
});
|
||||
|
||||
const years = Array.from(allYears).sort();
|
||||
|
||||
// Destroy existing chart
|
||||
if (comparisonChart) {
|
||||
comparisonChart.destroy();
|
||||
}
|
||||
|
||||
// Create new chart
|
||||
const ctx = elements.comparisonChart.getContext('2d');
|
||||
comparisonChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: years,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 2,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: { family: "'DM Sans', sans-serif", size: 12 },
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: metricLabels[metric],
|
||||
font: { family: "'Playfair Display', serif", size: 18, weight: 600 },
|
||||
padding: { bottom: 20 },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1a1612',
|
||||
titleFont: { family: "'DM Sans', sans-serif" },
|
||||
bodyFont: { family: "'DM Sans', sans-serif" },
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Academic Year',
|
||||
font: { family: "'DM Sans', sans-serif", weight: 500 },
|
||||
},
|
||||
grid: { display: false },
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: metricLabels[metric],
|
||||
font: { family: "'DM Sans', sans-serif", weight: 500 },
|
||||
},
|
||||
grid: { color: '#e5dfd5' },
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update comparison table
|
||||
updateComparisonTable(data.comparison, metric, years);
|
||||
}
|
||||
|
||||
function updateComparisonTable(comparison, metric, years) {
|
||||
// Build header
|
||||
let headerHtml = '<th>School</th>';
|
||||
years.forEach(year => {
|
||||
headerHtml += `<th>${year}</th>`;
|
||||
});
|
||||
headerHtml += '<th>Change</th>';
|
||||
elements.tableHeader.innerHTML = headerHtml;
|
||||
|
||||
// Build body - iterate in same order as selectedSchools for color consistency
|
||||
let bodyHtml = '';
|
||||
selectedSchools.forEach((school, index) => {
|
||||
const schoolData = comparison[school.urn];
|
||||
if (!schoolData) return;
|
||||
|
||||
const yearlyMap = {};
|
||||
schoolData.yearly_data.forEach(d => {
|
||||
yearlyMap[d.year] = d[metric];
|
||||
});
|
||||
|
||||
const firstValue = yearlyMap[years[0]];
|
||||
const lastValue = yearlyMap[years[years.length - 1]];
|
||||
const change = firstValue && lastValue ? (lastValue - firstValue).toFixed(2) : 'N/A';
|
||||
const changeClass = parseFloat(change) >= 0 ? 'positive' : 'negative';
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
|
||||
bodyHtml += `<tr>`;
|
||||
bodyHtml += `<td><strong style="border-left: 3px solid ${color}; padding-left: 8px;">${escapeHtml(schoolData.school_info.school_name)}</strong></td>`;
|
||||
years.forEach(year => {
|
||||
const value = yearlyMap[year];
|
||||
bodyHtml += `<td>${value !== undefined ? formatMetricValue(value, metric) : '-'}</td>`;
|
||||
});
|
||||
bodyHtml += `<td class="${changeClass}">${change !== 'N/A' ? (parseFloat(change) >= 0 ? '+' : '') + change : change}</td>`;
|
||||
bodyHtml += `</tr>`;
|
||||
});
|
||||
elements.tableBody.innerHTML = bodyHtml;
|
||||
}
|
||||
|
||||
function formatMetricValue(value, metric) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (metric.includes('progress')) {
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(1);
|
||||
}
|
||||
if (metric.includes('pct')) {
|
||||
return value.toFixed(0) + '%';
|
||||
}
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
async function openSchoolModal(urn) {
|
||||
const data = await loadSchoolDetails(urn);
|
||||
if (!data) return;
|
||||
|
||||
currentSchoolData = data;
|
||||
|
||||
elements.modalSchoolName.textContent = data.school_info.school_name;
|
||||
elements.modalMeta.innerHTML = `
|
||||
<span class="school-tag">${escapeHtml(data.school_info.local_authority || '')}</span>
|
||||
<span class="school-tag type">${escapeHtml(data.school_info.school_type || '')}</span>
|
||||
<span class="school-tag">Primary</span>
|
||||
`;
|
||||
|
||||
// Get latest year data with actual results (skip 2021 - no SATs)
|
||||
const sortedData = data.yearly_data.sort((a, b) => b.year - a.year);
|
||||
const latest = sortedData.find(d => d.rwm_expected_pct !== null) || sortedData[0];
|
||||
|
||||
elements.modalStats.innerHTML = `
|
||||
<div class="modal-stats-section">
|
||||
<h4>KS2 Results (${latest.year})</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.rwm_expected_pct, 'rwm_expected_pct')}</div>
|
||||
<div class="modal-stat-label">RWM Expected</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.rwm_high_pct, 'rwm_high_pct')}</div>
|
||||
<div class="modal-stat-label">RWM Higher</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.gps_expected_pct, 'gps_expected_pct')}</div>
|
||||
<div class="modal-stat-label">GPS Expected</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.science_expected_pct, 'science_expected_pct')}</div>
|
||||
<div class="modal-stat-label">Science Expected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-stats-section">
|
||||
<h4>Progress Scores</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.reading_progress)}">${formatMetricValue(latest.reading_progress, 'reading_progress')}</div>
|
||||
<div class="modal-stat-label">Reading</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.writing_progress)}">${formatMetricValue(latest.writing_progress, 'writing_progress')}</div>
|
||||
<div class="modal-stat-label">Writing</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.maths_progress)}">${formatMetricValue(latest.maths_progress, 'maths_progress')}</div>
|
||||
<div class="modal-stat-label">Maths</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-stats-section">
|
||||
<h4>School Context</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${latest.total_pupils || '-'}</div>
|
||||
<div class="modal-stat-label">Total Pupils</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.disadvantaged_pct, 'disadvantaged_pct')}</div>
|
||||
<div class="modal-stat-label">% Disadvantaged</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.eal_pct, 'eal_pct')}</div>
|
||||
<div class="modal-stat-label">% EAL</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.sen_support_pct, 'sen_support_pct')}</div>
|
||||
<div class="modal-stat-label">% SEN Support</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function getProgressClass(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return value >= 0 ? 'positive' : 'negative';
|
||||
}
|
||||
|
||||
// Create chart - filter out years with no data (2021)
|
||||
if (schoolDetailChart) {
|
||||
schoolDetailChart.destroy();
|
||||
}
|
||||
|
||||
const validData = sortedData.filter(d => d.rwm_expected_pct !== null).reverse();
|
||||
const years = validData.map(d => d.year);
|
||||
const ctx = elements.schoolDetailChart.getContext('2d');
|
||||
|
||||
schoolDetailChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: years,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Reading %',
|
||||
data: validData.map(d => d.reading_expected_pct),
|
||||
backgroundColor: '#2d7d7d',
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: 'Writing %',
|
||||
data: validData.map(d => d.writing_expected_pct),
|
||||
backgroundColor: '#c9a227',
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: 'Maths %',
|
||||
data: validData.map(d => d.maths_expected_pct),
|
||||
backgroundColor: '#e07256',
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 2,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: { family: "'DM Sans', sans-serif" },
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'KS2 Attainment Over Time (% meeting expected standard)',
|
||||
font: { family: "'Playfair Display', serif", size: 16, weight: 600 },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
grid: { color: '#e5dfd5' },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update add to compare button
|
||||
const isSelected = selectedSchools.some(s => s.urn === data.school_info.urn);
|
||||
elements.addToCompare.textContent = isSelected ? 'Remove from Compare' : 'Add to Compare';
|
||||
elements.addToCompare.dataset.urn = data.school_info.urn;
|
||||
|
||||
elements.modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
elements.modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
currentSchoolData = null;
|
||||
}
|
||||
|
||||
function addToComparison(school) {
|
||||
if (selectedSchools.some(s => s.urn === school.urn)) return;
|
||||
if (selectedSchools.length >= 5) {
|
||||
alert('Maximum 5 schools can be compared at once');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedSchools.push(school);
|
||||
renderSelectedSchools();
|
||||
}
|
||||
|
||||
function removeFromComparison(urn) {
|
||||
selectedSchools = selectedSchools.filter(s => s.urn !== urn);
|
||||
renderSelectedSchools();
|
||||
}
|
||||
|
||||
function showEmptyState(container, message) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="24" cy="24" r="20"/>
|
||||
<path d="M16 20h16M16 28h10"/>
|
||||
</svg>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
function setupEventListeners() {
|
||||
// Navigation
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const view = link.dataset.view;
|
||||
|
||||
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
document.getElementById(`${view}-view`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Search and filters
|
||||
let searchTimeout;
|
||||
elements.schoolSearch.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(loadSchools, 300);
|
||||
});
|
||||
|
||||
elements.localAuthorityFilter.addEventListener('change', loadSchools);
|
||||
elements.typeFilter.addEventListener('change', loadSchools);
|
||||
|
||||
// Compare search
|
||||
let compareSearchTimeout;
|
||||
elements.compareSearch.addEventListener('input', async () => {
|
||||
clearTimeout(compareSearchTimeout);
|
||||
const query = elements.compareSearch.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
elements.compareResults.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
compareSearchTimeout = setTimeout(async () => {
|
||||
const data = await fetchAPI(`/api/schools?search=${encodeURIComponent(query)}`);
|
||||
if (!data) return;
|
||||
|
||||
const results = data.schools.filter(s => !selectedSchools.some(sel => sel.urn === s.urn));
|
||||
|
||||
if (results.length === 0) {
|
||||
elements.compareResults.innerHTML = '<div class="compare-result-item"><span class="name">No schools found</span></div>';
|
||||
} else {
|
||||
elements.compareResults.innerHTML = results.slice(0, 10).map(school => `
|
||||
<div class="compare-result-item" data-urn="${school.urn}" data-name="${escapeHtml(school.school_name)}">
|
||||
<div class="name">${escapeHtml(school.school_name)}</div>
|
||||
<div class="location">${escapeHtml(school.local_authority || '')}${school.postcode ? ' • ' + escapeHtml(school.postcode) : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
elements.compareResults.querySelectorAll('.compare-result-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const urn = parseInt(item.dataset.urn);
|
||||
const school = data.schools.find(s => s.urn === urn);
|
||||
if (school) {
|
||||
addToComparison(school);
|
||||
elements.compareSearch.value = '';
|
||||
elements.compareResults.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
elements.compareResults.classList.add('active');
|
||||
}, 300);
|
||||
});
|
||||
|
||||
elements.compareSearch.addEventListener('blur', () => {
|
||||
setTimeout(() => elements.compareResults.classList.remove('active'), 200);
|
||||
});
|
||||
|
||||
elements.compareSearch.addEventListener('focus', () => {
|
||||
if (elements.compareSearch.value.trim().length >= 2) {
|
||||
elements.compareResults.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Metric selector
|
||||
elements.metricSelect.addEventListener('change', updateComparisonChart);
|
||||
|
||||
// Rankings
|
||||
elements.rankingMetric.addEventListener('change', loadRankings);
|
||||
elements.rankingYear.addEventListener('change', loadRankings);
|
||||
|
||||
// Modal
|
||||
elements.modalClose.addEventListener('click', closeModal);
|
||||
elements.modal.querySelector('.modal-backdrop').addEventListener('click', closeModal);
|
||||
|
||||
elements.addToCompare.addEventListener('click', () => {
|
||||
if (!currentSchoolData) return;
|
||||
|
||||
const urn = currentSchoolData.school_info.urn;
|
||||
const isSelected = selectedSchools.some(s => s.urn === urn);
|
||||
|
||||
if (isSelected) {
|
||||
removeFromComparison(urn);
|
||||
elements.addToCompare.textContent = 'Add to Compare';
|
||||
} else {
|
||||
addToComparison(currentSchoolData.school_info);
|
||||
elements.addToCompare.textContent = 'Remove from Compare';
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
261
frontend/index.html
Normal file
261
frontend/index.html
Normal file
@@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Primary School Compass | Wandsworth & Merton</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="noise-overlay"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="20" cy="20" r="18" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="20" r="4" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">Primary School Compass</span>
|
||||
<span class="logo-subtitle">Wandsworth & Merton</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a href="#" class="nav-link active" data-view="dashboard">Dashboard</a>
|
||||
<a href="#" class="nav-link" data-view="compare">Compare</a>
|
||||
<a href="#" class="nav-link" data-view="rankings">Rankings</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<!-- Dashboard View -->
|
||||
<section id="dashboard-view" class="view active">
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">Primary Schools in Wandsworth & Merton</h1>
|
||||
<p class="hero-subtitle">Compare KS2 performance data from the last 5 years across local primary schools</p>
|
||||
</div>
|
||||
|
||||
<div class="search-section">
|
||||
<div class="search-container">
|
||||
<input type="text" id="school-search" class="search-input" placeholder="Search primary schools by name...">
|
||||
<div class="search-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="local-authority-filter" class="filter-select">
|
||||
<option value="">All Areas</option>
|
||||
<option value="Wandsworth">Wandsworth</option>
|
||||
<option value="Merton">Merton</option>
|
||||
</select>
|
||||
<select id="type-filter" class="filter-select">
|
||||
<option value="">All School Types</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schools-grid" id="schools-grid">
|
||||
<!-- School cards populated by JS -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Compare View -->
|
||||
<section id="compare-view" class="view">
|
||||
<div class="compare-header">
|
||||
<h2 class="section-title">Compare Primary Schools</h2>
|
||||
<p class="section-subtitle">Select schools to compare their KS2 performance over time</p>
|
||||
</div>
|
||||
|
||||
<div class="selected-schools" id="selected-schools">
|
||||
<div class="empty-selection">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="6" y="10" width="36" height="28" rx="2"/>
|
||||
<path d="M6 18h36"/>
|
||||
<circle cx="14" cy="14" r="2" fill="currentColor"/>
|
||||
<circle cx="22" cy="14" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Search and add schools to compare</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compare-search-section">
|
||||
<input type="text" id="compare-search" class="search-input" placeholder="Add a school to compare...">
|
||||
<div id="compare-results" class="compare-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="charts-section" id="charts-section" style="display: none;">
|
||||
<div class="metric-selector">
|
||||
<label>Select KS2 Metric:</label>
|
||||
<select id="metric-select" class="filter-select">
|
||||
<optgroup label="Expected Standard">
|
||||
<option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option>
|
||||
<option value="reading_expected_pct">Reading Expected %</option>
|
||||
<option value="writing_expected_pct">Writing Expected %</option>
|
||||
<option value="maths_expected_pct">Maths Expected %</option>
|
||||
<option value="gps_expected_pct">GPS Expected %</option>
|
||||
<option value="science_expected_pct">Science Expected %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
<option value="rwm_high_pct">RWM Combined Higher %</option>
|
||||
<option value="reading_high_pct">Reading Higher %</option>
|
||||
<option value="writing_high_pct">Writing Higher %</option>
|
||||
<option value="maths_high_pct">Maths Higher %</option>
|
||||
<option value="gps_high_pct">GPS Higher %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
<option value="reading_progress">Reading Progress</option>
|
||||
<option value="writing_progress">Writing Progress</option>
|
||||
<option value="maths_progress">Maths Progress</option>
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
<option value="reading_avg_score">Reading Avg Score</option>
|
||||
<option value="maths_avg_score">Maths Avg Score</option>
|
||||
<option value="gps_avg_score">GPS Avg Score</option>
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
<option value="rwm_expected_boys_pct">RWM Expected % (Boys)</option>
|
||||
<option value="rwm_expected_girls_pct">RWM Expected % (Girls)</option>
|
||||
<option value="rwm_high_boys_pct">RWM Higher % (Boys)</option>
|
||||
<option value="rwm_high_girls_pct">RWM Higher % (Girls)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
<option value="rwm_expected_disadvantaged_pct">RWM Expected % (Disadvantaged)</option>
|
||||
<option value="rwm_expected_non_disadvantaged_pct">RWM Expected % (Non-Disadvantaged)</option>
|
||||
<option value="disadvantaged_gap">Disadvantaged Gap vs National</option>
|
||||
</optgroup>
|
||||
<optgroup label="School Context">
|
||||
<option value="disadvantaged_pct">% Disadvantaged Pupils</option>
|
||||
<option value="eal_pct">% EAL Pupils</option>
|
||||
<option value="sen_support_pct">% SEN Support</option>
|
||||
<option value="stability_pct">% Pupil Stability</option>
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
<option value="rwm_expected_3yr_pct">RWM Expected % (3-Year Avg)</option>
|
||||
<option value="reading_avg_3yr">Reading Score (3-Year Avg)</option>
|
||||
<option value="maths_avg_3yr">Maths Score (3-Year Avg)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas id="comparison-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table" id="comparison-table">
|
||||
<thead>
|
||||
<tr id="table-header"></tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rankings View -->
|
||||
<section id="rankings-view" class="view">
|
||||
<div class="rankings-header">
|
||||
<h2 class="section-title">Primary School Rankings</h2>
|
||||
<p class="section-subtitle">Top performing schools in Wandsworth & Merton by KS2 metric</p>
|
||||
</div>
|
||||
|
||||
<div class="rankings-controls">
|
||||
<select id="ranking-metric" class="filter-select">
|
||||
<optgroup label="Expected Standard">
|
||||
<option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option>
|
||||
<option value="reading_expected_pct">Reading Expected %</option>
|
||||
<option value="writing_expected_pct">Writing Expected %</option>
|
||||
<option value="maths_expected_pct">Maths Expected %</option>
|
||||
<option value="gps_expected_pct">GPS Expected %</option>
|
||||
<option value="science_expected_pct">Science Expected %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
<option value="rwm_high_pct">RWM Combined Higher %</option>
|
||||
<option value="reading_high_pct">Reading Higher %</option>
|
||||
<option value="writing_high_pct">Writing Higher %</option>
|
||||
<option value="maths_high_pct">Maths Higher %</option>
|
||||
<option value="gps_high_pct">GPS Higher %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
<option value="reading_progress">Reading Progress</option>
|
||||
<option value="writing_progress">Writing Progress</option>
|
||||
<option value="maths_progress">Maths Progress</option>
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
<option value="reading_avg_score">Reading Avg Score</option>
|
||||
<option value="maths_avg_score">Maths Avg Score</option>
|
||||
<option value="gps_avg_score">GPS Avg Score</option>
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
<option value="rwm_expected_boys_pct">RWM Expected % (Boys)</option>
|
||||
<option value="rwm_expected_girls_pct">RWM Expected % (Girls)</option>
|
||||
<option value="rwm_high_boys_pct">RWM Higher % (Boys)</option>
|
||||
<option value="rwm_high_girls_pct">RWM Higher % (Girls)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
<option value="rwm_expected_disadvantaged_pct">RWM Expected % (Disadvantaged)</option>
|
||||
<option value="rwm_expected_non_disadvantaged_pct">RWM Expected % (Non-Disadvantaged)</option>
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
<option value="rwm_expected_3yr_pct">RWM Expected % (3-Year Avg)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<select id="ranking-year" class="filter-select">
|
||||
<!-- Populated by JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="rankings-list" id="rankings-list">
|
||||
<!-- Rankings populated by JS -->
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- School Detail Modal -->
|
||||
<div class="modal" id="school-modal">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" id="modal-close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-school-name"></h2>
|
||||
<div class="modal-meta" id="modal-meta"></div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-chart-container">
|
||||
<canvas id="school-detail-chart"></canvas>
|
||||
</div>
|
||||
<div class="modal-stats" id="modal-stats"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" id="add-to-compare">Add to Compare</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/download-data" target="_blank">UK Government - Compare School Performance</a></p>
|
||||
<p class="footer-note">Primary school (KS2) data for Wandsworth and Merton. Data from 2019-2020, 2020-2021, 2021-2022 unavailable due to COVID-19 disruption.</p>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
931
frontend/styles.css
Normal file
931
frontend/styles.css
Normal file
@@ -0,0 +1,931 @@
|
||||
/*
|
||||
* School Performance Compass
|
||||
* A warm, editorial design inspired by quality publications
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Warm, sophisticated palette */
|
||||
--bg-primary: #faf7f2;
|
||||
--bg-secondary: #f3ede4;
|
||||
--bg-card: #ffffff;
|
||||
--bg-accent: #1a1612;
|
||||
|
||||
--text-primary: #1a1612;
|
||||
--text-secondary: #5c564d;
|
||||
--text-muted: #8a847a;
|
||||
--text-inverse: #faf7f2;
|
||||
|
||||
--accent-coral: #e07256;
|
||||
--accent-coral-dark: #c45a3f;
|
||||
--accent-teal: #2d7d7d;
|
||||
--accent-teal-light: #3a9e9e;
|
||||
--accent-gold: #c9a227;
|
||||
--accent-navy: #2c3e50;
|
||||
|
||||
/* Chart colors */
|
||||
--chart-1: #e07256;
|
||||
--chart-2: #2d7d7d;
|
||||
--chart-3: #c9a227;
|
||||
--chart-4: #7b68a6;
|
||||
--chart-5: #3498db;
|
||||
|
||||
--border-color: #e5dfd5;
|
||||
--shadow-soft: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
--shadow-medium: 0 4px 20px rgba(26, 22, 18, 0.1);
|
||||
--shadow-strong: 0 8px 40px rgba(26, 22, 18, 0.15);
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
--transition: 0.2s ease;
|
||||
--transition-slow: 0.4s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Subtle noise texture overlay */
|
||||
.noise-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
z-index: 1000;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--accent-coral);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.6rem 1.2rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--bg-accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.search-section {
|
||||
max-width: 700px;
|
||||
margin: 2rem auto 3rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem 1rem 3.5rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral);
|
||||
box-shadow: 0 0 0 4px rgba(224, 114, 86, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.6rem 2rem 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235c564d' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
|
||||
/* Schools Grid */
|
||||
.schools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.school-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.school-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: var(--accent-coral);
|
||||
transform: scaleY(0);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.school-card:hover {
|
||||
border-color: var(--accent-coral);
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.school-card:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.school-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.school-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.school-tag.type {
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.school-address {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.school-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-value.positive {
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: var(--accent-coral);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Section Titles */
|
||||
.section-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Compare View */
|
||||
.compare-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.selected-schools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
min-height: 100px;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-selection {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selected-school-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.selected-school-tag .remove {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.selected-school-tag .remove:hover {
|
||||
background: var(--accent-coral);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.compare-search-section {
|
||||
max-width: 500px;
|
||||
margin: 0 auto 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-medium);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compare-results.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.compare-result-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.compare-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.compare-result-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.compare-result-item .name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.compare-result-item .location {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Charts Section */
|
||||
.charts-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.metric-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-selector label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Data Table */
|
||||
.data-table-container {
|
||||
overflow-x: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: rgba(224, 114, 86, 0.03);
|
||||
}
|
||||
|
||||
/* Rankings View */
|
||||
.rankings-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.rankings-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.rankings-list {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 0.75rem;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ranking-item:hover {
|
||||
border-color: var(--accent-coral);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.ranking-position {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ranking-position.top-3 {
|
||||
background: linear-gradient(135deg, var(--accent-gold), #d4af37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ranking-position:not(.top-3) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ranking-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ranking-name {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ranking-location {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ranking-score {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ranking-score-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.ranking-score-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 200;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(26, 22, 18, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xl);
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-strong);
|
||||
animation: modalIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--accent-coral);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-close svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 2rem 2rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-chart-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.modal-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-stats-section {
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-stats-section h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.modal-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-stat {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-coral);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-coral-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
margin-top: 3rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent-teal);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent-coral);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schools-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.rankings-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user