diff --git a/README.md b/README.md
index 6f76c6e..6e7e4b6 100644
--- a/README.md
+++ b/README.md
@@ -54,13 +54,15 @@ An interactive web app that visualizes the **CosmoChron Binary Epoch (CoBiE)** t
```
├── index.html # Main HTML markup
-├── analog.html # Analog clock interface
├── clock.js # Clock logic
-
-├── style.css # Separated styles
-├── script.js # JavaScript logic
-├── README.md # This documentation
-└── assets/ # (Optional) images or external CSS/JS
+├── cobie.js # CoBiE time system utilities
+├── utils.js # Generic helper functions
+├── animate.js # Shared animations
+├── events.js # Sample calendar events
+├── style.css # Separated styles
+├── script.js # Page interactions
+├── README.md # This documentation
+└── test/ # Unit tests
```
## macOS Widget
diff --git a/animate.js b/animate.js
new file mode 100644
index 0000000..316088a
--- /dev/null
+++ b/animate.js
@@ -0,0 +1,83 @@
+(function(){
+ const lastAngles = {
+ handXeno: 0,
+ handQuantic: 0,
+ handChronon: 0,
+ handEonstrip: 0,
+ handMegasequence: 0
+ };
+
+ function rotateHand(id, angle) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ const prev = lastAngles[id];
+
+ if (angle < prev) {
+ const target = angle + 360;
+ const handle = () => {
+ el.removeEventListener('transitionend', handle);
+ el.style.transition = 'none';
+ el.style.transform = `translateX(-50%) translateZ(0) rotate(${angle}deg)`;
+ void el.offsetWidth;
+ el.style.transition = '';
+ };
+ el.addEventListener('transitionend', handle, { once: true });
+ el.style.transform = `translateX(-50%) translateZ(0) rotate(${target}deg)`;
+ } else {
+ el.style.transform = `translateX(-50%) translateZ(0) rotate(${angle}deg)`;
+ }
+ lastAngles[id] = angle;
+ }
+
+ function animateSwipe(direction, onDone) {
+ const grid = document.getElementById('eonstripGrid');
+ if (!grid) { onDone(); return; }
+
+ grid.style.transition = 'none';
+ grid.style.transform = 'translateX(0)';
+ void grid.offsetWidth;
+
+ grid.style.transition = 'transform 0.3s ease';
+ grid.style.transform = `translateX(${direction > 0 ? '-100%' : '100%'})`;
+
+ function afterOut() {
+ grid.removeEventListener('transitionend', afterOut);
+ grid.style.transition = 'none';
+ grid.style.transform = `translateX(${direction > 0 ? '100%' : '-100%'})`;
+ onDone();
+ void grid.offsetWidth;
+ grid.style.transition = 'transform 0.3s ease';
+ grid.style.transform = 'translateX(0)';
+ }
+ grid.addEventListener('transitionend', afterOut);
+ }
+
+ function animateDetailSwipe(direction, onDone) {
+ const tl = document.getElementById('detailTimeline');
+ if (!tl) { onDone(); return; }
+
+ tl.style.transition = 'none';
+ tl.style.transform = 'translateX(0)';
+ void tl.offsetWidth;
+
+ tl.style.transition = 'transform 0.3s ease';
+ tl.style.transform = `translateX(${direction > 0 ? '-100%' : '100%'})`;
+
+ function afterOut() {
+ tl.removeEventListener('transitionend', afterOut);
+ tl.style.transition = 'none';
+ tl.style.transform = `translateX(${direction > 0 ? '100%' : '-100%'})`;
+ onDone();
+ void tl.offsetWidth;
+ tl.style.transition = 'transform 0.3s ease';
+ tl.style.transform = 'translateX(0)';
+ }
+ tl.addEventListener('transitionend', afterOut);
+ }
+
+ window.Animate = {
+ rotateHand,
+ animateSwipe,
+ animateDetailSwipe
+ };
+})();
diff --git a/clock.js b/clock.js
index 163ac76..3aa0946 100644
--- a/clock.js
+++ b/clock.js
@@ -114,39 +114,6 @@
});
}
- const lastAngles = {
- handXeno: 0,
- handQuantic: 0,
- handChronon: 0,
- handEonstrip: 0,
- handMegasequence: 0
- };
-
- function rotateHand(id, angle) {
- const el = document.getElementById(id);
- if (!el) return;
- const prev = lastAngles[id];
-
- if (angle < prev) {
- // When wrapping around (e.g. 15 → 0), animate to one full turn
- // and then snap back to the new angle to avoid a jump.
- const target = angle + 360;
- const handle = () => {
- el.removeEventListener('transitionend', handle);
- // Snap back without animation
- el.style.transition = 'none';
- el.style.transform = `translateX(-50%) translateZ(0) rotate(${angle}deg)`;
- void el.offsetWidth;
- el.style.transition = '';
- };
- el.addEventListener('transitionend', handle, { once: true });
- el.style.transform = `translateX(-50%) translateZ(0) rotate(${target}deg)`;
- } else {
- el.style.transform = `translateX(-50%) translateZ(0) rotate(${angle}deg)`;
- }
-
- lastAngles[id] = angle;
- }
function renderClock(cob) {
// Use fractional progress within each unit so angles stay small
@@ -155,11 +122,11 @@
const cf = (cob % COBIE_UNITS.eonstrip) / COBIE_UNITS.eonstrip;
const ef = (cob % COBIE_UNITS.megasequence) / COBIE_UNITS.megasequence;
const mf = (cob % COBIE_UNITS.cosmocycle) / COBIE_UNITS.cosmocycle;
- rotateHand('handXeno', xf * 360);
- rotateHand('handQuantic', qf * 360);
- rotateHand('handChronon', cf * 360);
- rotateHand('handEonstrip', ef * 360);
- rotateHand('handMegasequence', mf * 360);
+ Animate.rotateHand('handXeno', xf * 360);
+ Animate.rotateHand('handQuantic', qf * 360);
+ Animate.rotateHand('handChronon', cf * 360);
+ Animate.rotateHand('handEonstrip', ef * 360);
+ Animate.rotateHand('handMegasequence', mf * 360);
}
function updateClock() {
diff --git a/cobie.js b/cobie.js
index 37fceef..7fb19f2 100644
--- a/cobie.js
+++ b/cobie.js
@@ -19,6 +19,20 @@ const COBIE_UNITS = {
astralmillennia: 0x1000000000000000
};
+const EONSTRIP_NAMES = [
+ 'Solprime', 'Lunex', 'Terros', 'Aquarion',
+ 'Ventaso', 'Ignisar', 'Crystalos', 'Floraen',
+ 'Faunor', 'Nebulus', 'Astraeus', 'Umbranox',
+ 'Electros', 'Chronar', 'Radiantae', 'Etherion'
+];
+
+const MEGASEQUENCE_NAMES = [
+ 'Azurean Tide', 'Sable Gleam', 'Verdanth Starfall', 'Crimson Dusk',
+ 'Cobalt Frost', 'Amber Blaze', 'Viridian Bloom', 'Argent Veil',
+ 'Helian Rise', 'Nocturne Shade', 'Celestine Aura', 'Pyralis Light',
+ 'Zephyrine Whisper', 'Lustran Bounty', 'Umbral Echo', 'Mythran Epoch'
+];
+
function floorDiv(a, b) {
return Math.trunc(a / b);
}
@@ -172,7 +186,9 @@ const Cobie = {
toCobiets,
fromCobiets,
formatCobieTimestamp,
- breakdownNonNeg
+ breakdownNonNeg,
+ EONSTRIP_NAMES,
+ MEGASEQUENCE_NAMES
};
if (typeof module !== 'undefined' && module.exports) {
diff --git a/index.html b/index.html
index af18f18..672005b 100644
--- a/index.html
+++ b/index.html
@@ -167,6 +167,8 @@
+
+
diff --git a/script.js b/script.js
index dcc2ef7..6460678 100644
--- a/script.js
+++ b/script.js
@@ -20,19 +20,7 @@ const {
breakdownNonNeg
} = window.Cobie;
-const EONSTRIP_NAMES = [
- 'Solprime', 'Lunex', 'Terros', 'Aquarion',
- 'Ventaso', 'Ignisar', 'Crystalos', 'Floraen',
- 'Faunor', 'Nebulus', 'Astraeus', 'Umbranox',
- 'Electros', 'Chronar', 'Radiantae', 'Etherion'
-];
-
-const MEGASEQUENCE_NAMES = [
- 'Azurean Tide', 'Sable Gleam', 'Verdanth Starfall', 'Crimson Dusk',
- 'Cobalt Frost', 'Amber Blaze', 'Viridian Bloom', 'Argent Veil',
- 'Helian Rise', 'Nocturne Shade', 'Celestine Aura', 'Pyralis Light',
- 'Zephyrine Whisper', 'Lustran Bounty', 'Umbral Echo', 'Mythran Epoch'
-];
+const { EONSTRIP_NAMES, MEGASEQUENCE_NAMES } = window.Cobie;
let currentOffset = 0;
let currentTimezone = 'UTC';
@@ -44,34 +32,14 @@ let updateInterval;
let lastRenderedEonstrip = null;
let currentDetailCob = null;
-// ── Utility color helpers ────────────────────────────────────────────────
-function parseColor(hex) {
- if (!hex) return [255, 255, 255];
- let c = hex.replace('#', '');
- if (c.length === 3) c = c.split('').map(x => x + x).join('');
- const num = parseInt(c, 16);
- return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
-}
-
-const toHex = v => v.toString(16).padStart(2, '0');
-
-const hexToRgba = (hex, a = 1) => {
- const [r, g, b] = parseColor(hex);
- return `rgba(${r},${g},${b},${a})`;
-};
-
-const getContrastColor = hex => {
- const [r, g, b] = parseColor(hex);
- const yiq = (r * 299 + g * 587 + b * 114) / 1000;
- return yiq >= 128 ? '#000' : '#fff';
-};
-
-const lightenColor = (hex, p) => {
- const [r, g, b] = parseColor(hex).map(v =>
- Math.min(255, Math.round(v + (255 - v) * p))
- );
- return '#' + [r, g, b].map(toHex).join('');
-};
+// ── Utility color helpers (in utils.js) ───────────────────────────────────
+const {
+ parseColor,
+ hexToRgba,
+ getContrastColor,
+ lightenColor,
+ getHumanDiff
+} = window.Utils;
const dateOptions = (long = true) => ({
timeZone: currentTimezone === 'TAI' ? 'UTC' : currentTimezone,
@@ -130,71 +98,7 @@ function formatSafeDate(rawDate, cobSeconds, intlOptions) {
// parseCobiets, floorDiv and other CoBiE helpers are provided by cobie.js
-function getHumanDiff(d1, d2) {
- // make sure start ≤ end
- let start = d1 < d2 ? d1 : d2;
- let end = d1 < d2 ? d2 : d1;
- // 1) year/month/day difference
- let years = end.getUTCFullYear() - start.getUTCFullYear();
- let months = end.getUTCMonth() - start.getUTCMonth();
- let days = end.getUTCDate() - start.getUTCDate();
-
- // if day roll-under, borrow from month
- if (days < 0) {
- months--;
- // days in the month *before* `end`’s month:
- let prevMonthDays = new Date(Date.UTC(
- end.getUTCFullYear(),
- end.getUTCMonth(), 0
- )).getUTCDate();
- days += prevMonthDays;
- }
- // if month roll-under, borrow from year
- if (months < 0) {
- years--;
- months += 12;
- }
-
- // 2) now handle hours/min/sec by “aligning” a Date at start+Y/M/D
- let aligned = new Date(Date.UTC(
- start.getUTCFullYear() + years,
- start.getUTCMonth() + months,
- start.getUTCDate() + days,
- start.getUTCHours(),
- start.getUTCMinutes(),
- start.getUTCSeconds()
- ));
- let diffMs = end.getTime() - aligned.getTime();
-
- // if we overshot (negative), borrow one day
- if (diffMs < 0) {
- // borrow 24 h
- diffMs += 24 * 3600e3;
- if (days > 0) {
- days--;
- } else {
- // days was zero, so borrow a month
- months--;
- if (months < 0) {
- years--;
- months += 12;
- }
- // set days to length of the previous month of `end`
- days = new Date(Date.UTC(
- end.getUTCFullYear(),
- end.getUTCMonth(), 0
- )).getUTCDate();
- }
- }
-
- // 3) extract h/m/s
- let hours = Math.floor(diffMs / 3600e3); diffMs -= hours * 3600e3;
- let minutes = Math.floor(diffMs / 60e3); diffMs -= minutes * 60e3;
- let seconds = Math.floor(diffMs / 1e3);
-
-return { years, months, days, hours, minutes, seconds };
-}
// ── Event utilities ──────────────────────────────────────────────────────
const normalizeEvent = ev => {
@@ -866,14 +770,14 @@ function updateDetailCurrentTime() {
function detailPrev() {
if (currentDetailCob === null) return;
- animateDetailSwipe(-1, () => {
+ Animate.animateDetailSwipe(-1, () => {
showEonstripDetail(currentDetailCob - COBIE_UNITS.eonstrip);
});
}
function detailNext() {
if (currentDetailCob === null) return;
- animateDetailSwipe(1, () => {
+ Animate.animateDetailSwipe(1, () => {
showEonstripDetail(currentDetailCob + COBIE_UNITS.eonstrip);
});
}
@@ -921,65 +825,12 @@ function navigatePeriod(evt, direction) {
exitDetailView();
}
- animateSwipe(direction, () => {
+ Animate.animateSwipe(direction, () => {
currentOffset += direction * step;
updateCalendar();
});
}
-function animateSwipe(direction, onDone) {
- const grid = document.getElementById('eonstripGrid');
- if (!grid) { onDone(); return; }
-
- // Ensure a clean starting state when the grid was previously hidden
- grid.style.transition = 'none';
- grid.style.transform = 'translateX(0)';
- void grid.offsetWidth; // force reflow
-
- // slide out
- grid.style.transition = 'transform 0.3s ease';
- grid.style.transform = `translateX(${direction > 0 ? '-100%' : '100%'})`;
-
- function afterOut() {
- grid.removeEventListener('transitionend', afterOut);
- // prepare new position off-screen on the other side
- grid.style.transition = 'none';
- grid.style.transform = `translateX(${direction > 0 ? '100%' : '-100%'})`;
- onDone();
- // force reflow to apply position instantly
- void grid.offsetWidth;
- // slide in with transition
- grid.style.transition = 'transform 0.3s ease';
- grid.style.transform = 'translateX(0)';
- }
-
- grid.addEventListener('transitionend', afterOut);
-}
-
-function animateDetailSwipe(direction, onDone) {
- const tl = document.getElementById('detailTimeline');
- if (!tl) { onDone(); return; }
-
- tl.style.transition = 'none';
- tl.style.transform = 'translateX(0)';
- void tl.offsetWidth;
-
- tl.style.transition = 'transform 0.3s ease';
- tl.style.transform = `translateX(${direction > 0 ? '-100%' : '100%'})`;
-
- function afterOut() {
- tl.removeEventListener('transitionend', afterOut);
- tl.style.transition = 'none';
- tl.style.transform = `translateX(${direction > 0 ? '100%' : '-100%'})`;
- onDone();
- void tl.offsetWidth;
- tl.style.transition = 'transform 0.3s ease';
- tl.style.transform = 'translateX(0)';
- }
-
- tl.addEventListener('transitionend', afterOut);
-}
-
function goToNow() {
manualMode = false;
manualCobiets = 0;
diff --git a/utils.js b/utils.js
new file mode 100644
index 0000000..a4b070c
--- /dev/null
+++ b/utils.js
@@ -0,0 +1,92 @@
+(function(){
+ function parseColor(hex) {
+ if (!hex) return [255, 255, 255];
+ let c = hex.replace('#', '');
+ if (c.length === 3) c = c.split('').map(x => x + x).join('');
+ const num = parseInt(c, 16);
+ return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
+ }
+
+ const toHex = v => v.toString(16).padStart(2, '0');
+
+ function hexToRgba(hex, a = 1) {
+ const [r, g, b] = parseColor(hex);
+ return `rgba(${r},${g},${b},${a})`;
+ }
+
+ function getContrastColor(hex) {
+ const [r, g, b] = parseColor(hex);
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
+ return yiq >= 128 ? '#000' : '#fff';
+ }
+
+ function lightenColor(hex, p) {
+ const [r, g, b] = parseColor(hex).map(v =>
+ Math.min(255, Math.round(v + (255 - v) * p))
+ );
+ return '#' + [r, g, b].map(toHex).join('');
+ }
+
+ function getHumanDiff(d1, d2) {
+ let start = d1 < d2 ? d1 : d2;
+ let end = d1 < d2 ? d2 : d1;
+
+ let years = end.getUTCFullYear() - start.getUTCFullYear();
+ let months = end.getUTCMonth() - start.getUTCMonth();
+ let days = end.getUTCDate() - start.getUTCDate();
+
+ if (days < 0) {
+ months--;
+ let prevMonthDays = new Date(Date.UTC(
+ end.getUTCFullYear(),
+ end.getUTCMonth(), 0
+ )).getUTCDate();
+ days += prevMonthDays;
+ }
+ if (months < 0) {
+ years--;
+ months += 12;
+ }
+
+ let aligned = new Date(Date.UTC(
+ start.getUTCFullYear() + years,
+ start.getUTCMonth() + months,
+ start.getUTCDate() + days,
+ start.getUTCHours(),
+ start.getUTCMinutes(),
+ start.getUTCSeconds()
+ ));
+ let diffMs = end.getTime() - aligned.getTime();
+
+ if (diffMs < 0) {
+ diffMs += 24 * 3600e3;
+ if (days > 0) {
+ days--;
+ } else {
+ months--;
+ if (months < 0) {
+ years--;
+ months += 12;
+ }
+ days = new Date(Date.UTC(
+ end.getUTCFullYear(),
+ end.getUTCMonth(), 0
+ )).getUTCDate();
+ }
+ }
+
+ let hours = Math.floor(diffMs / 3600e3); diffMs -= hours * 3600e3;
+ let minutes = Math.floor(diffMs / 60e3); diffMs -= minutes * 60e3;
+ let seconds = Math.floor(diffMs / 1e3);
+
+ return { years, months, days, hours, minutes, seconds };
+ }
+
+ window.Utils = {
+ parseColor,
+ hexToRgba,
+ getContrastColor,
+ lightenColor,
+ getHumanDiff
+ };
+})();