mirror of
https://github.com/shadoll/sLogos.git
synced 2025-12-20 02:26:05 +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:
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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