From c1bfa83963e3cf9762589b09acedec0e27ca31ee Mon Sep 17 00:00:00 2001 From: sHa Date: Fri, 15 Aug 2025 15:03:40 +0300 Subject: [PATCH] Enhance Geography Quiz functionality and UI - Updated Header component to recognize new quiz route for geography. - Improved InlineSvg component to ensure proper SVG scaling and color handling. - Refactored GeographyQuiz component to streamline game logic, including session management, question generation, and scoring. - Added achievements tracking and settings management for user preferences. - Enhanced UI elements for better user experience, including loading states and responsive design adjustments. - Implemented auto-advance timer for quiz questions and improved state restoration on page reload. --- public/data/world.svg | 20 +- public/data/world_hd.svg | 2 - src/components/CountryMap.svelte | 333 ++++++++++-- src/components/Header.svelte | 7 +- src/components/InlineSvg.svelte | 93 ++-- src/pages/GeographyQuiz.svelte | 905 +++++++++++++++++++++++++++---- 6 files changed, 1154 insertions(+), 206 deletions(-) delete mode 100644 public/data/world_hd.svg diff --git a/public/data/world.svg b/public/data/world.svg index 52eec74..f458673 100644 --- a/public/data/world.svg +++ b/public/data/world.svg @@ -485,25 +485,25 @@ - + - + - + - + - + - + - + - + - + - + diff --git a/public/data/world_hd.svg b/public/data/world_hd.svg deleted file mode 100644 index f7d692c..0000000 --- a/public/data/world_hd.svg +++ /dev/null @@ -1,2 +0,0 @@ -Created with Raphaël 2.1.0 diff --git a/src/components/CountryMap.svelte b/src/components/CountryMap.svelte index 18e9c30..8cba89b 100644 --- a/src/components/CountryMap.svelte +++ b/src/components/CountryMap.svelte @@ -3,6 +3,7 @@ import InlineSvg from "./InlineSvg.svelte"; export let countryCodes = []; export let countryNames = []; + export let forceCenterKey = 0; export let mapPath = "/data/world.svg"; export let countryScale = false; export let scalePadding = 30; @@ -30,52 +31,203 @@ } function zoomIn() { zoom(1.2); + lastUserInteraction = Date.now(); + manualInteractionUntil = Date.now() + AUTO_CENTER_IGNORE_MS; } function zoomOut() { zoom(0.8); + lastUserInteraction = Date.now(); + manualInteractionUntil = Date.now() + AUTO_CENTER_IGNORE_MS; } function recenter() { - highlightCountries(); + // force recentering when user explicitly clicks the recenter button + highlightCountries(true); } // Highlight countries after SVG loads, using MutationObserver for reliability let observer; + // Track recent user interaction to avoid clobbering manual pan/zoom + let lastUserInteraction = 0; + // Increase grace window so auto-centering won't fight quick drags/releases. + // 2000ms prevents immediate snapping after pointerup but is short enough + // to allow programmatic centering shortly after. + const AUTO_CENTER_IGNORE_MS = 2000; // ms to ignore automatic recenters after user interaction + // If the user manually changed the viewBox (pan/zoom), set this to a timestamp + // until which auto-centering must not override the user's viewBox. + let manualInteractionUntil = 0; + let svgSeen = false; // whether we've already run the initial highlight for the inlined SVG + function highlightCountries() { + // backward-compatible signature: allow passing a force flag + const args = Array.from(arguments); + const forceCenter = args && args[0] === true; if (!wrapperRef) return; const svgEl = wrapperRef.querySelector("svg"); if (!svgEl) return; + // Clear previous highlights robustly. Try to restore any saved inline + // attributes; if they are missing (e.g. due to re-mounts) also remove + // the highlight styling if present. + try { + const candidateSelectors = 'path, g, polygon, rect, circle, ellipse, use'; + const allEls = Array.from(svgEl.querySelectorAll(candidateSelectors)); + allEls.forEach((el) => { + try { + const prevFill = el.getAttribute('data-geo-prev-fill'); + const prevStroke = el.getAttribute('data-geo-prev-stroke'); + const prevFilter = el.getAttribute('data-geo-prev-filter'); + + // If we saved explicit previous values, restore them (empty string means remove attr) + if (prevFill !== null) { + if (prevFill === '') el.removeAttribute('fill'); + else el.setAttribute('fill', prevFill); + } else { + // no saved value: if the element currently has the highlight color, remove it + try { + const curFill = el.getAttribute && el.getAttribute('fill'); + if (curFill && curFill.toLowerCase() === '#4f8cff') el.removeAttribute('fill'); + } catch (e) {} + } + + if (prevStroke !== null) { + if (prevStroke === '') el.removeAttribute('stroke'); + else el.setAttribute('stroke', prevStroke); + } else { + try { + const curStroke = el.getAttribute && el.getAttribute('stroke'); + if (curStroke && curStroke.toLowerCase() === '#222') el.removeAttribute('stroke'); + } catch (e) {} + } + + if (prevFilter !== null) { + if (prevFilter === '') el.style.filter = ''; + else el.style.filter = prevFilter; + } else { + try { + const cf = el.style && el.style.filter; + if (cf && cf.indexOf('4f8cff') !== -1) el.style.filter = ''; + } catch (e) {} + } + + // Clean up helper attributes if present + try { + el.removeAttribute('data-geo-prev-fill'); + el.removeAttribute('data-geo-prev-stroke'); + el.removeAttribute('data-geo-prev-filter'); + el.removeAttribute('data-geo-highlight'); + } catch (e) {} + } catch (err) {} + }); + } catch (err) {} + // Highlight by country code (id) let highlighted = []; countryCodes.forEach((code) => { const countryPath = svgEl.querySelector(`#${code}`); if (countryPath) { + // Save previous inline attributes so we can restore later + const prevFill = countryPath.getAttribute('fill'); + const prevStroke = countryPath.getAttribute('stroke'); + const prevFilter = countryPath.style.filter || ''; + if (prevFill !== null) countryPath.setAttribute('data-geo-prev-fill', prevFill); + else countryPath.setAttribute('data-geo-prev-fill', ''); + if (prevStroke !== null) countryPath.setAttribute('data-geo-prev-stroke', prevStroke); + else countryPath.setAttribute('data-geo-prev-stroke', ''); + countryPath.setAttribute('data-geo-prev-filter', prevFilter); + countryPath.setAttribute("fill", "#4f8cff"); countryPath.setAttribute("stroke", "#222"); countryPath.style.filter = "drop-shadow(0 0 4px #4f8cff44)"; + countryPath.setAttribute('data-geo-highlight', '1'); highlighted.push(countryPath); } }); - // Highlight by country name (name attribute or class) - const names = Array.isArray(countryNames) - ? countryNames - : countryNames - ? [countryNames] - : []; - names.forEach((name) => { - const pathsByName = svgEl.querySelectorAll(`[name='${name}']`); - const pathsByClass = svgEl.querySelectorAll( - `.${name.replace(/ /g, ".")}`, - ); - const allPaths = [...pathsByName, ...pathsByClass]; - allPaths.forEach((countryPath) => { - countryPath.setAttribute("fill", "#4f8cff"); - countryPath.setAttribute("stroke", "#222"); - countryPath.style.filter = "drop-shadow(0 0 4px #4f8cff44)"; - highlighted.push(countryPath); + // Highlight by country name (try many fallbacks: attributes, class names, children) + const names = Array.isArray(countryNames) ? countryNames : countryNames ? [countryNames] : []; + if (names.length > 0) { + // candidate elements to check for name matches + const candidateSelectors = 'path, g, polygon, rect, circle, ellipse, use'; + const candidates = Array.from(svgEl.querySelectorAll(candidateSelectors)); + + names.forEach((nameRaw) => { + if (!nameRaw) return; + const name = String(nameRaw).trim(); + if (!name) return; + const nameLower = name.toLowerCase(); + + const matched = new Set(); + + // First pass: strict attribute/title/id matches (case-insensitive) + try { + const attrsToCheck = ['name', 'data-name', 'id', 'title', 'inkscape:label']; + candidates.forEach((el) => { + for (const a of attrsToCheck) { + try { + const val = el.getAttribute && el.getAttribute(a); + if (val && String(val).trim().toLowerCase() === nameLower) { + matched.add(el); + return; + } + } catch (err) {} + } + // child <title> element (redundant with 'title' attr check but kept safe) + try { + const titleEl = el.querySelector && el.querySelector('title'); + if (titleEl && titleEl.textContent && titleEl.textContent.trim().toLowerCase() === nameLower) { + matched.add(el); + return; + } + } catch (err) {} + }); + } catch (err) {} + + // If we didn't find any strict matches, fall back to class/slug heuristics + if (matched.size === 0) { + try { + // Try direct selectors only when safe + try { + const byNameAttr = svgEl.querySelectorAll(`[name='${name}']`); + byNameAttr.forEach((el) => matched.add(el)); + } catch (err) {} + try { + const byClassDot = svgEl.querySelectorAll(`.${name.replace(/\s+/g, '.')}`); + byClassDot.forEach((el) => matched.add(el)); + } catch (err) {} + + // class list heuristics: slug (lower, dashes) + candidates.forEach((el) => { + try { + const cls = (el.className && (typeof el.className === 'string' ? el.className : el.className.baseVal)) || ''; + if (cls) { + const parts = cls.split(/\s+/).map((c) => c.trim()).filter(Boolean); + const slug = nameLower.replace(/[^a-z0-9]+/g, '-').replace(/^\-+|\-+$/g, ''); + if (parts.includes(name) || parts.includes(nameLower) || parts.includes(slug)) matched.add(el); + } + } catch (err) {} + }); + } catch (err) {} + } + + // Apply highlighting to matched elements + matched.forEach((countryPath) => { + const prevFill = countryPath.getAttribute('fill'); + const prevStroke = countryPath.getAttribute('stroke'); + const prevFilter = countryPath.style.filter || ''; + if (prevFill !== null) countryPath.setAttribute('data-geo-prev-fill', prevFill); + else countryPath.setAttribute('data-geo-prev-fill', ''); + if (prevStroke !== null) countryPath.setAttribute('data-geo-prev-stroke', prevStroke); + else countryPath.setAttribute('data-geo-prev-stroke', ''); + countryPath.setAttribute('data-geo-prev-filter', prevFilter); + + countryPath.setAttribute('fill', '#4f8cff'); + countryPath.setAttribute('stroke', '#222'); + countryPath.style.filter = 'drop-shadow(0 0 4px #4f8cff44)'; + countryPath.setAttribute('data-geo-highlight', '1'); + highlighted.push(countryPath); + }); }); - }); - // Smart scale/center if enabled and at least one country is highlighted - if (countryScale && highlighted.length > 0) { + } + // Smart scale/center if enabled and at least one country is highlighted + if (countryScale && highlighted.length > 0) { // Compute bounding box of all highlighted paths let minX = Infinity, minY = Infinity, @@ -100,44 +252,94 @@ isFinite(maxX) && isFinite(maxY) ) { - minX -= scalePadding; - minY -= scalePadding; - maxX += scalePadding; - maxY += scalePadding; - const width = maxX - minX; - const height = maxY - minY; - svgEl.setAttribute( - "viewBox", - `${minX} ${minY} ${width} ${height}`, - ); + // Only auto-center if the parent forced it, or the user hasn't + // manually adjusted the viewBox recently (manualInteractionUntil). + const now = Date.now(); + const allowAutoCenter = forceCenter || now > manualInteractionUntil; + if (allowAutoCenter) { + minX -= scalePadding; + minY -= scalePadding; + maxX += scalePadding; + maxY += scalePadding; + const width = maxX - minX; + const height = maxY - minY; + svgEl.setAttribute( + "viewBox", + `${minX} ${minY} ${width} ${height}`, + ); + } } } } + + // Watch for forceCenterKey changes to override user interactions + $: if (typeof forceCenterKey !== 'undefined') { + // When parent toggles forceCenterKey it intends to request a recenter. + // However, if the user interacted recently we should avoid snapping the + // map back immediately. Only force if manualInteractionUntil has passed. + setTimeout(() => { + const now = Date.now(); + if (now > manualInteractionUntil) { + // reset interaction guard so auto-center is allowed and force center + manualInteractionUntil = 0; + highlightCountries(true); + } else { + // skip forcing center because the user just interacted; the quiz + // can try again (parent may choose to re-request centering later). + } + }, 30); + } // --- Map drag/move by mouse --- let isDragging = false; let dragStart = { x: 0, y: 0 }; let viewBoxStart = null; - function onMouseDown(e) { - const svgEl = wrapperRef.querySelector("svg"); + function onPointerDown(e) { + // Use pointer events for unified mouse/touch support + if (e && e.preventDefault) e.preventDefault(); + const svgEl = getSvgEl(); if (!svgEl) return; - isDragging = true; - dragStart = { x: e.clientX, y: e.clientY }; + isDragging = true; + lastUserInteraction = Date.now(); + manualInteractionUntil = Date.now() + AUTO_CENTER_IGNORE_MS; + const clientX = e.clientX; + const clientY = e.clientY; + dragStart = { x: clientX, y: clientY }; const vb = svgEl.getAttribute("viewBox"); if (vb) { const [x, y, w, h] = vb.split(" ").map(Number); viewBoxStart = { x, y, w, h }; } - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); + // set cursor to grabbing + try { + if (wrapperRef && wrapperRef.style) wrapperRef.style.cursor = 'grabbing'; + } catch (err) {} + + // Try to capture the pointer on the svg so we keep receiving events + try { + if (svgEl.setPointerCapture && typeof e.pointerId === 'number') { + svgEl.setPointerCapture(e.pointerId); + } + } catch (err) {} + + window.addEventListener('pointermove', onPointerMove, { passive: false }); + window.addEventListener('pointerup', onPointerUp); } - function onMouseMove(e) { + function onPointerMove(e) { if (!isDragging || !viewBoxStart) return; - const svgEl = wrapperRef.querySelector("svg"); + if (e && e.cancelable) { + try { e.preventDefault(); } catch (err) {} + } + // refresh interaction timestamp while the user is actively dragging + lastUserInteraction = Date.now(); + manualInteractionUntil = Date.now() + AUTO_CENTER_IGNORE_MS; + const clientX = e.clientX; + const clientY = e.clientY; + const svgEl = getSvgEl(); if (!svgEl) return; - const dx = e.clientX - dragStart.x; - const dy = e.clientY - dragStart.y; + const dx = clientX - dragStart.x; + const dy = clientY - dragStart.y; // Scale drag to SVG units const scaleX = viewBoxStart.w / wrapperRef.offsetWidth; const scaleY = viewBoxStart.h / wrapperRef.offsetHeight; @@ -149,11 +351,23 @@ ); } - function onMouseUp() { + function onPointerUp(e) { isDragging = false; - viewBoxStart = null; - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); + viewBoxStart = null; + // mark the time of the user's final interaction so auto-centering is suppressed + lastUserInteraction = Date.now(); + manualInteractionUntil = Date.now() + AUTO_CENTER_IGNORE_MS; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + try { + const svgEl = getSvgEl(); + if (svgEl && svgEl.releasePointerCapture && typeof e.pointerId === 'number') { + svgEl.releasePointerCapture(e.pointerId); + } + } catch (err) {} + try { + if (wrapperRef && wrapperRef.style) wrapperRef.style.cursor = 'grab'; + } catch (err) {} } function observeSvg() { @@ -163,8 +377,15 @@ highlightCountries(); }); observer.observe(wrapperRef, { childList: true, subtree: true }); - // Initial run in case SVG is already present - highlightCountries(); + // Initial run only once when the SVG appears to avoid re-highlighting + // after user interactions which can trigger component updates. + if (!svgSeen) { + const svgEl = wrapperRef.querySelector('svg'); + if (svgEl) { + svgSeen = true; + highlightCountries(); + } + } } onMount(() => { @@ -180,8 +401,8 @@ </script> <div class="country-map-section"> - <div bind:this={wrapperRef} role="application" style="width:100%;height:100%;position:relative; cursor: grab;"> - <InlineSvg path={mapPath} alt="World map" color={undefined} on:mousedown={onMouseDown} /> + <div bind:this={wrapperRef} role="application" tabindex="-1" class="svg-wrapper-inner" on:pointerdown={onPointerDown}> + <InlineSvg path={mapPath} alt="World map" color={undefined} /> {#if countryScale} <div class="map-controls-on-map"> <button class="zoom-btn-on-map" on:click={zoomIn}>+</button> @@ -196,8 +417,20 @@ .country-map-section { background: var(--color-card); border-radius: 12px; - padding: 1rem; + /* allow the section to fill parent's height so the svg can scale */ + padding: 0.5rem; box-shadow: 0 2px 8px 2px rgba(0, 0, 0, 0.08); + height: 100%; + display: flex; + align-items: stretch; + } + + /* inner wrapper that holds the InlineSvg should fill available space */ + .svg-wrapper-inner { + width: 100%; + height: 100%; + position: relative; + cursor: grab; } .map-controls-on-map { diff --git a/src/components/Header.svelte b/src/components/Header.svelte index b2968f6..b53707d 100644 --- a/src/components/Header.svelte +++ b/src/components/Header.svelte @@ -49,7 +49,12 @@ // Check if we're in game mode $: isGameMode = $location && $location.startsWith('/game'); - $: isQuizPage = $location && ($location.startsWith('/game/flags') || $location.startsWith('/game/capitals')); + // Treat known quiz routes as quiz pages so the header shows quiz stats/achievements + $: isQuizPage = $location && ( + $location.startsWith('/game/flags') || + $location.startsWith('/game/capitals') || + $location.startsWith('/game/geography') + ); // Determine default stats view based on quiz state $: { diff --git a/src/components/InlineSvg.svelte b/src/components/InlineSvg.svelte index 5a41162..ba15abf 100644 --- a/src/components/InlineSvg.svelte +++ b/src/components/InlineSvg.svelte @@ -27,24 +27,24 @@ const svg = doc.documentElement; // Fix SVG dimensions here too - const viewBox = svg.getAttribute('viewBox'); + const viewBox = svg.getAttribute("viewBox"); if (viewBox) { // Remove any existing style and dimension attributes - svg.removeAttribute('style'); - svg.removeAttribute('width'); - svg.removeAttribute('height'); + svg.removeAttribute("style"); + svg.removeAttribute("width"); + svg.removeAttribute("height"); // Set percentage dimensions to allow scaling - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); // svg.setAttribute('preserveAspectRatio', 'none'); // Ensure viewBox is preserved for proper clipping - svg.setAttribute('viewBox', viewBox); + svg.setAttribute("viewBox", viewBox); } else { - svg.removeAttribute('style'); - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); + svg.removeAttribute("style"); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); } // Set currentColor on SVG element if no fill is specified @@ -64,33 +64,33 @@ const svg = doc.documentElement; // Fix SVG dimensions based on viewBox - const viewBox = svg.getAttribute('viewBox'); + const viewBox = svg.getAttribute("viewBox"); if (viewBox) { // Remove any existing style attribute and fixed dimensions - svg.removeAttribute('style'); - svg.removeAttribute('width'); - svg.removeAttribute('height'); + svg.removeAttribute("style"); + svg.removeAttribute("width"); + svg.removeAttribute("height"); // Set percentage dimensions to allow scaling - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); // Ensure viewBox is preserved for proper clipping - svg.setAttribute('viewBox', viewBox); + svg.setAttribute("viewBox", viewBox); // Add preserveAspectRatio to ensure proper scaling - if (!svg.hasAttribute('preserveAspectRatio')) { - svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + if (!svg.hasAttribute("preserveAspectRatio")) { + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); } } else { // If no viewBox, remove style and set percentage dimensions - svg.removeAttribute('style'); - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); + svg.removeAttribute("style"); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); } // Ensure proper overflow handling - svg.setAttribute('overflow', 'visible'); // Let CSS handle the clipping + svg.setAttribute("overflow", "visible"); // Let CSS handle the clipping // Now process color changes // 1. Parse <style> rules and apply as inline attributes before removing <style> @@ -121,13 +121,21 @@ // Remove all <style> elements styleEls.forEach((styleEl) => styleEl.remove()); // Handle the new format with targets and sets if available - if (targets && sets && activeSet && typeof activeSet === 'string' && sets[activeSet]) { + if ( + targets && + sets && + activeSet && + typeof activeSet === "string" && + sets[activeSet] + ) { try { // Get the color assignments from the active set const colorAssignments = sets[activeSet]; // Apply each target-color pair - for (const [targetName, colorName] of Object.entries(colorAssignments)) { + for (const [targetName, colorName] of Object.entries( + colorAssignments, + )) { if (targets[targetName] && colors && colors[colorName]) { // Get the selector and determine if it's for fill or stroke const targetInfo = targets[targetName]; @@ -135,24 +143,24 @@ // Parse the selector to extract the target and attribute (fill/stroke) let selector, attribute; - if (typeof targetInfo === 'string') { - if (targetInfo.includes('&stroke')) { + if (typeof targetInfo === "string") { + if (targetInfo.includes("&stroke")) { // Format: "#element&stroke" - target stroke attribute - selector = targetInfo.split('&stroke')[0]; - attribute = 'stroke'; - } else if (targetInfo.includes('&fill')) { + selector = targetInfo.split("&stroke")[0]; + attribute = "stroke"; + } else if (targetInfo.includes("&fill")) { // Format: "#element&fill" - target fill attribute - selector = targetInfo.split('&fill')[0]; - attribute = 'fill'; + selector = targetInfo.split("&fill")[0]; + attribute = "fill"; } else { // Default is fill if not specified selector = targetInfo; - attribute = 'fill'; + attribute = "fill"; } } else { // Fallback for older format selector = targetInfo; - attribute = 'fill'; + attribute = "fill"; } // Proceed with selecting elements and applying colors @@ -160,13 +168,13 @@ const targetColor = colors[colorName]; // Apply the color to all elements matching this selector - targetElements.forEach(el => { + targetElements.forEach((el) => { if (colorName === "none") { // Special case for 'none' value el.setAttribute(attribute, "none"); - } else if (attribute === 'fill') { + } else if (attribute === "fill") { el.setAttribute("fill", targetColor); - } else if (attribute === 'stroke') { + } else if (attribute === "stroke") { el.setAttribute("stroke", targetColor); } }); @@ -191,7 +199,14 @@ return svgSource; } - $: path, color, colorConfig, targets, sets, activeSet, colors, fetchAndColorSvg(); + $: path, + color, + colorConfig, + targets, + sets, + activeSet, + colors, + fetchAndColorSvg(); </script> <div class="svg-wrapper" role="img" aria-label={alt || "SVG image"}> @@ -213,7 +228,7 @@ .svg-wrapper :global(svg) { width: 100%; - height: 100%; + height: auto; object-fit: contain; display: block; transform-origin: center; diff --git a/src/pages/GeographyQuiz.svelte b/src/pages/GeographyQuiz.svelte index 5d43374..adf3fc2 100644 --- a/src/pages/GeographyQuiz.svelte +++ b/src/pages/GeographyQuiz.svelte @@ -1,173 +1,870 @@ - <script> -import { quizInfo } from '../quizInfo/GeographyQuizInfo.js'; - import { onMount } from "svelte"; + import { applyTheme, setTheme, themeStore } from "../utils/theme.js"; + import { updateAchievementCount } from "../quizLogic/quizAchievements.js"; + import { saveSettings, loadSettings } from "../quizLogic/quizSettings.js"; + import { + loadGlobalStats, + updateGlobalStats, + } from "../quizLogic/quizGlobalStats.js"; + import { + saveSessionState, + loadSessionState, + clearSessionState, + createNewSessionState, + restoreSessionState, + } from "../quizLogic/quizSession.js"; + import { createAdvanceTimer } from "../quizLogic/advanceTimer.js"; + import { playCorrectSound, playWrongSound } from "../quizLogic/quizSound.js"; + import { quizInfo } from "../quizInfo/GeographyQuizInfo.js"; + import { onMount, onDestroy } from "svelte"; + import { + loadFlags as loadFlagsShared, + getCountryName, + pickWeightedFlag, + } from "../quizLogic/flags.js"; import Header from "../components/Header.svelte"; import Footer from "../components/Footer.svelte"; import CountryMap from "../components/CountryMap.svelte"; + import Achievements from "../components/Achievements.svelte"; + import QuizSettings from "../components/QuizSettings.svelte"; import QuizInfo from "../components/QuizInfo.svelte"; import ActionButtons from "../components/ActionButtons.svelte"; // Game data - let countries = []; + let flags = []; let currentQuestion = null; - let correctAnswer = null; - let options = []; + // Map parsing helpers - track which countries exist in the SVG + let mapSvgText = ""; + let availableIso = new Set(); + let availableNames = new Set(); + let availableSlugs = new Set(); + let mapParsed = false; + + function normalizeNameForLookup(n) { + if (!n) return ""; + return String(n).trim().toLowerCase(); + } + + function slugify(n) { + return normalizeNameForLookup(n).replace(/[^a-z0-9]+/g, "-").replace(/^[-]+|[-]+$/g, ""); + } + + function isFlagOnMap(flag) { + if (!mapParsed) return true; // allow through if we couldn't parse map + try { + const iso = flag?.meta?.["ISO code"] || flag?.meta?.["ISO Code"] || flag?.meta?.["ISO"] || flag?.ISO; + if (iso && availableIso.has(String(iso).trim())) return true; + const name = getCountryName(flag); + const n = normalizeNameForLookup(name); + if (n && availableNames.has(n)) return true; + if (n && availableSlugs.has(n)) return true; + const s = slugify(name); + if (s && availableSlugs.has(s)) return true; + } catch (err) { + return true; + } + return false; + } // Game states - let gameState = "welcome"; // 'welcome', 'loading', 'question', 'answered', 'session-complete' + let gameState = "welcome"; let quizSubpage = "welcome"; let selectedAnswer = null; let showResult = false; + + // advance timer + let advanceTimer; + let timerProgress = 0; + let timerDuration = 2000; + let questionKey = 0; + + // Scoring + let score = { correct: 0, total: 0, skipped: 0 }; + let gameStats = { correct: 0, wrong: 0, total: 0, skipped: 0 }; + let wrongAnswers = new Map(); + let correctAnswers = new Map(); + + // Achievements + let currentStreak = 0; + let showAchievements = false; + let achievementsComponent; + let achievementCount = { unlocked: 0, total: 0 }; + + // Settings + let autoAdvance = true; + let showSettings = false; + let settingsLoaded = false; + let showResetConfirmation = false; + let focusWrongAnswers = false; + let reduceCorrectAnswers = false; + let soundEnabled = true; let sessionLength = 10; + + // Session let currentSessionQuestions = 0; let sessionStats = { correct: 0, wrong: 0, + skipped: 0, total: 0, sessionLength: 10, }; let showSessionResults = false; let sessionInProgress = false; + let sessionStartTime = null; + let sessionRestoredFromReload = false; - async function loadCountries() { - const res = await fetch("/data/flags.json"); - const data = await res.json(); - countries = data.filter( - (c) => c.tags && c.tags.includes("Country") && c.code && c.meta?.country - ); + $: if (achievementsComponent) { + achievementCount = updateAchievementCount(achievementsComponent); } - function generateQuestion() { - // Pick correct country - const idx = Math.floor(Math.random() * countries.length); - correctAnswer = countries[idx]; - // Pick 3 random other countries - let other = countries.filter((c) => c.code !== correctAnswer.code); - other = shuffle(other).slice(0, 3); - options = shuffle([correctAnswer, ...other]); - selectedAnswer = null; - showResult = false; - questionKey++; - gameState = "question"; - quizSubpage = "quiz"; - currentSessionQuestions++; + $: if (settingsLoaded && typeof reduceCorrectAnswers !== "undefined") { + saveSettings("geographyQuizSettings", { + autoAdvance, + focusWrongAnswers, + reduceCorrectAnswers, + soundEnabled, + sessionLength, + }); } - function shuffle(arr) { - return arr - .map((v) => [Math.random(), v]) - .sort((a, b) => a[0] - b[0]) - .map((v) => v[1]); - } + onMount(async () => { + applyTheme($themeStore); - function selectAnswer(option) { - selectedAnswer = option; - showResult = true; - gameState = "answered"; - if (option === correctAnswer) { - sessionStats.correct++; - } else { - sessionStats.wrong++; + if (typeof window !== "undefined") { + window.appData = { + ...window.appData, + collection: "geography", + setCollection: () => {}, + theme: $themeStore, + setTheme: setTheme, + }; + + // Load saved game stats + const savedStats = localStorage.getItem("geographyQuizStats"); + if (savedStats) { + try { + const loadedStats = JSON.parse(savedStats); + gameStats = { + correct: loadedStats.correct || 0, + wrong: loadedStats.wrong || 0, + total: loadedStats.total || 0, + skipped: loadedStats.skipped || 0, + }; + } catch (e) { + console.error("Error loading geography game stats:", e); + } + } + + // Load wrong/correct answer maps + const savedWrong = localStorage.getItem("geographyQuizWrongAnswers"); + if (savedWrong) { + try { + wrongAnswers = new Map(Object.entries(JSON.parse(savedWrong))); + } catch (e) { + console.error("Error loading wrong answers:", e); + } + } + const savedCorrect = localStorage.getItem("geographyQuizCorrectAnswers"); + if (savedCorrect) { + try { + correctAnswers = new Map(Object.entries(JSON.parse(savedCorrect))); + } catch (e) { + console.error("Error loading correct answers:", e); + } + } + + // Load settings + const loadedSettings = loadSettings("geographyQuizSettings", { + autoAdvance, + focusWrongAnswers, + reduceCorrectAnswers, + soundEnabled, + sessionLength, + }); + if (loadedSettings) { + autoAdvance = loadedSettings.autoAdvance; + focusWrongAnswers = loadedSettings.focusWrongAnswers; + reduceCorrectAnswers = loadedSettings.reduceCorrectAnswers; + soundEnabled = loadedSettings.soundEnabled; + sessionLength = loadedSettings.sessionLength; + } + + loadGlobalStats("globalQuizStats"); } - sessionStats.total++; + + flags = await loadFlagsShared(); + // Fetch and parse the world SVG to build lookup sets for ids / names / class slugs + try { + const res = await fetch('/data/world.svg'); + if (res && res.ok) { + mapSvgText = await res.text(); + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(mapSvgText, 'image/svg+xml'); + const all = Array.from(doc.querySelectorAll('*')); + all.forEach((el) => { + try { + const id = el.getAttribute && el.getAttribute('id'); + if (id) availableIso.add(String(id).trim()); + const nameAttr = el.getAttribute && (el.getAttribute('name') || el.getAttribute('data-name') || el.getAttribute('inkscape:label')); + if (nameAttr) availableNames.add(String(nameAttr).trim().toLowerCase()); + // title child + try { + const title = el.querySelector && el.querySelector('title'); + if (title && title.textContent) availableNames.add(String(title.textContent).trim().toLowerCase()); + } catch (e) {} + // classes -> slugs + const cls = el.getAttribute && el.getAttribute('class'); + if (cls) { + cls.split(/\s+/).forEach((c) => { + const cc = String(c).trim(); + if (!cc) return; + availableSlugs.add(cc.toLowerCase()); + }); + } + } catch (err) {} + }); + mapParsed = true; + } catch (err) { + console.warn('Error parsing world.svg', err); + } + } + } catch (err) { + console.warn('Could not fetch world.svg', err); + } + settingsLoaded = true; + + // Restore session if any + const restored = restoreSessionState("geographyQuizSessionState"); + if (restored && restored.sessionInProgress) { + sessionInProgress = restored.sessionInProgress; + currentSessionQuestions = restored.currentSessionQuestions; + sessionStats = restored.sessionStats; + score = restored.score; + currentQuestion = restored.currentQuestion; + selectedAnswer = restored.selectedAnswer; + showResult = restored.showResult; + gameState = restored.gameState; + quizSubpage = restored.quizSubpage; + sessionStartTime = restored.sessionStartTime; + questionKey = restored.questionKey || 0; + sessionRestoredFromReload = restored.sessionRestoredFromReload; + + if (!currentQuestion) generateQuestion(); + } else { + quizSubpage = "welcome"; + gameState = "welcome"; + } + }); + + onDestroy(() => { + if (advanceTimer) advanceTimer.cancel(); + }); + + async function loadFlags() { + flags = await loadFlagsShared(); + console.log(`Loaded ${flags.length} countries for geography quiz`); + } + + function startAutoAdvanceTimer(duration) { + timerDuration = duration; + if (!advanceTimer) { + advanceTimer = createAdvanceTimer( + (p) => (timerProgress = p), + () => generateQuestion(), + ); + } + advanceTimer.start(duration); + } + + function cancelAutoAdvanceTimer() { + if (advanceTimer) advanceTimer.cancel(); + timerProgress = 0; } function startNewSession() { - sessionInProgress = true; + const s = createNewSessionState(sessionLength); + score = s.score; + currentSessionQuestions = s.currentSessionQuestions; + sessionStats = s.sessionStats; + sessionInProgress = s.sessionInProgress; + sessionStartTime = s.sessionStartTime; + showSessionResults = s.showSessionResults; + sessionRestoredFromReload = s.sessionRestoredFromReload; + quizSubpage = "quiz"; gameState = "loading"; - currentSessionQuestions = 0; - sessionStats = { - correct: 0, - wrong: 0, - total: 0, - sessionLength, - }; generateQuestion(); } function endSession() { sessionInProgress = false; + clearSessionState("geographyQuizSessionState"); + quizSubpage = "welcome"; gameState = "welcome"; showSessionResults = true; } - onMount(async () => { - await loadCountries(); - }); + function generateQuestion() { + if (flags.length < 4) return; + + gameState = "question"; + showResult = false; + selectedAnswer = null; + questionKey++; + + cancelAutoAdvanceTimer(); + + // build pool of flags that exist on the map when possible + let pool = flags; + try { + const onMap = flags.filter((f) => isFlagOnMap(f)); + if (mapParsed && onMap.length >= 4) pool = onMap; + } catch (err) {} + + // Pick correct country (adaptive) from pool + const pick = pickWeightedFlag(pool, { focusWrongAnswers, reduceCorrectAnswers }, wrongAnswers, correctAnswers); + const correctFlag = pick || pool[Math.floor(Math.random() * pool.length)]; + + const correctName = getCountryName(correctFlag); + + const wrongOptions = []; + const used = new Set([correctName.toLowerCase()]); + const wrongPool = pool; + while (wrongOptions.length < 3 && wrongOptions.length < wrongPool.length - 1) { + const r = wrongPool[Math.floor(Math.random() * wrongPool.length)]; + const rName = getCountryName(r).toLowerCase(); + if (!used.has(rName)) { + wrongOptions.push(r); + used.add(rName); + } + } + while (wrongOptions.length < 3) { + const r = flags[Math.floor(Math.random() * flags.length)]; + if (r !== correctFlag && !wrongOptions.includes(r)) wrongOptions.push(r); + } + + const all = [correctFlag, ...wrongOptions].sort(() => Math.random() - 0.5); + currentQuestion = { + type: "map-to-country", + correct: correctFlag, + options: all, + correctIndex: all.indexOf(correctFlag), + }; + + saveSessionState("geographyQuizSessionState", { + sessionInProgress, + currentSessionQuestions, + sessionStats, + score, + currentQuestion, + selectedAnswer, + showResult, + gameState, + quizSubpage, + sessionStartTime, + questionKey, + }); + } + + function selectAnswer(index) { + if (gameState !== "question") return; + selectedAnswer = index; + showResult = true; + gameState = "answered"; + + score.total++; + currentSessionQuestions++; + sessionStats.total++; + + const isCorrect = index === currentQuestion.correctIndex; + if (isCorrect) { + score.correct++; + gameStats.correct++; + sessionStats.correct++; + currentStreak++; + + playCorrectSound(soundEnabled); + + if (currentQuestion.correct?.name) { + const name = currentQuestion.correct.name; + correctAnswers.set(name, (correctAnswers.get(name) || 0) + 1); + localStorage.setItem( + "geographyQuizCorrectAnswers", + JSON.stringify(Object.fromEntries(correctAnswers)), + ); + } + + if (achievementsComponent && currentQuestion.correct?.tags) { + const continent = currentQuestion.correct.tags.find((tag) => + [ + "Europe", + "Asia", + "Africa", + "North America", + "South America", + "Oceania", + ].includes(tag), + ); + if (continent) + achievementsComponent.incrementContinentProgress(continent); + } + + if (achievementsComponent) achievementsComponent.resetConsecutiveSkips(); + } else { + gameStats.wrong++; + sessionStats.wrong++; + currentStreak = 0; + + playWrongSound(soundEnabled); + + if (currentQuestion.correct?.name) { + const name = currentQuestion.correct.name; + wrongAnswers.set(name, (wrongAnswers.get(name) || 0) + 1); + localStorage.setItem( + "geographyQuizWrongAnswers", + JSON.stringify(Object.fromEntries(wrongAnswers)), + ); + } + + if (achievementsComponent) achievementsComponent.resetConsecutiveSkips(); + } + + gameStats.total++; + localStorage.setItem("geographyQuizStats", JSON.stringify(gameStats)); + + updateGlobalStats("globalQuizStats", "geographyQuiz", isCorrect); + + if (achievementsComponent) achievementsComponent.checkAchievements(); + + saveSessionState("geographyQuizSessionState", { + sessionInProgress, + currentSessionQuestions, + sessionStats, + score, + currentQuestion, + selectedAnswer, + showResult, + gameState, + quizSubpage, + sessionStartTime, + questionKey, + }); + + if (currentSessionQuestions >= sessionLength) { + gameState = "session-complete"; + sessionStats.sessionLength = sessionLength; + if (autoAdvance) setTimeout(() => endSession(), isCorrect ? 2000 : 4000); + else endSession(); + return; + } + + if (autoAdvance) startAutoAdvanceTimer(isCorrect ? 2000 : 4000); + } + + function skipQuestion() { + if (gameState !== "question") return; + score.skipped++; + gameStats.skipped++; + gameStats.total++; + currentSessionQuestions++; + sessionStats.skipped++; + sessionStats.total++; + + if (achievementsComponent) + achievementsComponent.incrementConsecutiveSkips(); + if (achievementsComponent) achievementsComponent.checkAchievements(); + + localStorage.setItem("geographyQuizStats", JSON.stringify(gameStats)); + updateGlobalStats("globalQuizStats", "geographyQuiz", null, true); + + saveSessionState("geographyQuizSessionState", { + sessionInProgress, + currentSessionQuestions, + sessionStats, + score, + currentQuestion, + selectedAnswer, + showResult, + gameState, + quizSubpage, + sessionStartTime, + questionKey, + }); + + if (currentSessionQuestions >= sessionLength) { + gameState = "session-complete"; + sessionStats.sessionLength = sessionLength; + endSession(); + return; + } + generateQuestion(); + } + + function handleSettingsChange(event) { + const { + autoAdvance: a, + focusWrongAnswers: f, + reduceCorrectAnswers: r, + soundEnabled: s, + sessionLength: l, + } = event.detail; + autoAdvance = a; + focusWrongAnswers = f; + reduceCorrectAnswers = r; + soundEnabled = s; + sessionLength = l; + sessionStats.sessionLength = l; + } + + function handleSettingsToggle(event) { + showSettings = event.detail; + } + + function handleResetConfirmation(event) { + showResetConfirmation = event.detail; + } + + function nextQuestion() { + sessionRestoredFromReload = false; + generateQuestion(); + } + + function handleActionButtonClick(event) { + const { action } = event.detail; + switch (action) { + case "startQuiz": + startNewSession(); + break; + case "playAgain": + startNewSession(); + break; + case "goToGames": + window.location.hash = "#/game"; + break; + case "openSettings": + showSettings = true; + break; + case "endSession": + endSession(); + break; + default: + console.warn("Unknown action:", action); + } + } + + function handleAchievementsUnlocked() { + achievementCount = updateAchievementCount(achievementsComponent); + } + + // Reactive country code array for CountryMap; recomputes when currentQuestion changes + $: countryCodes = currentQuestion + ? [ + currentQuestion.correct?.meta?.["ISO code"] || + currentQuestion.correct?.meta?.["ISO Code"] || + currentQuestion.correct?.meta?.["ISO"] || + (currentQuestion.correct?.meta && + currentQuestion.correct.meta["ISO code"]) || + currentQuestion.correct?.ISO || + "", + ].filter(Boolean) + : []; + + // Reactive country names array for CountryMap (fallback when no ISO present) + $: countryNames = currentQuestion + ? [ + currentQuestion.correct?.name || + (currentQuestion.correct?.meta && currentQuestion.correct.meta.country) || + currentQuestion.correct?.meta?.country || + "", + ].filter(Boolean) + : []; </script> -<Header /> -<main class="map-quiz"> - {#if quizSubpage === "welcome"} - <div class="container"> - <QuizInfo - gameStats={sessionStats} +<svelte:head> + <title>Geography Quiz + + +
(showAchievements = true)} +/> + +
+
+ + + (showAchievements = false)} + on:achievementsUnlocked={handleAchievementsUnlocked} + /> + + {#if quizSubpage === "welcome"} + {}} - on:closeResults={() => {}} + on:openSettings={() => (showSettings = true)} + on:closeResults={() => (showSessionResults = false)} /> + 0} - on:action={startNewSession} + sessionInfo={showSessionResults + ? "" + : `${sessionLength} questions per quiz`} + hasPlayedBefore={gameStats.total > 0} + on:action={handleActionButtonClick} /> -
- {:else if quizSubpage === "quiz"} -
-

Question {currentSessionQuestions} of {sessionLength}

- {#if correctAnswer} - -
- {#each options as option} - - {/each} -
- {#if showResult} -
- {selectedAnswer === correctAnswer - ? 'Correct!' - : `Wrong! The correct answer is ${correctAnswer.meta.country}`} + {:else if quizSubpage === "quiz"} + {#if gameState === "loading"} +
Loading countries...
+ {:else if currentQuestion} +
+
+
+ Question {currentSessionQuestions + 1} from {sessionLength} +
+
+ Which country is highlighted on the map? +
- {#if currentSessionQuestions < sessionLength} - - {:else} - + +
+ +
+ +
+ {#each currentQuestion.options as option, index} + + {/each} +
+ + {#if gameState === "question"} + + {:else if (!autoAdvance && gameState === "answered") || (autoAdvance && gameState === "answered" && sessionRestoredFromReload)} + {/if} - {/if} - {:else} -
Loading...
+ + {#if autoAdvance && gameState === "answered" && timerProgress > 0 && !sessionRestoredFromReload} +
+
+
+
+ Next question in {Math.ceil( + (timerDuration - (timerProgress / 100) * timerDuration) / + 1000, + )}s +
+ {/if} +
{/if} -
- {/if} + + + {/if} +