@jjlmoya/utils-chrono 1.11.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +6 -0
- package/src/entries.ts +10 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/gmt-world-timer/bibliography.astro +11 -0
- package/src/tool/gmt-world-timer/bibliography.ts +7 -0
- package/src/tool/gmt-world-timer/client.ts +250 -0
- package/src/tool/gmt-world-timer/component.astro +13 -0
- package/src/tool/gmt-world-timer/components/GmtPanel.astro +18 -0
- package/src/tool/gmt-world-timer/entry.ts +34 -0
- package/src/tool/gmt-world-timer/gmt-world-timer.css +239 -0
- package/src/tool/gmt-world-timer/helpers.ts +28 -0
- package/src/tool/gmt-world-timer/i18n/de.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/en.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/es.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/fr.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/id.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/it.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/ja.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/ko.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/nl.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/pl.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/pt.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/ru.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/sv.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/tr.ts +72 -0
- package/src/tool/gmt-world-timer/i18n/zh.ts +72 -0
- package/src/tool/gmt-world-timer/index.ts +11 -0
- package/src/tool/gmt-world-timer/seo.astro +11 -0
- package/src/tool/perpetual-calendar/bibliography.astro +16 -0
- package/src/tool/perpetual-calendar/bibliography.ts +16 -0
- package/src/tool/perpetual-calendar/calendar.ts +24 -0
- package/src/tool/perpetual-calendar/client.ts +98 -0
- package/src/tool/perpetual-calendar/component.astro +17 -0
- package/src/tool/perpetual-calendar/components/CalendarPanel.astro +49 -0
- package/src/tool/perpetual-calendar/dial.ts +176 -0
- package/src/tool/perpetual-calendar/entry.ts +48 -0
- package/src/tool/perpetual-calendar/helpers.ts +49 -0
- package/src/tool/perpetual-calendar/i18n/de.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/en.ts +102 -0
- package/src/tool/perpetual-calendar/i18n/es.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/fr.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/id.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/it.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/ja.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/ko.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/nl.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/pl.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/pt.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/ru.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/sv.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/tr.ts +85 -0
- package/src/tool/perpetual-calendar/i18n/zh.ts +85 -0
- package/src/tool/perpetual-calendar/index.ts +11 -0
- package/src/tool/perpetual-calendar/perpetual-calendar.css +181 -0
- package/src/tool/perpetual-calendar/seo.astro +16 -0
- package/src/tool/perpetual-calendar/state.ts +26 -0
- package/src/tool/tourbillon-visualizer/bibliography.astro +11 -0
- package/src/tool/tourbillon-visualizer/bibliography.ts +7 -0
- package/src/tool/tourbillon-visualizer/client.ts +122 -0
- package/src/tool/tourbillon-visualizer/component.astro +126 -0
- package/src/tool/tourbillon-visualizer/components/TourbillonPanel.astro +66 -0
- package/src/tool/tourbillon-visualizer/entry.ts +51 -0
- package/src/tool/tourbillon-visualizer/helpers.ts +35 -0
- package/src/tool/tourbillon-visualizer/i18n/de.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/en.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/es.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/fr.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/id.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/it.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/ja.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/ko.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/nl.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/pl.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/pt.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/ru.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/sv.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/tr.ts +96 -0
- package/src/tool/tourbillon-visualizer/i18n/zh.ts +96 -0
- package/src/tool/tourbillon-visualizer/index.ts +11 -0
- package/src/tool/tourbillon-visualizer/renderer/base.ts +78 -0
- package/src/tool/tourbillon-visualizer/renderer/cage.ts +115 -0
- package/src/tool/tourbillon-visualizer/renderer/esc.ts +160 -0
- package/src/tool/tourbillon-visualizer/seo.astro +11 -0
- package/src/tool/tourbillon-visualizer/state.ts +21 -0
- package/src/tool/tourbillon-visualizer/tourbillon.ts +9 -0
- package/src/tools.ts +6 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { strapLengthCalculator } from '../tool/strap-length-calculator/entry';
|
|
|
16
16
|
import { telemeterCalculator } from '../tool/telemeter-calculator/entry';
|
|
17
17
|
import { siderealTimeTracker } from '../tool/sidereal-time-tracker/entry';
|
|
18
18
|
import { gearTrainExplorer } from '../tool/gear-train-explorer/entry';
|
|
19
|
+
import { perpetualCalendar } from '../tool/perpetual-calendar/entry';
|
|
20
|
+
import { tourbillonVisualizer } from '../tool/tourbillon-visualizer/entry';
|
|
21
|
+
import { gmtWorldTimer } from '../tool/gmt-world-timer/entry';
|
|
19
22
|
|
|
20
23
|
export const chronoCategory: ChronoCategoryEntry = {
|
|
21
24
|
icon: 'mdi:clock-outline',
|
|
@@ -37,6 +40,9 @@ export const chronoCategory: ChronoCategoryEntry = {
|
|
|
37
40
|
telemeterCalculator,
|
|
38
41
|
siderealTimeTracker,
|
|
39
42
|
gearTrainExplorer,
|
|
43
|
+
perpetualCalendar,
|
|
44
|
+
tourbillonVisualizer,
|
|
45
|
+
gmtWorldTimer,
|
|
40
46
|
],
|
|
41
47
|
i18n: {
|
|
42
48
|
de: () => import('./i18n/de').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -34,6 +34,12 @@ export { siderealTimeTracker } from './tool/sidereal-time-tracker/entry';
|
|
|
34
34
|
export type { SiderealTimeTrackerUI, SiderealTimeTrackerLocaleContent } from './tool/sidereal-time-tracker/entry';
|
|
35
35
|
export { gearTrainExplorer } from './tool/gear-train-explorer/entry';
|
|
36
36
|
export type { GearTrainExplorerUI, GearTrainExplorerLocaleContent } from './tool/gear-train-explorer/entry';
|
|
37
|
+
export { perpetualCalendar } from './tool/perpetual-calendar/entry';
|
|
38
|
+
export type { PerpetualCalendarUI, PerpetualCalendarLocaleContent } from './tool/perpetual-calendar/entry';
|
|
39
|
+
export { tourbillonVisualizer } from './tool/tourbillon-visualizer/entry';
|
|
40
|
+
export type { TourbillonUI, TourbillonLocaleContent } from './tool/tourbillon-visualizer/entry';
|
|
41
|
+
export { gmtWorldTimer } from './tool/gmt-world-timer/entry';
|
|
42
|
+
export type { GMTWorldTimerUI, GMTWorldTimerLocaleContent } from './tool/gmt-world-timer/entry';
|
|
37
43
|
export { chronoCategory } from './category';
|
|
38
44
|
|
|
39
45
|
import { watchAccuracyTracker } from './tool/watch-accuracy-tracker/entry';
|
|
@@ -54,6 +60,9 @@ import { strapLengthCalculator } from './tool/strap-length-calculator/entry';
|
|
|
54
60
|
import { telemeterCalculator } from './tool/telemeter-calculator/entry';
|
|
55
61
|
import { siderealTimeTracker } from './tool/sidereal-time-tracker/entry';
|
|
56
62
|
import { gearTrainExplorer } from './tool/gear-train-explorer/entry';
|
|
63
|
+
import { perpetualCalendar } from './tool/perpetual-calendar/entry';
|
|
64
|
+
import { tourbillonVisualizer } from './tool/tourbillon-visualizer/entry';
|
|
65
|
+
import { gmtWorldTimer } from './tool/gmt-world-timer/entry';
|
|
57
66
|
|
|
58
|
-
export const ALL_ENTRIES = [watchAccuracyTracker, wristPresenceCalculator, demagnetizingTimer, watchSavingsPlanner, crownReferenceGuide, powerReserveEstimator, beatRateConverter, waterResistanceConverter, strapTaperCalculator, watchSizeComparator, lumeColorSimulator, moonPhaseVisualizer, tachymeterCalculator, serviceIntervalTracker, strapLengthCalculator, telemeterCalculator, siderealTimeTracker, gearTrainExplorer];
|
|
67
|
+
export const ALL_ENTRIES = [watchAccuracyTracker, wristPresenceCalculator, demagnetizingTimer, watchSavingsPlanner, crownReferenceGuide, powerReserveEstimator, beatRateConverter, waterResistanceConverter, strapTaperCalculator, watchSizeComparator, lumeColorSimulator, moonPhaseVisualizer, tachymeterCalculator, serviceIntervalTracker, strapLengthCalculator, telemeterCalculator, siderealTimeTracker, gearTrainExplorer, perpetualCalendar, tourbillonVisualizer, gmtWorldTimer];
|
|
59
68
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { gmtWorldTimer } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
interface Props { locale?: KnownLocale; }
|
|
6
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
7
|
+
const loader = gmtWorldTimer.i18n[locale] || gmtWorldTimer.i18n.en;
|
|
8
|
+
const content = await loader?.();
|
|
9
|
+
if (!content) return null;
|
|
10
|
+
---
|
|
11
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{ name: 'Time Zone Map - TimeAndDate', url: 'https://www.timeanddate.com/time/map/' },
|
|
5
|
+
{ name: 'IANA Time Zone Database', url: 'https://www.iana.org/time-zones' },
|
|
6
|
+
{ name: 'UTC - Coordinated Universal Time', url: 'https://www.timeanddate.com/time/utc.html' },
|
|
7
|
+
];
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
const STORAGE_KEY = 'gmt-world-timer-zones';
|
|
2
|
+
|
|
3
|
+
interface ZoneItem {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ZONES: ZoneItem[] = [
|
|
9
|
+
{ id: 'America/New_York', label: 'New York' },
|
|
10
|
+
{ id: 'Europe/London', label: 'London' },
|
|
11
|
+
{ id: 'Europe/Paris', label: 'Paris' },
|
|
12
|
+
{ id: 'Asia/Tokyo', label: 'Tokyo' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const ALL_ZONES: ZoneItem[] = [
|
|
16
|
+
{ id: 'Pacific/Midway', label: 'Midway' },
|
|
17
|
+
{ id: 'Pacific/Honolulu', label: 'Honolulu' },
|
|
18
|
+
{ id: 'America/Anchorage', label: 'Anchorage' },
|
|
19
|
+
{ id: 'America/Los_Angeles', label: 'Los Angeles' },
|
|
20
|
+
{ id: 'America/Denver', label: 'Denver' },
|
|
21
|
+
{ id: 'America/Chicago', label: 'Chicago' },
|
|
22
|
+
{ id: 'America/New_York', label: 'New York' },
|
|
23
|
+
{ id: 'America/Halifax', label: 'Halifax' },
|
|
24
|
+
{ id: 'America/Argentina/Buenos_Aires', label: 'Buenos Aires' },
|
|
25
|
+
{ id: 'Atlantic/Azores', label: 'Azores' },
|
|
26
|
+
{ id: 'Europe/London', label: 'London' },
|
|
27
|
+
{ id: 'Europe/Paris', label: 'Paris' },
|
|
28
|
+
{ id: 'Europe/Berlin', label: 'Berlin' },
|
|
29
|
+
{ id: 'Europe/Madrid', label: 'Madrid' },
|
|
30
|
+
{ id: 'Europe/Rome', label: 'Rome' },
|
|
31
|
+
{ id: 'Europe/Athens', label: 'Athens' },
|
|
32
|
+
{ id: 'Europe/Moscow', label: 'Moscow' },
|
|
33
|
+
{ id: 'Asia/Dubai', label: 'Dubai' },
|
|
34
|
+
{ id: 'Asia/Karachi', label: 'Karachi' },
|
|
35
|
+
{ id: 'Asia/Kolkata', label: 'Kolkata' },
|
|
36
|
+
{ id: 'Asia/Dhaka', label: 'Dhaka' },
|
|
37
|
+
{ id: 'Asia/Bangkok', label: 'Bangkok' },
|
|
38
|
+
{ id: 'Asia/Shanghai', label: 'Shanghai' },
|
|
39
|
+
{ id: 'Asia/Singapore', label: 'Singapore' },
|
|
40
|
+
{ id: 'Asia/Tokyo', label: 'Tokyo' },
|
|
41
|
+
{ id: 'Asia/Seoul', label: 'Seoul' },
|
|
42
|
+
{ id: 'Australia/Sydney', label: 'Sydney' },
|
|
43
|
+
{ id: 'Pacific/Noumea', label: 'Noumea' },
|
|
44
|
+
{ id: 'Pacific/Auckland', label: 'Auckland' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
let zones: ZoneItem[] = [];
|
|
48
|
+
|
|
49
|
+
function loadZones(): void {
|
|
50
|
+
try {
|
|
51
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
52
|
+
if (raw) {
|
|
53
|
+
const parsed: ZoneItem[] = JSON.parse(raw);
|
|
54
|
+
zones = parsed.filter((z) => ALL_ZONES.some((a) => a.id === z.id));
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
zones = [];
|
|
58
|
+
}
|
|
59
|
+
if (zones.length === 0) zones = [...DEFAULT_ZONES];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function saveZones(): void {
|
|
63
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(zones));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getOffsetLabel(tz: string): string {
|
|
67
|
+
try {
|
|
68
|
+
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'shortOffset' }).formatToParts();
|
|
69
|
+
const off = parts.find((p) => p.type === 'timeZoneName');
|
|
70
|
+
return off ? off.value : '';
|
|
71
|
+
} catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getZoneDate(tz: string): Date {
|
|
77
|
+
const now = new Date();
|
|
78
|
+
const localMs = now.getMilliseconds();
|
|
79
|
+
const localOff = now.getTimezoneOffset();
|
|
80
|
+
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(now);
|
|
81
|
+
const offPart = parts.find((p) => p.type === 'timeZoneName');
|
|
82
|
+
if (!offPart || !offPart.value) return now;
|
|
83
|
+
const m = offPart.value.match(/GMT([+-]\d+)(?::(\d+))?/);
|
|
84
|
+
if (!m) return now;
|
|
85
|
+
const sign = m[1].startsWith('+') ? 1 : -1;
|
|
86
|
+
const h = parseInt(m[1].slice(1));
|
|
87
|
+
const min = m[2] ? parseInt(m[2]) : 0;
|
|
88
|
+
const tzOff = sign * (h * 60 + min);
|
|
89
|
+
const utcMs = now.getTime() + localOff * 60000;
|
|
90
|
+
return new Date(utcMs + tzOff * 60000 + localMs * 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function makeClockSVG(tz: string): string {
|
|
94
|
+
const d = getZoneDate(tz);
|
|
95
|
+
const h = d.getHours() % 12;
|
|
96
|
+
const m = d.getMinutes();
|
|
97
|
+
const s = d.getSeconds();
|
|
98
|
+
const ms = d.getMilliseconds();
|
|
99
|
+
const hA = (h / 12) * 360 + (m / 60) * 30;
|
|
100
|
+
const mA = (m / 60) * 360 + (s / 60) * 6;
|
|
101
|
+
const sA = (s / 60) * 360 + (ms / 1000) * 6;
|
|
102
|
+
const tick = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((a) => {
|
|
103
|
+
const rad = (a - 90) * (Math.PI / 180);
|
|
104
|
+
const r1 = a % 90 === 0 ? 34 : 37;
|
|
105
|
+
const x1 = 50 + r1 * Math.cos(rad);
|
|
106
|
+
const y1 = 50 + r1 * Math.sin(rad);
|
|
107
|
+
const x2 = 50 + 43 * Math.cos(rad);
|
|
108
|
+
const y2 = 50 + 43 * Math.sin(rad);
|
|
109
|
+
const w = a % 90 === 0 ? '1.8' : '1';
|
|
110
|
+
return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="var(--clock-marks)" stroke-width="${w}" stroke-linecap="round"/>`;
|
|
111
|
+
}).join('');
|
|
112
|
+
return `<svg class="gmt-card-clock" viewBox="0 0 100 100">
|
|
113
|
+
<circle cx="50" cy="50" r="46" fill="var(--clock-face)" stroke="var(--clock-bezel)" stroke-width="1.6"/>
|
|
114
|
+
${tick}
|
|
115
|
+
<line x1="50" y1="50" x2="50" y2="27" stroke="var(--clock-hour)" stroke-width="3" stroke-linecap="round" transform="rotate(${hA.toFixed(1)},50,50)"/>
|
|
116
|
+
<line x1="50" y1="50" x2="50" y2="14" stroke="var(--clock-min)" stroke-width="1.8" stroke-linecap="round" transform="rotate(${mA.toFixed(1)},50,50)"/>
|
|
117
|
+
<line x1="50" y1="55" x2="50" y2="8" stroke="var(--clock-sec)" stroke-width="1" stroke-linecap="round" transform="rotate(${sA.toFixed(1)},50,50)"/>
|
|
118
|
+
<circle cx="50" cy="50" r="3" fill="var(--clock-hour)"/>
|
|
119
|
+
</svg>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getTimeStr(tz: string): string {
|
|
123
|
+
try {
|
|
124
|
+
return new Intl.DateTimeFormat('en', {
|
|
125
|
+
timeZone: tz, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
|
126
|
+
}).format(new Date());
|
|
127
|
+
} catch {
|
|
128
|
+
return '--:--:--';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderCard(z: ZoneItem): string {
|
|
133
|
+
const id = z.id.replace(/\//g, '-');
|
|
134
|
+
return `<div class="gmt-card" data-zone="${z.id}" id="card-${id}">
|
|
135
|
+
<button class="gmt-card-remove" data-remove="${z.id}" aria-label="Remove">✕</button>
|
|
136
|
+
<div class="gmt-card-clock-w" id="clock-${id}">${makeClockSVG(z.id)}</div>
|
|
137
|
+
<div class="gmt-card-info">
|
|
138
|
+
<div class="gmt-card-city">${z.label}</div>
|
|
139
|
+
<div class="gmt-card-time" id="time-${id}">${getTimeStr(z.id)}</div>
|
|
140
|
+
<div class="gmt-card-offset" id="off-${id}">${getOffsetLabel(z.id)}</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderGrid(): void {
|
|
146
|
+
const grid = document.getElementById('gmt-grid')!;
|
|
147
|
+
grid.innerHTML = zones.map(renderCard).join('');
|
|
148
|
+
grid.querySelectorAll('[data-remove]').forEach((btn) => {
|
|
149
|
+
btn.addEventListener('click', () => {
|
|
150
|
+
const id = (btn as HTMLElement).dataset.remove!;
|
|
151
|
+
removeZone(id);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function updateCards(): void {
|
|
157
|
+
for (const z of zones) {
|
|
158
|
+
const id = z.id.replace(/\//g, '-');
|
|
159
|
+
const clockEl = document.getElementById(`clock-${id}`);
|
|
160
|
+
const timeEl = document.getElementById(`time-${id}`);
|
|
161
|
+
if (clockEl) clockEl.innerHTML = makeClockSVG(z.id);
|
|
162
|
+
if (timeEl) timeEl.textContent = getTimeStr(z.id);
|
|
163
|
+
}
|
|
164
|
+
requestAnimationFrame(updateCards);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function addZone(z: ZoneItem): void {
|
|
168
|
+
if (zones.some((existing) => existing.id === z.id)) return;
|
|
169
|
+
zones.push(z);
|
|
170
|
+
saveZones();
|
|
171
|
+
renderGrid();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function removeZone(id: string): void {
|
|
175
|
+
zones = zones.filter((z) => z.id !== id);
|
|
176
|
+
saveZones();
|
|
177
|
+
renderGrid();
|
|
178
|
+
if (zones.length === 0) {
|
|
179
|
+
zones = [...DEFAULT_ZONES];
|
|
180
|
+
saveZones();
|
|
181
|
+
renderGrid();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function filterZones(q: string): ZoneItem[] {
|
|
186
|
+
return ALL_ZONES.filter((z) => {
|
|
187
|
+
const l = z.label.toLowerCase();
|
|
188
|
+
const i = z.id.toLowerCase();
|
|
189
|
+
return l.includes(q) || i.includes(q);
|
|
190
|
+
}).slice(0, 10);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderDropdownItems(dd: HTMLElement, matches: ZoneItem[]): void {
|
|
194
|
+
dd.innerHTML = matches
|
|
195
|
+
.map((z) => {
|
|
196
|
+
const taken = zones.some((existing) => existing.id === z.id);
|
|
197
|
+
return `<div class="gmt-dd-item ${taken ? 'taken' : ''}" data-add="${z.id}">
|
|
198
|
+
<span>${z.label}</span>
|
|
199
|
+
<span class="gmt-dd-offset">${getOffsetLabel(z.id)}</span>
|
|
200
|
+
</div>`;
|
|
201
|
+
})
|
|
202
|
+
.join('');
|
|
203
|
+
dd.querySelectorAll('[data-add]').forEach((el) => {
|
|
204
|
+
el.addEventListener('click', () => {
|
|
205
|
+
const zid = (el as HTMLElement).dataset.add!;
|
|
206
|
+
const zone = ALL_ZONES.find((z) => z.id === zid);
|
|
207
|
+
if (zone) addZone(zone);
|
|
208
|
+
(document.getElementById('gmt-search') as HTMLInputElement).value = '';
|
|
209
|
+
dd.classList.remove('open');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
dd.classList.add('open');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function renderDropdown(query: string): void {
|
|
216
|
+
const dd = document.getElementById('gmt-dropdown')!;
|
|
217
|
+
const q = query.toLowerCase().trim();
|
|
218
|
+
if (!q) { dd.classList.remove('open'); return; }
|
|
219
|
+
const matches = filterZones(q);
|
|
220
|
+
if (matches.length === 0) {
|
|
221
|
+
dd.innerHTML = '<div class="gmt-dd-empty">No cities found</div>';
|
|
222
|
+
dd.classList.add('open');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
renderDropdownItems(dd, matches);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function init(): void {
|
|
229
|
+
loadZones();
|
|
230
|
+
renderGrid();
|
|
231
|
+
requestAnimationFrame(updateCards);
|
|
232
|
+
|
|
233
|
+
const search = document.getElementById('gmt-search') as HTMLInputElement;
|
|
234
|
+
const dd = document.getElementById('gmt-dropdown')!;
|
|
235
|
+
|
|
236
|
+
search.addEventListener('input', () => renderDropdown(search.value));
|
|
237
|
+
search.addEventListener('focus', () => {
|
|
238
|
+
if (search.value.trim()) renderDropdown(search.value);
|
|
239
|
+
});
|
|
240
|
+
document.addEventListener('click', (e) => {
|
|
241
|
+
if (!(e.target as HTMLElement).closest('.gmt-search-w')) {
|
|
242
|
+
dd.classList.remove('open');
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
search.addEventListener('keydown', (e) => {
|
|
246
|
+
if (e.key === 'Escape') dd.classList.remove('open');
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
init();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import GmtPanel from './components/GmtPanel.astro';
|
|
3
|
+
interface Props { ui: Record<string, string>; }
|
|
4
|
+
const { ui } = Astro.props;
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<link href="./gmt-world-timer.css" rel="stylesheet" />
|
|
8
|
+
|
|
9
|
+
<div class="tool-main-card" data-ui={JSON.stringify(ui)}>
|
|
10
|
+
<GmtPanel labels={ui} />
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<script src="./client.ts"></script>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props { labels: Record<string, string>; }
|
|
3
|
+
const { labels } = Astro.props;
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<div class="gmt-tool">
|
|
7
|
+
<div class="gmt-search-w">
|
|
8
|
+
<input
|
|
9
|
+
type="text"
|
|
10
|
+
class="gmt-search"
|
|
11
|
+
id="gmt-search"
|
|
12
|
+
placeholder={labels.searchPlaceholder || 'Search city or time zone...'}
|
|
13
|
+
autocomplete="off"
|
|
14
|
+
/>
|
|
15
|
+
<div class="gmt-dropdown" id="gmt-dropdown"></div>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="gmt-grid" id="gmt-grid"></div>
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ChronoToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
|
|
3
|
+
export type GMTWorldTimerUI = {
|
|
4
|
+
title: string;
|
|
5
|
+
searchPlaceholder: string;
|
|
6
|
+
addLabel: string;
|
|
7
|
+
removeLabel: string;
|
|
8
|
+
noResults: string;
|
|
9
|
+
yourZones: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type GMTWorldTimerLocaleContent = ToolLocaleContent<GMTWorldTimerUI>;
|
|
13
|
+
|
|
14
|
+
export const gmtWorldTimer: ChronoToolEntry<GMTWorldTimerUI> = {
|
|
15
|
+
id: 'gmt-world-timer',
|
|
16
|
+
icons: { bg: 'mdi:web', fg: 'mdi:clock-time-eight' },
|
|
17
|
+
i18n: {
|
|
18
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
19
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
20
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
21
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
22
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
23
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
24
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
25
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
26
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
27
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
28
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
29
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
30
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
31
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
32
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
.theme-dark .gmt-tool {
|
|
2
|
+
--clock-face: color-mix(in srgb, var(--bg-surface) 60%, #000);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.gmt-tool {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
gap: 1.5rem;
|
|
9
|
+
|
|
10
|
+
--clock-face: var(--bg-surface);
|
|
11
|
+
--clock-bezel: color-mix(in srgb, var(--accent) 35%, transparent);
|
|
12
|
+
--clock-marks: var(--text-muted);
|
|
13
|
+
--clock-hour: var(--accent);
|
|
14
|
+
--clock-min: var(--text-base);
|
|
15
|
+
--clock-sec: var(--color-error);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.gmt-search-w {
|
|
19
|
+
position: relative;
|
|
20
|
+
max-width: 420px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.gmt-search {
|
|
24
|
+
width: 100%;
|
|
25
|
+
padding: 0.8rem 1.1rem;
|
|
26
|
+
border: 1.5px solid var(--border-base);
|
|
27
|
+
border-radius: 0.85rem;
|
|
28
|
+
background: var(--bg-surface);
|
|
29
|
+
color: var(--text-base);
|
|
30
|
+
font-size: 0.9rem;
|
|
31
|
+
outline: none;
|
|
32
|
+
transition: border-color 0.15s ease;
|
|
33
|
+
box-sizing: border-box;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.gmt-search:focus {
|
|
37
|
+
border-color: var(--accent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.gmt-search::placeholder {
|
|
41
|
+
color: var(--text-base);
|
|
42
|
+
opacity: 0.3;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.gmt-dropdown {
|
|
46
|
+
position: absolute;
|
|
47
|
+
top: 100%;
|
|
48
|
+
left: 0;
|
|
49
|
+
right: 0;
|
|
50
|
+
z-index: 50;
|
|
51
|
+
margin-top: 0.3rem;
|
|
52
|
+
border: 1px solid var(--border-base);
|
|
53
|
+
border-radius: 0.85rem;
|
|
54
|
+
background: var(--bg-surface);
|
|
55
|
+
max-height: 280px;
|
|
56
|
+
overflow-y: auto;
|
|
57
|
+
display: none;
|
|
58
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.gmt-dropdown.open {
|
|
62
|
+
display: block;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.gmt-dd-item {
|
|
66
|
+
padding: 0.65rem 0.9rem;
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: space-between;
|
|
71
|
+
font-size: 0.85rem;
|
|
72
|
+
color: var(--text-base);
|
|
73
|
+
transition: background 0.1s ease;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.gmt-dd-item:first-child {
|
|
77
|
+
border-radius: 0.85rem 0.85rem 0 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.gmt-dd-item:last-child {
|
|
81
|
+
border-radius: 0 0 0.85rem 0.85rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.gmt-dd-item:hover {
|
|
85
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.gmt-dd-item.taken {
|
|
89
|
+
opacity: 0.3;
|
|
90
|
+
pointer-events: none;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.gmt-dd-offset {
|
|
94
|
+
font-size: 0.75rem;
|
|
95
|
+
opacity: 0.5;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.gmt-dd-empty {
|
|
99
|
+
padding: 1rem;
|
|
100
|
+
text-align: center;
|
|
101
|
+
font-size: 0.8rem;
|
|
102
|
+
color: var(--text-base);
|
|
103
|
+
opacity: 0.4;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.gmt-grid {
|
|
107
|
+
display: grid;
|
|
108
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
109
|
+
gap: 1rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.gmt-card {
|
|
113
|
+
position: relative;
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 0.75rem;
|
|
118
|
+
padding: 1.75rem 1rem 1.15rem;
|
|
119
|
+
background: var(--bg-surface);
|
|
120
|
+
border: 1px solid var(--border-base);
|
|
121
|
+
border-radius: 1.25rem;
|
|
122
|
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
123
|
+
min-height: 300px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.gmt-card:hover {
|
|
127
|
+
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-base));
|
|
128
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.gmt-card-remove {
|
|
132
|
+
position: absolute;
|
|
133
|
+
top: 0.6rem;
|
|
134
|
+
right: 0.6rem;
|
|
135
|
+
width: 1.6rem;
|
|
136
|
+
height: 1.6rem;
|
|
137
|
+
border: none;
|
|
138
|
+
border-radius: 50%;
|
|
139
|
+
background: color-mix(in srgb, var(--text-base) 8%, transparent);
|
|
140
|
+
color: var(--text-base);
|
|
141
|
+
font-size: 0.85rem;
|
|
142
|
+
line-height: 1;
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
opacity: 0;
|
|
148
|
+
transition: opacity 0.15s ease;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.gmt-card:hover .gmt-card-remove {
|
|
152
|
+
opacity: 0.4;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.gmt-card-remove:hover {
|
|
156
|
+
opacity: 1;
|
|
157
|
+
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
|
158
|
+
color: var(--accent);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.gmt-card-clock-w {
|
|
162
|
+
width: 160px;
|
|
163
|
+
height: 160px;
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
justify-content: center;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.gmt-card-clock {
|
|
170
|
+
width: 100%;
|
|
171
|
+
height: 100%;
|
|
172
|
+
display: block;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.gmt-card-info {
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
align-items: center;
|
|
179
|
+
gap: 0.2rem;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.gmt-card-city {
|
|
183
|
+
font-size: 0.8rem;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
text-transform: uppercase;
|
|
186
|
+
letter-spacing: 0.1em;
|
|
187
|
+
color: var(--text-base);
|
|
188
|
+
opacity: 0.5;
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
text-overflow: ellipsis;
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
max-width: 180px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.gmt-card-time {
|
|
196
|
+
font-size: 1rem;
|
|
197
|
+
font-weight: 500;
|
|
198
|
+
line-height: 1.4;
|
|
199
|
+
color: var(--text-base);
|
|
200
|
+
letter-spacing: 0.05em;
|
|
201
|
+
font-variant-numeric: tabular-nums;
|
|
202
|
+
opacity: 0.8;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.gmt-card-offset {
|
|
206
|
+
font-size: 0.7rem;
|
|
207
|
+
font-weight: 500;
|
|
208
|
+
color: var(--text-base);
|
|
209
|
+
opacity: 0.35;
|
|
210
|
+
letter-spacing: 0.03em;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@media (max-width: 640px) {
|
|
214
|
+
.gmt-grid {
|
|
215
|
+
grid-template-columns: repeat(2, 1fr);
|
|
216
|
+
gap: 0.6rem;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.gmt-card {
|
|
220
|
+
padding: 1.2rem 0.6rem 0.85rem;
|
|
221
|
+
min-height: 220px;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.gmt-card-clock-w {
|
|
225
|
+
width: 120px;
|
|
226
|
+
height: 120px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.gmt-card-time {
|
|
230
|
+
font-size: 0.85rem;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@media (max-width: 400px) {
|
|
235
|
+
.gmt-grid {
|
|
236
|
+
grid-template-columns: 1fr;
|
|
237
|
+
gap: 0.6rem;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { WithContext, Thing } from 'schema-dts';
|
|
2
|
+
import type { FAQItem, HowToStep } from '../../types';
|
|
3
|
+
|
|
4
|
+
function buildFAQ(faq: FAQItem[]): WithContext<Thing> {
|
|
5
|
+
return {
|
|
6
|
+
'@context': 'https://schema.org', '@type': 'FAQPage',
|
|
7
|
+
'mainEntity': faq.map((f) => ({ '@type': 'Question', 'name': f.question, 'acceptedAnswer': { '@type': 'Answer', 'text': f.answer } })),
|
|
8
|
+
} as unknown as WithContext<Thing>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildApp(title: string): WithContext<Thing> {
|
|
12
|
+
return {
|
|
13
|
+
'@context': 'https://schema.org', '@type': 'SoftwareApplication', 'name': title,
|
|
14
|
+
'operatingSystem': 'All', 'applicationCategory': 'UtilitiesApplication',
|
|
15
|
+
'browserRequirements': 'Requires HTML5. Requires JavaScript.',
|
|
16
|
+
} as unknown as WithContext<Thing>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildHowTo(title: string, howTo: HowToStep[]): WithContext<Thing> {
|
|
20
|
+
return {
|
|
21
|
+
'@context': 'https://schema.org', '@type': 'HowTo', 'name': title,
|
|
22
|
+
'step': howTo.map((h) => ({ '@type': 'HowToStep', 'name': h.name, 'text': h.text })),
|
|
23
|
+
} as unknown as WithContext<Thing>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildSchemas(title: string, faq: FAQItem[], howTo: HowToStep[]): WithContext<Thing>[] {
|
|
27
|
+
return [buildFAQ(faq), buildApp(title), buildHowTo(title, howTo)];
|
|
28
|
+
}
|