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

View File

@@ -485,25 +485,25 @@
</path>
<path class="France" d="M 1014.4 185 1015.5 185.5 1016.9 185.4 1019.3 187 1026.5 188.2 1024.1 192.4 1023.7 196.9 1022.4 198 1020.1 197.4 1020.3 199 1016.7 202.5 1016.7 205.4 1019.1 204.4 1020.9 207.1 1020.8 208.9 1022.3 211.3 1020.6 213.2 1022.1 218.1 1024.9 218.9 1024.4 221.6 1019.9 225.2 1009.7 223.5 1002.3 225.6 1001.7 229.4 995.8 230.2 990 227.3 988.1 228.7 978.6 225.8 976.6 223.4 979.3 219.6 980.3 207 975.2 200.4 971.5 197.2 963.9 194.8 963.5 190.2 970 188.9 978.3 190.5 976.8 183.4 981.5 186.1 992.9 181.3 994.4 176.2 998.6 174.9 999.4 177.1 1001.6 177.2 1004 179.7 1007.5 182.6 1010 182.1 1014.4 185 Z">
</path>
<path class="United States" d="M 118.8 379.3 117.7 380.4 116.5 379.5 117.1 377.7 116.7 375.3 117.2 374.6 118.4 373.6 118.3 372.3 118.7 371.7 119.1 371.8 121 372.9 121.9 373.5 122.6 374.3 123.5 376.6 123.3 376.9 120.8 378.3 118.8 379.3 Z">
<path class="United States of America" d="M 118.8 379.3 117.7 380.4 116.5 379.5 117.1 377.7 116.7 375.3 117.2 374.6 118.4 373.6 118.3 372.3 118.7 371.7 119.1 371.8 121 372.9 121.9 373.5 122.6 374.3 123.5 376.6 123.3 376.9 120.8 378.3 118.8 379.3 Z">
</path>
<path class="United States" d="M 118.1 369.3 116.1 369.7 115.5 368.4 115 367.9 115 367.5 115.7 366.9 117.5 367.5 118.7 368.5 118.1 369.3 Z">
<path class="United States of America" d="M 118.1 369.3 116.1 369.7 115.5 368.4 115 367.9 115 367.5 115.7 366.9 117.5 367.5 118.7 368.5 118.1 369.3 Z">
</path>
<path class="United States" d="M 115.1 365.9 114.8 366.6 111.8 366.4 112.4 365.6 115.1 365.9 Z">
<path class="United States of America" d="M 115.1 365.9 114.8 366.6 111.8 366.4 112.4 365.6 115.1 365.9 Z">
</path>
<path class="United States" d="M 110.4 364.9 110 365.3 109.6 365.2 107.7 365 107.4 363.5 107.2 363.3 108.9 362.4 109.3 362.8 110.4 364.9 Z">
<path class="United States of America" d="M 110.4 364.9 110 365.3 109.6 365.2 107.7 365 107.4 363.5 107.2 363.3 108.9 362.4 109.3 362.8 110.4 364.9 Z">
</path>
<path class="United States" d="M 102 360.7 101.2 361.3 99.6 360.2 100 359.7 101 359.1 102.3 359.2 102 360.7 Z">
<path class="United States of America" d="M 102 360.7 101.2 361.3 99.6 360.2 100 359.7 101 359.1 102.3 359.2 102 360.7 Z">
</path>
<path class="United States" d="M 539.5 194.5 533.4 196.5 528.7 199 524.1 201.7 523.6 202.6 529.3 201.3 531.4 203.4 536 201.9 540.9 199.8 546.3 197.7 543.2 201 545.7 201.8 548.2 204.2 553.3 202.8 558.4 202.3 558.7 204.1 560.2 204.3 561.4 204.5 562.9 207 558.2 207.6 558.1 207.6 554.4 206.9 549.9 208.1 546.2 208.7 541.5 212.8 538.5 215.1 538.9 215.8 544.4 211.7 545.1 211.7 540.4 216.6 537.5 221 535 224.6 534.4 227.7 533.6 229.2 533 230.9 533.1 234.2 533.4 234.7 535.2 234.6 536.8 233.9 538.2 233.1 541.5 230 543.3 225.8 543.2 221.9 544.6 219.2 547.2 216.1 549.3 213.9 552 212.4 551.6 214.5 553.8 211.4 555.1 210.8 556.8 208.4 560.6 209.7 563.4 212.1 562.6 215 561 217.9 557.2 220.4 556.8 222 557.8 222 562.1 219.3 563.7 219.9 563.2 223.6 562.5 226.2 558.8 229.7 556.8 231.9 554.1 234.3 556.8 235.6 559.3 236 563.3 235.1 567 233.4 570 232.5 574.6 230.7 580.4 226.9 580.5 226.3 580.8 224.4 583.5 223.6 587.4 223.9 591.4 224.4 596 222.3 596.6 219.8 596.4 218.9 603.2 214.5 605.9 213.4 613.7 213.3 623 213.3 624.1 211.8 625.8 211.5 628.3 210.5 631.1 207.6 634.3 202.7 639.8 198 640.9 199.6 644.6 198.6 646.2 200.4 643.3 208.9 645.5 212.5 645.7 214.6 639.3 217.6 633.3 219.8 627.3 221.7 623.3 225.5 622 226.9 620.8 230.3 621.5 233.6 623.6 233.8 623.8 231.5 624.9 232.9 623.9 234.7 620.1 235.7 617.6 235.6 613.4 236.7 611.1 237 608 237.3 603 239.2 611.1 238 612.2 239.2 604.3 241.1 601 241.1 601.4 240.3 599.3 242.1 600.7 242.4 598.2 247 592.9 251.9 593 250.2 591.9 249.9 590.7 248.3 590.7 251.8 591.7 252.9 591.1 255.3 588.7 257.8 584.2 262.9 583.8 262.7 586.7 258.3 584.7 255.9 585.7 250.5 583.8 253.3 583.8 257.4 580.6 256.4 583.6 258.4 582.1 264.5 583.5 265 583.5 267.2 582.5 273.6 577.9 278.3 571.8 280.2 567.4 284 564.6 284.4 561.2 286.8 559.9 288.9 553 293.1 549.2 296.2 545.7 300 543.8 304.5 543.8 309 544.4 314.5 545.9 319 545.4 321.8 546.7 329.2 545.7 333.6 545.1 336.1 543.1 340 541.3 340.8 538.7 340 538.3 337.2 536.5 335.7 534.5 330.2 532.9 325.3 532.5 322.8 534.5 318.5 533.7 315 530.6 309.6 528.7 308.6 522.6 311.6 521.7 311.2 519.7 308.2 516.7 306.6 510.3 307.5 505.7 306.7 501.4 307.2 498.9 308.2 499.5 309.9 498.8 312.5 499.6 313.8 498.4 314.6 496.6 313.7 494.3 314.9 490.4 314.7 487.1 311.3 482.2 312.1 478.6 310.6 475.1 311.1 470.1 312.6 464 317.3 457.9 320.1 454.2 323.1 452.3 326 451.3 330.5 450.9 333.5 451.5 335.7 449.3 335.9 445.7 334.5 441.8 332.5 440.9 329.5 440.7 325 438.3 321.4 437.4 317.6 435.8 313.2 432.6 310.6 428.1 310.8 423.3 315.8 419.3 313.9 417 312 416.6 308.4 415.8 305.1 413.4 302.3 411.3 300.2 410 297.9 400.6 297.9 399.8 300.6 395.5 300.6 384.7 300.6 373.8 296.1 366.8 293 367.7 291.7 360.6 292.4 354.3 292.9 354.6 289.7 352.5 286 350.3 285.2 350.4 283.4 347.5 283 346.3 281.3 341.5 280.7 340.6 279.6 341.4 276.1 338.9 269.7 338.4 260.8 339.3 259.3 338 257.2 336.5 251.8 338.3 246.6 337.4 243.1 341.3 237.8 344.1 232.4 345.2 227.5 350.7 221.5 354.7 215.8 358.7 210.1 363 201.6 364.8 196.3 365.2 193.4 366.6 192.1 372.4 194.3 371.4 200.2 373.6 198.5 376.1 193.4 377.7 188.3 391.8 188.3 406.5 188.3 411.3 188.3 426.4 188.3 441.1 188.3 455.9 188.3 470.8 188.3 487.6 188.3 504.6 188.3 514.8 188.3 516.1 185.9 517.8 185.9 516.9 189.3 517.9 190.3 521.2 190.7 525.8 191.7 529.7 193.6 534.1 192.8 539.5 194.5 Z">
<path class="United States of America" d="M 539.5 194.5 533.4 196.5 528.7 199 524.1 201.7 523.6 202.6 529.3 201.3 531.4 203.4 536 201.9 540.9 199.8 546.3 197.7 543.2 201 545.7 201.8 548.2 204.2 553.3 202.8 558.4 202.3 558.7 204.1 560.2 204.3 561.4 204.5 562.9 207 558.2 207.6 558.1 207.6 554.4 206.9 549.9 208.1 546.2 208.7 541.5 212.8 538.5 215.1 538.9 215.8 544.4 211.7 545.1 211.7 540.4 216.6 537.5 221 535 224.6 534.4 227.7 533.6 229.2 533 230.9 533.1 234.2 533.4 234.7 535.2 234.6 536.8 233.9 538.2 233.1 541.5 230 543.3 225.8 543.2 221.9 544.6 219.2 547.2 216.1 549.3 213.9 552 212.4 551.6 214.5 553.8 211.4 555.1 210.8 556.8 208.4 560.6 209.7 563.4 212.1 562.6 215 561 217.9 557.2 220.4 556.8 222 557.8 222 562.1 219.3 563.7 219.9 563.2 223.6 562.5 226.2 558.8 229.7 556.8 231.9 554.1 234.3 556.8 235.6 559.3 236 563.3 235.1 567 233.4 570 232.5 574.6 230.7 580.4 226.9 580.5 226.3 580.8 224.4 583.5 223.6 587.4 223.9 591.4 224.4 596 222.3 596.6 219.8 596.4 218.9 603.2 214.5 605.9 213.4 613.7 213.3 623 213.3 624.1 211.8 625.8 211.5 628.3 210.5 631.1 207.6 634.3 202.7 639.8 198 640.9 199.6 644.6 198.6 646.2 200.4 643.3 208.9 645.5 212.5 645.7 214.6 639.3 217.6 633.3 219.8 627.3 221.7 623.3 225.5 622 226.9 620.8 230.3 621.5 233.6 623.6 233.8 623.8 231.5 624.9 232.9 623.9 234.7 620.1 235.7 617.6 235.6 613.4 236.7 611.1 237 608 237.3 603 239.2 611.1 238 612.2 239.2 604.3 241.1 601 241.1 601.4 240.3 599.3 242.1 600.7 242.4 598.2 247 592.9 251.9 593 250.2 591.9 249.9 590.7 248.3 590.7 251.8 591.7 252.9 591.1 255.3 588.7 257.8 584.2 262.9 583.8 262.7 586.7 258.3 584.7 255.9 585.7 250.5 583.8 253.3 583.8 257.4 580.6 256.4 583.6 258.4 582.1 264.5 583.5 265 583.5 267.2 582.5 273.6 577.9 278.3 571.8 280.2 567.4 284 564.6 284.4 561.2 286.8 559.9 288.9 553 293.1 549.2 296.2 545.7 300 543.8 304.5 543.8 309 544.4 314.5 545.9 319 545.4 321.8 546.7 329.2 545.7 333.6 545.1 336.1 543.1 340 541.3 340.8 538.7 340 538.3 337.2 536.5 335.7 534.5 330.2 532.9 325.3 532.5 322.8 534.5 318.5 533.7 315 530.6 309.6 528.7 308.6 522.6 311.6 521.7 311.2 519.7 308.2 516.7 306.6 510.3 307.5 505.7 306.7 501.4 307.2 498.9 308.2 499.5 309.9 498.8 312.5 499.6 313.8 498.4 314.6 496.6 313.7 494.3 314.9 490.4 314.7 487.1 311.3 482.2 312.1 478.6 310.6 475.1 311.1 470.1 312.6 464 317.3 457.9 320.1 454.2 323.1 452.3 326 451.3 330.5 450.9 333.5 451.5 335.7 449.3 335.9 445.7 334.5 441.8 332.5 440.9 329.5 440.7 325 438.3 321.4 437.4 317.6 435.8 313.2 432.6 310.6 428.1 310.8 423.3 315.8 419.3 313.9 417 312 416.6 308.4 415.8 305.1 413.4 302.3 411.3 300.2 410 297.9 400.6 297.9 399.8 300.6 395.5 300.6 384.7 300.6 373.8 296.1 366.8 293 367.7 291.7 360.6 292.4 354.3 292.9 354.6 289.7 352.5 286 350.3 285.2 350.4 283.4 347.5 283 346.3 281.3 341.5 280.7 340.6 279.6 341.4 276.1 338.9 269.7 338.4 260.8 339.3 259.3 338 257.2 336.5 251.8 338.3 246.6 337.4 243.1 341.3 237.8 344.1 232.4 345.2 227.5 350.7 221.5 354.7 215.8 358.7 210.1 363 201.6 364.8 196.3 365.2 193.4 366.6 192.1 372.4 194.3 371.4 200.2 373.6 198.5 376.1 193.4 377.7 188.3 391.8 188.3 406.5 188.3 411.3 188.3 426.4 188.3 441.1 188.3 455.9 188.3 470.8 188.3 487.6 188.3 504.6 188.3 514.8 188.3 516.1 185.9 517.8 185.9 516.9 189.3 517.9 190.3 521.2 190.7 525.8 191.7 529.7 193.6 534.1 192.8 539.5 194.5 Z">
</path>
<path class="United States" d="M 275 138.6 268 140.9 267.2 139.3 269.5 136.5 275.9 134.4 279.4 133.5 282 133.9 282 135.8 275 138.6 Z">
<path class="United States of America" d="M 275 138.6 268 140.9 267.2 139.3 269.5 136.5 275.9 134.4 279.4 133.5 282 133.9 282 135.8 275 138.6 Z">
</path>
<path class="United States" d="M 236 122 232.1 122.9 230.4 121.8 229.6 120.2 235.3 119.2 238.3 119.8 236 122 Z">
<path class="United States of America" d="M 236 122 232.1 122.9 230.4 121.8 229.6 120.2 235.3 119.2 238.3 119.8 236 122 Z">
</path>
<path class="United States" d="M 237.2 99.6 238.4 100.6 241.9 100.1 243.5 101.6 246.8 102.3 245.6 103 240.7 104.2 239 102.9 238.7 101.9 234.4 102.2 234.1 101.7 237.2 99.6 Z">
<path class="United States of America" d="M 237.2 99.6 238.4 100.6 241.9 100.1 243.5 101.6 246.8 102.3 245.6 103 240.7 104.2 239 102.9 238.7 101.9 234.4 102.2 234.1 101.7 237.2 99.6 Z">
</path>
<path class="United States" d="M 410 66.6 385.4 87 349.8 119.7 354 119.9 356.8 121.5 357.3 124.1 357.6 127.9 365.2 124.6 371.7 122.7 371.1 125.8 371.9 128.2 373.5 130.9 372.4 135.1 371 142 375.6 145.8 372.4 149.6 367.3 152.5 366.7 150.3 364.2 148.3 367.5 143.1 365.9 138.2 368.6 132.6 364.5 132.2 357.4 132.1 353.6 130.3 350.3 124.2 347 123.1 341.3 121 334.5 121.5 328.5 118.8 325.8 116.3 319.5 117.5 316 121.6 313.1 122 306.5 123.2 300.3 125.2 293.9 126.5 297.1 123 305.5 117.2 312.3 115.4 312.7 114 303.3 117.2 295.9 121.1 284.7 125.3 284.9 128.2 275.9 132.4 268.2 134.9 261.6 136.8 257.6 139.4 247 142.5 242.5 145.3 234.3 147.9 231.6 147.5 225.4 149.1 218.4 151.2 212.3 153.2 202.3 155 202.7 153.9 210.9 151.1 217.5 149.2 226.1 145.9 232.6 145.3 237.6 142.8 248 139.2 250.3 138 256 135.9 261.8 131.4 268 127.9 260.7 129.7 260.4 128.6 255.5 130.8 255.9 127.8 252.3 129.9 253.9 127 246.6 129.3 243.8 129.3 247.5 125.8 250.8 123.6 250.4 121.5 243.2 122.7 242.6 119.9 241.3 118.5 245.3 115.2 244.9 112.7 250.8 109.4 258.5 106.1 263.8 103.2 267.9 102.8 269.7 103.7 276.8 100.9 279.3 101.4 284.9 99.6 287.4 97 286.3 96 292.3 93.8 289.5 93.9 283.3 95.1 280.4 96.4 278.6 95.1 271.7 95.8 267.1 94.4 268.3 92.1 267.3 88.9 276.5 86.5 289.7 83.8 293.2 83.8 288.9 86.6 298.1 86.4 299.3 82.9 297 80.8 297.8 78 297.1 75.7 293.8 74 300.3 71.1 307.8 70.9 316.6 68.5 321.4 65.9 329.3 63.3 334.1 62.7 345.3 60.3 348.4 60.7 358.8 57.9 363.2 59 362.7 61.4 366 60.4 372.3 60.7 370.4 61.9 375.3 62.8 380.2 62.3 386.4 63.9 393.6 64.5 395.8 65.1 402.4 64.3 406.5 65.9 410 66.6 Z">
<path class="United States of America" d="M 410 66.6 385.4 87 349.8 119.7 354 119.9 356.8 121.5 357.3 124.1 357.6 127.9 365.2 124.6 371.7 122.7 371.1 125.8 371.9 128.2 373.5 130.9 372.4 135.1 371 142 375.6 145.8 372.4 149.6 367.3 152.5 366.7 150.3 364.2 148.3 367.5 143.1 365.9 138.2 368.6 132.6 364.5 132.2 357.4 132.1 353.6 130.3 350.3 124.2 347 123.1 341.3 121 334.5 121.5 328.5 118.8 325.8 116.3 319.5 117.5 316 121.6 313.1 122 306.5 123.2 300.3 125.2 293.9 126.5 297.1 123 305.5 117.2 312.3 115.4 312.7 114 303.3 117.2 295.9 121.1 284.7 125.3 284.9 128.2 275.9 132.4 268.2 134.9 261.6 136.8 257.6 139.4 247 142.5 242.5 145.3 234.3 147.9 231.6 147.5 225.4 149.1 218.4 151.2 212.3 153.2 202.3 155 202.7 153.9 210.9 151.1 217.5 149.2 226.1 145.9 232.6 145.3 237.6 142.8 248 139.2 250.3 138 256 135.9 261.8 131.4 268 127.9 260.7 129.7 260.4 128.6 255.5 130.8 255.9 127.8 252.3 129.9 253.9 127 246.6 129.3 243.8 129.3 247.5 125.8 250.8 123.6 250.4 121.5 243.2 122.7 242.6 119.9 241.3 118.5 245.3 115.2 244.9 112.7 250.8 109.4 258.5 106.1 263.8 103.2 267.9 102.8 269.7 103.7 276.8 100.9 279.3 101.4 284.9 99.6 287.4 97 286.3 96 292.3 93.8 289.5 93.9 283.3 95.1 280.4 96.4 278.6 95.1 271.7 95.8 267.1 94.4 268.3 92.1 267.3 88.9 276.5 86.5 289.7 83.8 293.2 83.8 288.9 86.6 298.1 86.4 299.3 82.9 297 80.8 297.8 78 297.1 75.7 293.8 74 300.3 71.1 307.8 70.9 316.6 68.5 321.4 65.9 329.3 63.3 334.1 62.7 345.3 60.3 348.4 60.7 358.8 57.9 363.2 59 362.7 61.4 366 60.4 372.3 60.7 370.4 61.9 375.3 62.8 380.2 62.3 386.4 63.9 393.6 64.5 395.8 65.1 402.4 64.3 406.5 65.9 410 66.6 Z">
</path>
<path d="M677.3 487l1.5-2.8 0.5-2.9 1-2.7-2.1-3.8-0.3-4.4 3.1-5.5 1.9 0.7 4.1 1.5 5.9 5.4 0.8 2.6-3.4 5.9-1.8 4.7-2.2 2.5-2.7 0.4-0.8-1.8-1.3-0.2-1.7 1.7-2.5-1.3z" id="GF" name="French Guiana">
</path>

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.9 MiB

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 {

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
$: {

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;

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>