mirror of
https://github.com/shadoll/sLogos.git
synced 2026-08-28 11:33:29 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
$: {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user