DEV Community

Menard Maranan
Menard Maranan

Posted on

Traffic Light - vanilla HTML, CSS, & JavaScript

I created this fun little project as a refresher.

🌐 Demo: https://menard-codes.github.io/traffic-light/
👨‍💻 Code: https://github.com/menard-codes/traffic-light


I haven't touched pure vanilla HTML, CSS, and JavaScript for a while.

React, Tailwind, and TypeScript is what I use everyday in the frontend, so I want to challenge myself if I still got the basics right without relying on modern conveniences of frameworks and tools.

And since there's no React to help me manage component state, I have to rely on the observer pattern if I want a similar functionality.

So I decided to try and build a Traffic Light simulator, only using HTML, CSS, and JavaScript.

Markpup

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main>
      <div class="traffic-light">
        <div class="timer-box">
          <p id="timer"></p>
        </div>
        <div class="box">
          <div class="light red"></div>
          <div class="light yellow"></div>
          <div class="light green"></div>
        </div>
      </div>
    </main>

    <script src="./script.js" defer></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Stylesheet

*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

main {
  min-height: 100vh;
  display: grid;
  place-items: center;
}

.traffic-light {
  display: grid;
  gap: 1rem;
}

.traffic-light .box {
  width: 8rem;
  height: 20rem;
  background-color: #1d1e22;
  border-radius: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 1.2rem;
  justify-content: center;
  align-items: center;
}

.light {
  width: 5rem;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
}

.red {
  background-color: #500;
}

.red.on {
  background-color: #f00;
}

.yellow {
  background-color: #550;
}

.yellow.on {
  background-color: #ff0;
}

.green {
  background-color: #050;
}

.green.on {
  background-color: #0f0;
}

.timer-box {
  background-color: #555;
  border: 5px solid #333;
  border-radius: 1rem;
  padding: 0.5rem 1rem;
  text-align: center;
}

#timer {
  font-family: "Courier New", Courier, monospace;
  font-weight: 700;
  color: #0f0;
  font-size: 4rem;
}

#timer.warn {
  color: #f00;
}
Enter fullscreen mode Exit fullscreen mode

Traffic Light logic

I created 3 classes:

  • Timer
  • Light
  • TrafficLight

The TrafficLight and Light classes follow the observer pattern.

The TrafficLight.run() method handles switching the currently active light, notifying the subscribers, and running/resetting the timer on every light switch.

document.addEventListener("DOMContentLoaded", () => {
  class Timer {
    _currentCount = 0;
    _interval = null;
    _timer = document.querySelector("#timer");

    constructor() {
      this._timer.textContent = this._currentCount;
    }

    /**
     * @param {{ maxCount: number; currentColor: TrafficLightColor }}
     */
    start({ maxCount, currentColor }) {
      // Reset count and clear any existing interval before starting fresh
      this._currentCount = maxCount;
      this._setTimerDisplay({ currentColor });

      if (this._interval !== null) {
        clearInterval(this._interval);
      }

      this._interval = setInterval(() => {
        this._currentCount--;

        if (this._currentCount <= 0) {
          this.stop();
        }

        this._setTimerDisplay({ currentColor });
      }, 1000);
    }

    stop() {
      if (this._interval !== null) {
        clearInterval(this._interval);
        this._interval = null;
        this._currentCount = 0;
        this._timer.classList.remove("warn");
      }
    }

    /**
     * @param {{ currentColor: TrafficLightColor }} param0
     */
    _setTimerDisplay({ currentColor }) {
      this._timer.textContent = String(this._currentCount).padStart(2, "0");
      switch (currentColor) {
        case "green": {
          this._timer.classList.add("go");
          this._timer.classList.remove("wait");
          this._timer.classList.remove("stop");
          break;
        }
        case "red": {
          this._timer.classList.add("stop");
          this._timer.classList.remove("wait");
          this._timer.classList.remove("go");
          break;
        }
        case "yellow": {
          this._timer.classList.add("wait");
          this._timer.classList.remove("go");
          this._timer.classList.remove("stop");
          break;
        }
      }
    }
  }

  /**
   * @typedef {'red' | 'yellow' | 'green'} TrafficLightColor
   */
  class Light {
    /**
     * @param {TrafficLightColor} color
     */
    constructor(color) {
      this.color = color;
      this._dom = document.querySelector(`.${color}`);
    }

    /**
     * @param {TrafficLightColor} currentColor
     */
    notify(currentColor) {
      if (currentColor === this.color) {
        this._turnOn();
      } else {
        this._turnOff();
      }
    }

    _turnOn() {
      this._dom.classList.add("on");
    }

    _turnOff() {
      this._dom.classList.remove("on");
    }
  }

  class TrafficLight {
    _listeners = [new Light("red"), new Light("yellow"), new Light("green")];

    /**
     *
     * @param {{ timeInterval: number }} {timeInterval} - Time interval in seconds for switching the lights.
     */
    constructor({ timeInterval } = {}) {
      this._timeInterval = timeInterval ?? 60;
    }

    async run() {
      const timer = new Timer();
      const listenersLength = this._listeners.length;
      let currentPointer = listenersLength;
      while (true) {
        const nextPointer = currentPointer - 1;
        currentPointer = nextPointer < 0 ? listenersLength - 1 : nextPointer;
        const colors = this._listeners.map((listener) => listener.color);
        const currentColor = colors[currentPointer];

        this._listeners.forEach((listener) => listener.notify(currentColor));

        // Start timer AFTER switching the light, so count is in sync with current light

        const sleepInMS =
          currentColor === "yellow"
            ? Math.max(this._timeInterval - (this._timeInterval - 6), 6) * 1000
            : this._timeInterval * 1000;

        timer.start({ maxCount: sleepInMS / 1000, currentColor });

        await this._sleep(sleepInMS);

        timer.stop();
      }
    }

    async _sleep(time = 1000) {
      await new Promise((res) => setTimeout(res, time));
    }
  }

  const trafficLight = new TrafficLight({ timeInterval: 15 });
  trafficLight.run();
});

Enter fullscreen mode Exit fullscreen mode

Final thoughts

Unintentionally, I also had to refresh my understanding of the browser event loop. I ran through several bugs because of improper use of intervals, timeouts, and promises. That reminded me of the time when I was first learning about the event loop, pulling my hair out of frustration trying to understand it, lol.

Top comments (0)