|
16 | 16 | const WORLD_GEOJSON_PATH = |
17 | 17 | "https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson"; |
18 | 18 |
|
| 19 | + const STOP_YEAR = 2016; |
| 20 | + |
19 | 21 | const selectors = { |
20 | | - root: "#never-medaled-viz" |
| 22 | + root: "#never-medaled-viz", |
| 23 | + yearSlider: "#never-year", |
| 24 | + currentYear: "#never-current-year", |
| 25 | + play: "#never-play" |
21 | 26 | }; |
22 | 27 |
|
23 | 28 | const state = { |
24 | 29 | rows: [], |
25 | 30 | columns: {}, |
26 | | - worldGeo: null |
| 31 | + worldGeo: null, |
| 32 | + currentSeason: "Summer", |
| 33 | + currentYear: null, |
| 34 | + availableYears: [], |
| 35 | + playing: false, |
| 36 | + timer: null |
27 | 37 | }; |
28 | 38 |
|
29 | 39 | const continentOrder = [ |
|
264 | 274 | state.columns = detectColumns(rows); |
265 | 275 |
|
266 | 276 | injectStyles(); |
267 | | - render(); |
| 277 | + setupControls(); |
| 278 | + setSeason("Summer"); |
268 | 279 | } catch (error) { |
269 | 280 | console.error(error); |
270 | 281 | showError("Could not load never-medaled data."); |
|
413 | 424 | document.head.appendChild(style); |
414 | 425 | } |
415 | 426 |
|
| 427 | + function setupControls() { |
| 428 | + d3.select(selectors.yearSlider).on("input", function () { |
| 429 | + stopPlaying(); |
| 430 | + |
| 431 | + const index = Number(this.value); |
| 432 | + const year = state.availableYears[index]; |
| 433 | + |
| 434 | + if (year !== undefined) { |
| 435 | + state.currentYear = year; |
| 436 | + update(); |
| 437 | + broadcastYear(year); |
| 438 | + } |
| 439 | + }); |
| 440 | + |
| 441 | + d3.select(selectors.play).on("click", togglePlay); |
| 442 | + |
| 443 | + window.addEventListener("olympic-year-change", event => { |
| 444 | + const year = Number(event.detail?.year); |
| 445 | + const season = event.detail?.season || state.currentSeason; |
| 446 | + |
| 447 | + if (!Number.isFinite(year)) return; |
| 448 | + |
| 449 | + state.currentSeason = season; |
| 450 | + state.availableYears = getAvailableYears(season); |
| 451 | + |
| 452 | + if (state.availableYears.includes(year)) { |
| 453 | + state.currentYear = year; |
| 454 | + } else { |
| 455 | + state.currentYear = state.availableYears.reduce((closest, y) => |
| 456 | + Math.abs(y - year) < Math.abs(closest - year) ? y : closest |
| 457 | + ); |
| 458 | + } |
| 459 | + |
| 460 | + updateSliderLimits(); |
| 461 | + update(); |
| 462 | + }); |
| 463 | + } |
| 464 | + |
| 465 | + function setSeason(season) { |
| 466 | + stopPlaying(); |
| 467 | + |
| 468 | + state.currentSeason = season; |
| 469 | + state.availableYears = getAvailableYears(season); |
| 470 | + |
| 471 | + state.currentYear = state.availableYears[0]; |
| 472 | + |
| 473 | + updateSliderLimits(); |
| 474 | + update(); |
| 475 | + } |
| 476 | + |
| 477 | + function getAvailableYears(season) { |
| 478 | + const { year: yearCol, season: seasonCol } = state.columns; |
| 479 | + |
| 480 | + return Array.from( |
| 481 | + new Set( |
| 482 | + state.rows |
| 483 | + .filter(row => String(row[seasonCol] || "").trim() === season) |
| 484 | + .map(row => Number(row[yearCol])) |
| 485 | + .filter(year => Number.isFinite(year) && year <= STOP_YEAR) |
| 486 | + ) |
| 487 | + ).sort((a, b) => a - b); |
| 488 | + } |
| 489 | + |
| 490 | + function updateSliderLimits() { |
| 491 | + d3.select(selectors.yearSlider) |
| 492 | + .attr("min", 0) |
| 493 | + .attr("max", Math.max(0, state.availableYears.length - 1)) |
| 494 | + .attr("step", 1) |
| 495 | + .property("value", state.availableYears.indexOf(state.currentYear)); |
| 496 | + |
| 497 | + d3.select(selectors.currentYear).text(state.currentYear); |
| 498 | + } |
| 499 | + |
| 500 | + function update() { |
| 501 | + if (!state.currentYear) return; |
| 502 | + |
| 503 | + updateSliderLimits(); |
| 504 | + render(); |
| 505 | + } |
| 506 | + |
| 507 | + function togglePlay() { |
| 508 | + if (state.playing) { |
| 509 | + stopPlaying(); |
| 510 | + } else { |
| 511 | + startPlaying(); |
| 512 | + } |
| 513 | + } |
| 514 | + |
| 515 | + function startPlaying() { |
| 516 | + stopPlaying(); |
| 517 | + |
| 518 | + const currentIndex = state.availableYears.indexOf(state.currentYear); |
| 519 | + const lastIndex = state.availableYears.length - 1; |
| 520 | + |
| 521 | + if (currentIndex >= lastIndex) return; |
| 522 | + |
| 523 | + state.playing = true; |
| 524 | + d3.select(selectors.play).text("II"); |
| 525 | + |
| 526 | + playNextYear(); |
| 527 | + } |
| 528 | + |
| 529 | + function playNextYear() { |
| 530 | + if (!state.playing) return; |
| 531 | + |
| 532 | + const currentIndex = state.availableYears.indexOf(state.currentYear); |
| 533 | + const lastIndex = state.availableYears.length - 1; |
| 534 | + |
| 535 | + if (currentIndex >= lastIndex) { |
| 536 | + stopPlaying(); |
| 537 | + return; |
| 538 | + } |
| 539 | + |
| 540 | + const nextIndex = currentIndex + 1; |
| 541 | + state.currentYear = state.availableYears[nextIndex]; |
| 542 | + |
| 543 | + update(); |
| 544 | + broadcastYear(state.currentYear); |
| 545 | + |
| 546 | + state.timer = setTimeout(playNextYear, 1300); |
| 547 | + } |
| 548 | + |
| 549 | + function stopPlaying() { |
| 550 | + state.playing = false; |
| 551 | + |
| 552 | + if (state.timer !== null) { |
| 553 | + clearTimeout(state.timer); |
| 554 | + state.timer = null; |
| 555 | + } |
| 556 | + |
| 557 | + d3.select(selectors.play).text("▶"); |
| 558 | + } |
| 559 | + |
| 560 | + function broadcastYear(year) { |
| 561 | + window.dispatchEvent(new CustomEvent("olympic-year-change", { |
| 562 | + detail: { |
| 563 | + year, |
| 564 | + season: state.currentSeason, |
| 565 | + source: "never-medaled" |
| 566 | + } |
| 567 | + })); |
| 568 | + } |
| 569 | + |
416 | 570 | function render() { |
417 | 571 | const root = d3.select(selectors.root); |
418 | 572 | root.selectAll("*").remove(); |
|
441 | 595 | function computeNeverMedaledCountries() { |
442 | 596 | const c = state.columns; |
443 | 597 |
|
| 598 | + const rowsForYear = state.rows.filter(row => { |
| 599 | + const rowYear = Number(row[c.year]); |
| 600 | + const rowSeason = String(row[c.season] || "").trim(); |
| 601 | + |
| 602 | + return ( |
| 603 | + Number.isFinite(rowYear) && |
| 604 | + rowYear <= state.currentYear && |
| 605 | + rowYear <= STOP_YEAR && |
| 606 | + rowSeason === state.currentSeason |
| 607 | + ); |
| 608 | + }); |
| 609 | + |
444 | 610 | if (!c.country || !c.medal || !c.sport) { |
445 | 611 | throw new Error("Missing required columns: country, medal, or sport."); |
446 | 612 | } |
447 | 613 |
|
448 | 614 | const allCountries = new Map(); |
449 | 615 | const medalCountries = new Set(); |
450 | 616 |
|
451 | | - state.rows.forEach(row => { |
| 617 | + rowsForYear.forEach(row => { |
452 | 618 | const country = normalizeCountryName(cleanCountry(row[c.country])); |
453 | 619 | const medal = cleanLabel(row[c.medal]); |
454 | 620 | const noc = c.noc ? cleanLabel(row[c.noc]) : ""; |
|
478 | 644 |
|
479 | 645 | const neverSet = new Set(neverMedaledNames); |
480 | 646 |
|
481 | | - const rowsForNeverMedaled = state.rows.filter(row => { |
| 647 | + const rowsForNeverMedaled = rowsForYear.filter(row => { |
482 | 648 | const country = normalizeCountryName(cleanCountry(row[c.country])); |
483 | 649 | return neverSet.has(country); |
484 | 650 | }); |
|
0 commit comments