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.
This commit is contained in:
sha
2025-08-15 15:03:40 +03:00
parent d61366b91d
commit c1bfa83963
6 changed files with 1154 additions and 206 deletions
+283 -50
View File
@@ -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, <title> 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 {
+6 -1
View File
@@ -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
$: {
+54 -39
View File
@@ -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;
+801 -104
View File
@@ -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</title>
</svelte:head>
<Header
theme={$themeStore}
{setTheme}
{gameStats}
{achievementCount}
{sessionStats}
isQuizActive={sessionInProgress && quizSubpage === "quiz"}
onAchievementClick={() => (showAchievements = true)}
/>
<main class="geography-quiz">
<div class="container">
<QuizSettings
bind:autoAdvance
bind:focusWrongAnswers
bind:reduceCorrectAnswers
bind:soundEnabled
bind:sessionLength
bind:showSettings
bind:showResetConfirmation
focusWrongLabel="Focus on previously answered incorrectly countries"
reduceCorrectLabel="Show correctly answered countries less frequently"
on:settingsChange={handleSettingsChange}
on:settingsToggle={handleSettingsToggle}
on:resetConfirmation={handleResetConfirmation}
/>
<Achievements
bind:this={achievementsComponent}
{gameStats}
{currentStreak}
show={showAchievements}
on:close={() => (showAchievements = false)}
on:achievementsUnlocked={handleAchievementsUnlocked}
/>
{#if quizSubpage === "welcome"}
<QuizInfo
{gameStats}
{sessionStats}
{sessionLength}
{showSessionResults}
quizInfo={quizInfo}
{quizInfo}
on:startQuiz={startNewSession}
on:openSettings={() => {}}
on:closeResults={() => {}}
on:openSettings={() => (showSettings = true)}
on:closeResults={() => (showSessionResults = false)}
/>
<ActionButtons
mode={showSessionResults ? "results" : "welcome"}
sessionInfo={showSessionResults ? "" : `${sessionLength} questions per quiz`}
hasPlayedBefore={sessionStats.total > 0}
on:action={startNewSession}
sessionInfo={showSessionResults
? ""
: `${sessionLength} questions per quiz`}
hasPlayedBefore={gameStats.total > 0}
on:action={handleActionButtonClick}
/>
</div>
{:else if quizSubpage === "quiz"}
<div class="container">
<h2>Question {currentSessionQuestions} of {sessionLength}</h2>
{#if correctAnswer}
<CountryMap
countryCodes={[correctAnswer.code]}
countryNames={[]}
mapPath="/data/world.svg"
countryScale={true}
scalePadding={60}
/>
<div class="options">
{#each options as option}
<button
class="option-btn {selectedAnswer === option ? (option === correctAnswer ? 'correct' : 'wrong') : ''}"
on:click={() => selectAnswer(option)}
disabled={showResult}
>
{option.meta.country}
</button>
{/each}
</div>
{#if showResult}
<div class="result">
{selectedAnswer === correctAnswer
? 'Correct!'
: `Wrong! The correct answer is ${correctAnswer.meta.country}`}
{:else if quizSubpage === "quiz"}
{#if gameState === "loading"}
<div class="loading">Loading countries...</div>
{:else if currentQuestion}
<div class="question-container">
<div class="question-header">
<div class="question-number">
Question {currentSessionQuestions + 1} from {sessionLength}
</div>
<div class="question-type">
Which country is highlighted on the map?
</div>
</div>
{#if currentSessionQuestions < sessionLength}
<button class="next-btn" on:click={generateQuestion}>Next</button>
{:else}
<button class="next-btn" on:click={endSession}>Finish</button>
<div class="map-display">
<CountryMap
{countryCodes}
{countryNames}
countryScale={true}
scalePadding={90}
forceCenterKey={questionKey}
key={questionKey}
/>
</div>
<div class="options" key={questionKey}>
{#each currentQuestion.options as option, index}
<button
class="option"
class:selected={selectedAnswer === index}
class:correct={showResult &&
index === currentQuestion.correctIndex}
class:wrong={showResult &&
selectedAnswer === index &&
index !== currentQuestion.correctIndex}
on:click={() => selectAnswer(index)}
disabled={gameState === "answered"}
>
{getCountryName(option)}
</button>
{/each}
</div>
{#if gameState === "question"}
<button class="btn btn-skip btn-next-full" on:click={skipQuestion}
>Skip Question</button
>
{:else if (!autoAdvance && gameState === "answered") || (autoAdvance && gameState === "answered" && sessionRestoredFromReload)}
<button
class="btn btn-primary btn-next-full"
on:click={nextQuestion}>Next Question →</button
>
{/if}
{/if}
{:else}
<div>Loading...</div>
{#if autoAdvance && gameState === "answered" && timerProgress > 0 && !sessionRestoredFromReload}
<div class="auto-advance-timer">
<div class="timer-bar">
<div
class="timer-progress"
style="width: {timerProgress}%"
></div>
</div>
<span class="timer-text"
>Next question in {Math.ceil(
(timerDuration - (timerProgress / 100) * timerDuration) /
1000,
)}s</span
>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<ActionButtons
mode="quiz"
sessionInfo={`Question ${currentSessionQuestions + 1} from ${sessionLength}`}
on:action={handleActionButtonClick}
/>
{/if}
</div>
</main>
<Footer />
<style>
/* Use the same container and layout as FlagQuiz/CapitalsQuiz for welcome page */
.geography-quiz {
min-height: 100vh;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.container {
max-width: 800px;
max-width: 900px;
margin: 0 auto;
padding: 1.25rem 1rem;
}
.loading {
text-align: center;
font-size: 1.5rem;
color: var(--color-text-secondary);
margin: 3rem 0;
}
.question-container {
background: var(--color-bg-secondary);
border-radius: 16px;
padding: 2rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
}
.question-header {
text-align: center;
margin-bottom: 1rem;
}
.question-number {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.question-type {
font-size: 1.3rem;
font-weight: 600;
color: var(--color-text-primary);
}
.map-display {
display: flex;
justify-content: center;
align-items: stretch;
margin-bottom: 1rem;
width: 100%;
aspect-ratio: 16 / 9;
max-height: 520px;
overflow: hidden;
}
:global(.map-display .country-map-section) {
flex: 1 1 100%;
width: 100%;
height: 100%;
}
:global(.map-display .country-map-section > div) {
width: 100%;
height: 100%;
}
/* Force the inlined SVG to fit the .map-display box to prevent overflow */
:global(.map-display .svg-wrapper) { width: 100%; height: 100%; }
:global(.map-display .svg-wrapper svg) { width: 100%; height: 100%; display: block; }
.options {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr;
}
.option {
background: var(--color-bg-primary);
border: 2px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-primary);
}
.option.selected {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.option.correct {
border-color: #22c55e;
background: #22c55e;
color: white;
}
.option.wrong {
border-color: #ef4444;
background: #ef4444;
color: white;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-next-full {
display: block;
width: 100%;
margin-top: 1rem;
text-align: center;
}
.btn-skip {
background: var(--color-text-secondary);
color: var(--color-bg-primary);
opacity: 0.8;
}
.auto-advance-timer {
margin-top: 1rem;
text-align: center;
}
.timer-bar {
width: 100%;
height: 6px;
background: var(--color-bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
}
.timer-progress {
height: 100%;
background: var(--color-primary);
transition: width 0.05s linear;
}
.timer-text {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
@media (max-width: 768px) {
.options {
grid-template-columns: 1fr;
}
.map-display {
height: 260px;
}
}
</style>