introducing tooltips
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s

This commit is contained in:
Tudor
2026-01-09 15:10:39 +00:00
parent 79cf16d6b3
commit c63e0e2682
2 changed files with 448 additions and 22 deletions

View File

@@ -49,6 +49,89 @@ const CHART_COLORS = [
"#9b59b6", // violet
];
// Term definitions for tooltips
const TERM_DEFINITIONS = {
rwm_expected: {
title: "RWM Expected Standard",
description:
"The percentage of pupils meeting the expected standard in Reading, Writing and Maths combined at the end of Key Stage 2 (Year 6).",
note: "National average: 61%",
},
rwm_higher: {
title: "RWM Higher Standard",
description:
"The percentage of pupils exceeding the expected standard and reaching the higher standard in Reading, Writing and Maths combined.",
note: "National average: 8%",
},
gps_expected: {
title: "GPS Expected Standard",
description:
"The percentage of pupils meeting the expected standard in Grammar, Punctuation and Spelling at the end of Key Stage 2.",
note: "National average: 72%",
},
science_expected: {
title: "Science Expected Standard",
description:
"The percentage of pupils meeting the expected standard in Science, assessed by teacher judgement at the end of Key Stage 2.",
note: "National average: 80%",
},
reading_progress: {
title: "Reading Progress Score",
description:
"A value-added measure showing how much progress pupils made in Reading between KS1 and KS2, compared to pupils with similar starting points nationally.",
note: "A score of 0 is average. Positive = above-average progress.",
},
writing_progress: {
title: "Writing Progress Score",
description:
"A value-added measure showing how much progress pupils made in Writing between KS1 and KS2, compared to pupils with similar starting points nationally.",
note: "A score of 0 is average. Positive = above-average progress.",
},
maths_progress: {
title: "Maths Progress Score",
description:
"A value-added measure showing how much progress pupils made in Maths between KS1 and KS2, compared to pupils with similar starting points nationally.",
note: "A score of 0 is average. Positive = above-average progress.",
},
disadvantaged_pct: {
title: "% Disadvantaged",
description:
"The percentage of pupils eligible for free school meals or who have been at any point in the last six years, or are looked-after children.",
note: "Affects school funding through the Pupil Premium.",
},
eal_pct: {
title: "% EAL",
description:
"The percentage of pupils whose first language is known or believed to be other than English. These pupils may need additional language support.",
note: null,
},
sen_support_pct: {
title: "% SEN Support",
description:
"The percentage of pupils receiving Special Educational Needs Support. These pupils need extra help but do not have an Education, Health and Care Plan.",
note: "Does not include pupils with EHCPs.",
},
total_pupils: {
title: "Total Pupils",
description: "The total number of pupils enrolled at the school.",
note: null,
},
};
/**
* Creates an info trigger button for a term tooltip
* @param {string} termKey - Key from TERM_DEFINITIONS
* @returns {string} HTML string for the info trigger
*/
function createInfoTrigger(termKey) {
const definition = TERM_DEFINITIONS[termKey];
if (!definition) return "";
const label = `What is ${definition.title}?`;
return `<button class="info-trigger" type="button" data-term="${termKey}" aria-label="${label}" aria-expanded="false"><svg class="info-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v4"/><circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none"/></svg></button>`;
}
// Map instances (stored to allow cleanup)
const schoolMaps = new Map();
@@ -290,6 +373,9 @@ async function init() {
// Always set up event listeners and routing, even if data loading fails
setupEventListeners();
// Initialize tooltip manager
tooltipManager = new TooltipManager();
// Handle initial route
handleRoute();
@@ -523,15 +609,15 @@ function renderFeaturedSchools(schools) {
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
${getTrendIndicator(school.rwm_expected_pct, school.prev_rwm_expected_pct)}
</div>
<div class="stat-label">RWM Expected</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Expected${createInfoTrigger("rwm_expected")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${formatMetricValue(school.rwm_high_pct, "rwm_high_pct")}</div>
<div class="stat-label">RWM Higher</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Higher${createInfoTrigger("rwm_higher")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${school.total_pupils || "-"}</div>
<div class="stat-label">Pupils</div>
<div class="stat-label"><span class="stat-label-with-info">Pupils${createInfoTrigger("total_pupils")}</span></div>
</div>
</div>
${mapContainer}
@@ -547,8 +633,8 @@ function renderFeaturedSchools(schools) {
// Add click handlers
elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => {
card.addEventListener("click", (e) => {
// Don't trigger if clicking on map
if (e.target.closest(".school-map")) return;
// Don't trigger if clicking on map or info trigger
if (e.target.closest(".school-map") || e.target.closest(".info-trigger")) return;
const urn = parseInt(card.dataset.urn);
openSchoolModal(urn);
});
@@ -839,15 +925,15 @@ function renderSchools(schools) {
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
${getTrendIndicator(school.rwm_expected_pct, school.prev_rwm_expected_pct)}
</div>
<div class="stat-label">RWM Expected</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Expected${createInfoTrigger("rwm_expected")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${formatMetricValue(school.rwm_high_pct, "rwm_high_pct")}</div>
<div class="stat-label">RWM Higher</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Higher${createInfoTrigger("rwm_higher")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${school.total_pupils || "-"}</div>
<div class="stat-label">Pupils</div>
<div class="stat-label"><span class="stat-label-with-info">Pupils${createInfoTrigger("total_pupils")}</span></div>
</div>
</div>
${mapContainer}
@@ -878,8 +964,8 @@ function renderSchools(schools) {
// Add click handlers
elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => {
card.addEventListener("click", (e) => {
// Don't trigger if clicking on map
if (e.target.closest(".school-map")) return;
// Don't trigger if clicking on map or info trigger
if (e.target.closest(".school-map") || e.target.closest(".info-trigger")) return;
const urn = parseInt(card.dataset.urn);
openSchoolModal(urn);
});
@@ -1259,19 +1345,19 @@ async function openSchoolModal(urn) {
<div class="modal-stats-grid">
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.rwm_expected_pct, "rwm_expected_pct")} ${getTrendIndicator(latest.rwm_expected_pct, prevRwm)}</div>
<div class="modal-stat-label">RWM Expected</div>
<div class="modal-stat-label">RWM Expected${createInfoTrigger("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 class="modal-stat-label">RWM Higher${createInfoTrigger("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 class="modal-stat-label">GPS Expected${createInfoTrigger("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 class="modal-stat-label">Science Expected${createInfoTrigger("science_expected")}</div>
</div>
</div>
</div>
@@ -1280,15 +1366,15 @@ async function openSchoolModal(urn) {
<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 class="modal-stat-label">Reading${createInfoTrigger("reading_progress")}</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 class="modal-stat-label">Writing${createInfoTrigger("writing_progress")}</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 class="modal-stat-label">Maths${createInfoTrigger("maths_progress")}</div>
</div>
</div>
</div>
@@ -1297,19 +1383,19 @@ async function openSchoolModal(urn) {
<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 class="modal-stat-label">Total Pupils${createInfoTrigger("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 class="modal-stat-label">% Disadvantaged${createInfoTrigger("disadvantaged_pct")}</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 class="modal-stat-label">% EAL${createInfoTrigger("eal_pct")}</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 class="modal-stat-label">% SEN Support${createInfoTrigger("sen_support_pct")}</div>
</div>
</div>
</div>
@@ -1740,3 +1826,218 @@ function setupEventListeners() {
}
});
}
// =============================================================================
// TOOLTIP MANAGER
// =============================================================================
class TooltipManager {
constructor() {
this.activeTooltip = null;
this.showTimeout = null;
this.hideTimeout = null;
this.isTouchDevice =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
this.init();
}
init() {
// Create tooltip container element (singleton)
this.tooltipEl = document.createElement("div");
this.tooltipEl.className = "tooltip";
this.tooltipEl.setAttribute("role", "tooltip");
this.tooltipEl.setAttribute("aria-hidden", "true");
document.body.appendChild(this.tooltipEl);
// Event delegation on document
this.bindEvents();
}
bindEvents() {
if (this.isTouchDevice) {
document.addEventListener("click", this.handleTouchClick.bind(this));
} else {
document.addEventListener(
"mouseenter",
this.handleMouseEnter.bind(this),
true
);
document.addEventListener(
"mouseleave",
this.handleMouseLeave.bind(this),
true
);
document.addEventListener("focusin", this.handleFocusIn.bind(this));
document.addEventListener("focusout", this.handleFocusOut.bind(this));
}
// Escape key closes tooltip
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.activeTooltip) {
this.hide();
}
});
}
handleMouseEnter(e) {
const trigger = e.target.closest(".info-trigger");
if (!trigger) return;
clearTimeout(this.hideTimeout);
this.showTimeout = setTimeout(() => {
this.show(trigger);
}, 150);
}
handleMouseLeave(e) {
const trigger = e.target.closest(".info-trigger");
const tooltip = e.target.closest(".tooltip");
if (!trigger && !tooltip) return;
// Check if moving between trigger and tooltip
const relatedTarget = e.relatedTarget;
if (
relatedTarget?.closest(".info-trigger") === this.activeTooltip ||
relatedTarget?.closest(".tooltip")
) {
return;
}
clearTimeout(this.showTimeout);
this.hideTimeout = setTimeout(() => {
this.hide();
}, 100);
}
handleFocusIn(e) {
const trigger = e.target.closest(".info-trigger");
if (!trigger) return;
clearTimeout(this.hideTimeout);
this.show(trigger);
}
handleFocusOut(e) {
const trigger = e.target.closest(".info-trigger");
if (!trigger) return;
this.hideTimeout = setTimeout(() => {
this.hide();
}, 100);
}
handleTouchClick(e) {
const trigger = e.target.closest(".info-trigger");
if (trigger) {
e.preventDefault();
e.stopPropagation();
if (this.activeTooltip === trigger) {
this.hide();
} else {
this.show(trigger);
}
return;
}
// Tap outside closes tooltip
if (this.activeTooltip && !e.target.closest(".tooltip")) {
this.hide();
}
}
show(trigger) {
const termKey = trigger.dataset.term;
const definition = TERM_DEFINITIONS[termKey];
if (!definition) {
console.warn(`No definition found for term: ${termKey}`);
return;
}
// Build tooltip content
let content = "";
if (definition.title) {
content += `<div class="tooltip-title">${definition.title}</div>`;
}
content += `<div class="tooltip-description">${definition.description}</div>`;
if (definition.note) {
content += `<div class="tooltip-note">${definition.note}</div>`;
}
this.tooltipEl.innerHTML = content;
// Make tooltip visible first so we can measure it
this.tooltipEl.style.visibility = "hidden";
this.tooltipEl.style.opacity = "0";
this.tooltipEl.classList.add("visible");
// Position tooltip
this.position(trigger);
// Show tooltip with animation
this.tooltipEl.style.visibility = "";
this.tooltipEl.style.opacity = "";
this.tooltipEl.setAttribute("aria-hidden", "false");
trigger.setAttribute("aria-expanded", "true");
this.activeTooltip = trigger;
}
hide() {
if (!this.activeTooltip) return;
this.tooltipEl.classList.remove("visible");
this.tooltipEl.setAttribute("aria-hidden", "true");
this.activeTooltip.setAttribute("aria-expanded", "false");
this.activeTooltip = null;
}
position(trigger) {
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = this.tooltipEl.getBoundingClientRect();
// Determine placement: prefer top, fall back to bottom if not enough space
const spaceAbove = triggerRect.top;
const tooltipHeight = tooltipRect.height || 100;
let placement = "top";
let top;
if (spaceAbove < tooltipHeight + 20) {
placement = "bottom";
top = triggerRect.bottom + 10 + window.scrollY;
} else {
top = triggerRect.top - tooltipHeight - 10 + window.scrollY;
}
// Horizontal centering with edge detection
let left =
triggerRect.left +
triggerRect.width / 2 -
tooltipRect.width / 2 +
window.scrollX;
// Prevent overflow on left
if (left < 10) {
left = 10;
}
// Prevent overflow on right
const rightEdge = left + tooltipRect.width;
if (rightEdge > window.innerWidth - 10) {
left = window.innerWidth - tooltipRect.width - 10;
}
this.tooltipEl.style.top = `${top}px`;
this.tooltipEl.style.left = `${left}px`;
this.tooltipEl.dataset.placement = placement;
}
}
// Global tooltip manager instance
let tooltipManager = null;

View File

@@ -1396,3 +1396,128 @@ body {
}
}
/* =============================================================================
TOOLTIP SYSTEM
============================================================================= */
/* Info Icon Trigger */
.info-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 0.25rem;
background: none;
border: none;
cursor: help;
color: var(--text-muted);
opacity: 0.6;
transition: var(--transition);
vertical-align: middle;
border-radius: 50%;
}
.info-trigger:hover,
.info-trigger:focus {
color: var(--accent-teal);
opacity: 1;
}
.info-trigger:focus {
outline: none;
box-shadow: 0 0 0 2px var(--accent-teal);
}
.info-trigger:focus:not(:focus-visible) {
box-shadow: none;
}
.info-trigger:focus-visible {
box-shadow: 0 0 0 2px var(--accent-teal);
}
/* Info Icon SVG */
.info-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.modal-stat-label .info-icon {
width: 14px;
height: 14px;
}
/* Tooltip Container */
.tooltip {
position: absolute;
z-index: 3000;
min-width: 200px;
max-width: 280px;
padding: 0.75rem 1rem;
background: var(--bg-accent);
color: var(--text-inverse);
border-radius: var(--radius-md);
box-shadow: var(--shadow-medium);
font-family: 'DM Sans', sans-serif;
font-size: 0.8125rem;
line-height: 1.5;
text-transform: none;
letter-spacing: normal;
text-align: left;
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease, visibility 150ms ease;
pointer-events: none;
}
.tooltip.visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
/* Tooltip Arrow - Top Placement (arrow points down) */
.tooltip[data-placement="top"]::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: var(--bg-accent);
}
/* Tooltip Arrow - Bottom Placement (arrow points up) */
.tooltip[data-placement="bottom"]::after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-bottom-color: var(--bg-accent);
}
/* Tooltip Title */
.tooltip-title {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
/* Tooltip Note (for context like national average) */
.tooltip-note {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(250, 247, 242, 0.2);
font-size: 0.75rem;
opacity: 0.8;
}
/* Label wrapper for inline icon */
.stat-label-with-info {
display: inline-flex;
align-items: center;
justify-content: center;
}