Back to PHICodepen Playground
Codepen Playground

Amplitude Timeline

Data Viz

live renderhtml
htmlmultitimeline.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Multidimensional Timeline Calendar</title>
    <!-- Bootstrap 5 CDN -->
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <style>
      :root {
        /* category colours (extend as needed) */
        --climate: #86d06d;
        --astronomy: #63a2ff;
        --culture: #ff66aa;
        --mythic: #f9bf3b;
        --economy: #ff8763;
      }
      html,
      body {
        height: 100%;
        margin: 0;
        font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
          Arial, sans-serif;
      }
      #app {
        height: 100%;
        display: flex;
        flex-direction: column;
      }
      /* controls */
      #controls {
        background: #212123;
        color: #f8f9fa;
      }
      #timelineWrapper {
        flex: 1 1 auto;
        overflow-y: auto;
        position: relative;
        background: #f7f0e6;
      }
      /* svg timeline */
      svg.timeline-svg {
        position: absolute;
        left: 0;
        top: 0;
      }
      .timeline-base {
        stroke: #212123;
        stroke-width: 2px;
      }
      .event-circle {
        stroke: #212123;
        stroke-width: 1;
        fill-opacity: 0.6;
        transition: r 0.2s ease, fill-opacity 0.2s ease;
        cursor: pointer;
      }
      .event-circle:hover {
        r: calc(var(--r) + 8px);
        fill-opacity: 1;
      }
      .event-label {
        font-size: 0.85rem;
        fill: #212123;
        pointer-events: none;
        user-select: none;
      }
      .event-date {
        font-size: 0.75rem;
        fill: #666;
        pointer-events: none;
        user-select: none;
      }
      /* colour mapping for categories */
      .cat-astronomy {
        fill: var(--astronomy);
      }
      .cat-climate {
        fill: var(--climate);
      }
      .cat-culture {
        fill: var(--culture);
      }
      .cat-mythic {
        fill: var(--mythic);
      }
      .cat-economy {
        fill: var(--economy);
      }
    </style>
  </head>
  <body>
    <div id="app">
      <!-- CONTROLS -->
      <nav id="controls" class="navbar navbar-dark px-3 py-2">
        <div class="navbar-brand">Multidimensional Timeline</div>
        <form class="d-flex gap-3 flex-wrap" role="search" id="controlForm">
          <select id="calendarSelect" class="form-select form-select-sm">
            <option value="gregorian">Gregorian</option>
            <option value="julian">Julian</option>
            <option value="mayan">Mayan Long Count</option>
            <option value="chinese">Chinese Zodiac</option>
            <option value="indian">Indian Lunisolar</option>
            <option value="aztec">Aztec Tonalpohualli</option>
            <option value="nazca">Nazca Horizon</option>
            <option value="abstract">Planetary Abstract</option>
          </select>
          <div class="form-check form-switch text-nowrap">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="liveDataSwitch"
            />
            <label class="form-check-label" for="liveDataSwitch"
              >Live Celestial Data</label
            >
          </div>
          <div class="form-check form-switch text-nowrap">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="fictionSwitch"
              checked
            />
            <label class="form-check-label" for="fictionSwitch"
              >Mythic &amp; Fictional</label
            >
          </div>
        </form>
      </nav>

      <!-- TIMELINE WRAPPER -->
      <div id="timelineWrapper">
        <svg class="timeline-svg" id="timelineSvg"></svg>
      </div>
    </div>

    <!-- Optionally, Bootstrap JS (popper included) -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>

    <script>
      /* =============================================================
       *  Multidimensional Timeline Calendar – Vanilla JS (no libs)
       *  Author: ChatGPT o3, 2025
       *  =========================================================== */

      const SVG_NS = "http://www.w3.org/2000/svg";

      /* Utility: date helpers */
      function iso(date) {
        return date.toISOString().split("T")[0];
      }

      // Julian = Gregorian - 13 days (valid 1900‑2100 for demo)
      function toJulian(date) {
        const jul = new Date(date);
        jul.setDate(jul.getDate() - 13);
        return iso(jul);
      }

      // Stub converters (placeholders) – return symbolic strings
      function toMayanLongCount(date) {
        // Very rough: days since 2025‑06‑13 → add offset to 13.0.0.0.0 (Dec 21, 2012)
        const base = new Date("2012-12-21");
        const diff = Math.floor((date - base) / 864e5);
        return `13.${Math.floor(diff / 144000)}.${Math.floor((diff % 144000) / 7200)}.${Math.floor((diff % 7200) / 360)}.${diff % 360}`;
      }

      function toChineseZodiac(date) {
        const animals = [
          "Rat",
          "Ox",
          "Tiger",
          "Rabbit",
          "Dragon",
          "Snake",
          "Horse",
          "Goat",
          "Monkey",
          "Rooster",
          "Dog",
          "Pig",
        ];
        const baseYear = 1900; // Rat
        const idx = (date.getFullYear() - baseYear) % 12;
        return `${animals[idx]} Year`;
      }

      function toIndianLunisolar(date) {
        return `Paksha ${date.getDate() % 15}, Month ${(date.getMonth() + 1)}`;
      }

      function toAztec(date) {
        return `Tonalpohualli ${(date.getTime() / 864e5) % 260 | 0}`;
      }

      function toNazca(date) {
        return `Solstice offset ${(Math.abs(date - new Date(date.getFullYear(), 5, 21)) / 864e5).toFixed(0)}d`;
      }

      function toAbstract(date) {
        const k101start = new Date("2025-06-13");
        const days = Math.floor((date - k101start) / 864e5);
        return `K101+${days}`;
      }

      function convertDate(date, calendar) {
        switch (calendar) {
          case "julian":
            return toJulian(date);
          case "mayan":
            return toMayanLongCount(date);
          case "chinese":
            return toChineseZodiac(date);
          case "indian":
            return toIndianLunisolar(date);
          case "aztec":
            return toAztec(date);
          case "nazca":
            return toNazca(date);
          case "abstract":
            return toAbstract(date);
          default:
            return iso(date);
        }
      }

      /* Event generation (demo)  */
      const BASE_EVENTS = [
        {
          id: 1,
          date: new Date("2025-06-13"),
          label: "Friday 13 // K101 Era begins",
          category: "culture",
          intensity: 0.8,
        },
        {
          id: 2,
          date: new Date("2025-07-03"),
          label: "Earth Aphelion",
          category: "astronomy",
          intensity: 0.7,
        },
        {
          id: 3,
          date: new Date("2026-02-13"),
          label: "Super El Niño Peak",
          category: "climate",
          intensity: 0.9,
        },
      ];

      // Generate placeholder events year +/- range (fictional & real)
      function generateSyntheticEvents(yearsBack = 50, yearsForward = 50) {
        const synthetic = [];
        const categories = ["astronomy", "climate", "culture", "mythic", "economy"];
        const now = new Date();
        const startYear = now.getFullYear() - yearsBack;
        const endYear = now.getFullYear() + yearsForward;
        let id = 1000;
        for (let y = startYear; y <= endYear; y++) {
          // couple per year
          const count = Math.random() * 3 + 1;
          for (let i = 0; i < count; i++) {
            const d = new Date(y, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1);
            const cat = categories[(Math.random() * categories.length) | 0];
            synthetic.push({
              id: id++,
              date: d,
              label: `${cat.charAt(0).toUpperCase() + cat.slice(1)} Event`,
              category: cat,
              intensity: Math.random(),
            });
          }
        }
        return synthetic;
      }

      const EVENTS = [...BASE_EVENTS, ...generateSyntheticEvents()];

      /* SVG Timeline rendering */
      const timelineWrapper = document.getElementById("timelineWrapper");
      const svg = document.getElementById("timelineSvg");

      // timeline dimensions will be recalculated on resize/scroll
      let timelineX = 120;
      const marginTop = 40;
      const marginBottom = 40;
      const circleRadiusMin = 6;
      const circleRadiusMax = 28;

      // scale helpers
      function linearScale(domainMin, domainMax, rangeMin, rangeMax) {
        return (val) => {
          const ratio = (val - domainMin) / (domainMax - domainMin || 1);
          return rangeMin + ratio * (rangeMax - rangeMin);
        };
      }

      function renderTimeline() {
        // Clean svg
        svg.innerHTML = "";

        const wrapperRect = timelineWrapper.getBoundingClientRect();
        const width = wrapperRect.width;
        const height = EVENTS.length * 80 + marginTop + marginBottom; // rough initial height
        svg.setAttribute("width", width);
        svg.setAttribute("height", height);

        // baseline
        const baseLine = document.createElementNS(SVG_NS, "line");
        baseLine.setAttribute("x1", timelineX);
        baseLine.setAttribute("y1", 0);
        baseLine.setAttribute("x2", timelineX);
        baseLine.setAttribute("y2", height);
        baseLine.setAttribute("class", "timeline-base");
        svg.appendChild(baseLine);

        // domain (time) – earliest to latest
        const minDate = EVENTS.reduce((a, b) => (a < b.date ? a : b.date), EVENTS[0].date);
        const maxDate = EVENTS.reduce((a, b) => (a > b.date ? a : b.date), EVENTS[0].date);
        const domainMin = minDate.getTime();
        const domainMax = maxDate.getTime();
        const scaleY = linearScale(domainMin, domainMax, marginTop, height - marginBottom);

        // radius scale – intensity based
        const scaleR = linearScale(0, 1, circleRadiusMin, circleRadiusMax);

        // calendar conversion
        const calendarType = document.getElementById("calendarSelect").value;

        EVENTS.forEach((ev, index) => {
          const y = scaleY(ev.date.getTime());
          const r = scaleR(ev.intensity);

          // group
          const g = document.createElementNS(SVG_NS, "g");

          // circle
          const circle = document.createElementNS(SVG_NS, "circle");
          circle.setAttribute("cx", timelineX);
          circle.setAttribute("cy", y);
          circle.setAttribute("r", r);
          circle.setAttribute("class", `event-circle cat-${ev.category}`);
          circle.style.setProperty("--r", r + "px");

          // label
          const label = document.createElementNS(SVG_NS, "text");
          label.setAttribute("x", timelineX + r + 12);
          label.setAttribute("y", y + 4);
          label.setAttribute("class", "event-label");
          label.textContent = ev.label;

          // date
          const dateTxt = document.createElementNS(SVG_NS, "text");
          dateTxt.setAttribute("x", timelineX - r - 12);
          dateTxt.setAttribute("y", y + 4);
          dateTxt.setAttribute("text-anchor", "end");
          dateTxt.setAttribute("class", "event-date");
          dateTxt.textContent = convertDate(ev.date, calendarType);

          // tooltip (simple using title)
          circle.setAttribute("title", `${ev.label}\n${convertDate(ev.date, calendarType)}`);

          g.appendChild(circle);
          g.appendChild(label);
          g.appendChild(dateTxt);
          svg.appendChild(g);
        });
      }

      // UI interactions
      document.getElementById("calendarSelect").addEventListener("change", renderTimeline);
      document.getElementById("fictionSwitch").addEventListener("change", (e) => {
        if (e.target.checked) {
          // ensure mythic are visible
          EVENTS.forEach((ev) => {
            if (ev.category === "mythic") ev.hidden = false;
          });
        } else {
          EVENTS.forEach((ev) => {
            if (ev.category === "mythic") ev.hidden = true;
          });
        }
        renderTimeline();
      });

      // Live data fetch (placeholder)
      document.getElementById("liveDataSwitch").addEventListener("change", async (e) => {
        if (e.target.checked) {
          // Example: fetch today’s NASA APOD date and add to timeline
          try {
            const res = await fetch(
              "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY"
            );
            const json = await res.json();
            EVENTS.push({
              id: Date.now(),
              date: new Date(json.date),
              label: `NASA APOD: ${json.title}`,
              category: "astronomy",
              intensity: 0.5,
            });
            renderTimeline();
          } catch (err) {
            console.error("API error", err);
          }
        }
      });

      // initial render
      renderTimeline();

      /* Infinite scroll: if near bottom, add more future events  */
      timelineWrapper.addEventListener("scroll", () => {
        const threshold = 300; // px from bottom
        if (
          timelineWrapper.scrollTop + timelineWrapper.clientHeight + threshold >=
          timelineWrapper.scrollHeight
        ) {
          // generate +10 future years events
          const futureEvents = generateSyntheticEvents(0, 10);
          EVENTS.push(...futureEvents);
          renderTimeline();
        }
      });

      // responsive resize
      window.addEventListener("resize", renderTimeline);
    </script>
  </body>
</html>