From c63e0e268230b121fc149cb191c1056a6fa4c3c7 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 9 Jan 2026 15:10:39 +0000 Subject: [PATCH] introducing tooltips --- frontend/app.js | 343 +++++++++++++++++++++++++++++++++++++++++--- frontend/styles.css | 127 +++++++++++++++- 2 files changed, 448 insertions(+), 22 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 2d2b5d6..9373592 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 ``; +} + // 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)} -
RWM Expected
+
RWM Expected${createInfoTrigger("rwm_expected")}
${formatMetricValue(school.rwm_high_pct, "rwm_high_pct")}
-
RWM Higher
+
RWM Higher${createInfoTrigger("rwm_higher")}
${school.total_pupils || "-"}
-
Pupils
+
Pupils${createInfoTrigger("total_pupils")}
${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)} -
RWM Expected
+
RWM Expected${createInfoTrigger("rwm_expected")}
${formatMetricValue(school.rwm_high_pct, "rwm_high_pct")}
-
RWM Higher
+
RWM Higher${createInfoTrigger("rwm_higher")}
${school.total_pupils || "-"}
-
Pupils
+
Pupils${createInfoTrigger("total_pupils")}
${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) { @@ -1280,15 +1366,15 @@ async function openSchoolModal(urn) { @@ -1297,19 +1383,19 @@ async function openSchoolModal(urn) { @@ -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 += `
${definition.title}
`; + } + content += `
${definition.description}
`; + if (definition.note) { + content += `
${definition.note}
`; + } + + 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; diff --git a/frontend/styles.css b/frontend/styles.css index 3e0d700..a31c628 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1389,10 +1389,135 @@ body { margin: 1rem; max-height: calc(100vh - 2rem); } - + .rankings-controls { flex-direction: column; align-items: stretch; } } +/* ============================================================================= + 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; +}