One file. Reactive state on requestAnimationFrame.
Built with wildflower.nano.min.js
<div data-component="world-clock">
<button data-action="toggleView" data-bind="toggleLabel"></button>
<!-- Analog: three hands, each positioned by a computed style -->
<div class="face" data-show="analog">
<div class="hand hour" data-bind-style="hourStyle"></div>
<div class="hand minute" data-bind-style="minuteStyle"></div>
<div class="hand second" data-bind-style="secondStyle"></div>
</div>
<!-- Digital -->
<div class="digital" data-show="!analog">
<div data-bind="digital"></div>
<div data-bind="dateStr"></div>
</div>
<select data-model="zone"> ...time zones... </select>
<div id="clock-wall"></div> <!-- cloned city cards land here -->
</div>
<!-- One city, written once. cloneNode() stamps copies. -->
<template id="city-template">
<div class="city" data-component="city-clock">
<button data-action="remove">×</button>
<div data-bind="label"></div>
<div data-bind="time"></div>
<div data-bind="date"></div>
</div>
</template>
// The main clock: a reactive `now` updated every animation frame gives the
// second hand a smooth sweep; every computed below is derived from it.
wildflower.component('world-clock', {
state: {
now: Date.now(), zone: 'America/New_York',
analog: true, cities: [], newCity: 'Europe/London'
},
computed: {
zParts() { return zonedParts(this.now, this.zone); },
secondAngle() { return (this.zParts.s + (this.now % 1000) / 1000) * 6; },
minuteAngle() { return (this.zParts.m + this.zParts.s / 60) * 6; },
hourAngle() { return ((this.zParts.h % 12) + this.zParts.m / 60) * 30; },
hourStyle() { return { transform: `translateX(-50%) rotate(${this.hourAngle}deg)` }; },
minuteStyle() { return { transform: `translateX(-50%) rotate(${this.minuteAngle}deg)` }; },
secondStyle() { return { transform: `translateX(-50%) rotate(${this.secondAngle}deg)` }; },
digital() { return fmtTime(this.now, this.zone); },
toggleLabel() { return this.analog ? 'Digital' : 'Analog'; },
cityCount() { return this.cities.length; }
},
init() {
const tick = () => { this.now = Date.now(); this._raf = requestAnimationFrame(tick); };
this._raf = requestAnimationFrame(tick);
},
destroy() { cancelAnimationFrame(this._raf); },
toggleView() { this.analog = !this.analog; },
// Array in state stays the source of truth; reassign to guarantee reactivity.
addCity() {
const zone = this.newCity;
if (!zone || this.cities.includes(zone)) return;
this.cities = this.cities.concat(zone);
// Clone the <template> and append it. The mutation observer
// initializes the new city-clock component automatically.
const el = document.getElementById('city-template').content.firstElementChild.cloneNode(true);
el.dataset.zone = zone;
this.element.querySelector('#clock-wall').appendChild(el);
},
dropCity(zone) { this.cities = this.cities.filter(z => z !== zone); }
});
// Each city on the wall is its own reactive component, self-ticking once a second.
wildflower.component('city-clock', {
state: { now: Date.now() },
computed: {
zone() { return this.element ? this.element.dataset.zone : 'UTC'; },
label() { return cityLabel(this.zone); },
time() { return fmtTime(this.now, this.zone); },
date() { return fmtDate(this.now, this.zone); }
},
init() { this._timer = setInterval(() => { this.now = Date.now(); }, 1000); },
// The mutation observer fires destroy() when the element leaves the DOM.
destroy() { clearInterval(this._timer); },
remove() {
const main = wildflower.getComponents('world-clock')[0];
if (main) main.dropCity(this.zone);
this.element.remove();
}
});