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>
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;
}
Traffic Light logic
I created 3 classes:
TimerLightTrafficLight
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();
});
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)