@jjlmoya/utils-chrono 1.10.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 +8 -0
- package/src/entries.ts +13 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/gear-train-explorer/bibliography.astro +16 -0
- package/src/tool/gear-train-explorer/bibliography.ts +12 -0
- package/src/tool/gear-train-explorer/client.ts +146 -0
- package/src/tool/gear-train-explorer/component.astro +17 -0
- package/src/tool/gear-train-explorer/components/GearPanel.astro +102 -0
- package/src/tool/gear-train-explorer/entry.ts +53 -0
- package/src/tool/gear-train-explorer/gear-train-explorer.css +172 -0
- package/src/tool/gear-train-explorer/gears.ts +148 -0
- package/src/tool/gear-train-explorer/helpers.ts +49 -0
- package/src/tool/gear-train-explorer/i18n/de.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/en.ts +98 -0
- package/src/tool/gear-train-explorer/i18n/es.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/fr.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/id.ts +98 -0
- package/src/tool/gear-train-explorer/i18n/it.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/ja.ts +98 -0
- package/src/tool/gear-train-explorer/i18n/ko.ts +98 -0
- package/src/tool/gear-train-explorer/i18n/nl.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/pl.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/pt.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/ru.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/sv.ts +99 -0
- package/src/tool/gear-train-explorer/i18n/tr.ts +98 -0
- package/src/tool/gear-train-explorer/i18n/zh.ts +98 -0
- package/src/tool/gear-train-explorer/index.ts +11 -0
- package/src/tool/gear-train-explorer/movements.ts +61 -0
- package/src/tool/gear-train-explorer/scene.ts +120 -0
- package/src/tool/gear-train-explorer/seo.astro +16 -0
- package/src/tool/gear-train-explorer/state.ts +30 -0
- 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 +8 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { MovementDef } from './movements';
|
|
2
|
+
import { getCtx, getMov, getHovered, getFontFam, setMov, setHovered, detectTheme, c, W, H } from './state';
|
|
3
|
+
import { drawGear, drawPallet, drawBalance, drawConnection } from './gears';
|
|
4
|
+
|
|
5
|
+
export function drawBackground() {
|
|
6
|
+
const ctx = getCtx();
|
|
7
|
+
const bg0 = c('#1e1e3a', '#f5f0e8');
|
|
8
|
+
const bg1 = c('#16162e', '#eae4d8');
|
|
9
|
+
const bg2 = c('#0c0c18', '#ddd6c8');
|
|
10
|
+
const grad = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, 400);
|
|
11
|
+
grad.addColorStop(0, bg0);
|
|
12
|
+
grad.addColorStop(0.5, bg1);
|
|
13
|
+
grad.addColorStop(1, bg2);
|
|
14
|
+
ctx.fillStyle = grad;
|
|
15
|
+
ctx.fillRect(0, 0, W, H);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function drawTitleBar(label: string) {
|
|
19
|
+
const ctx = getCtx();
|
|
20
|
+
ctx.fillStyle = c('rgba(212,175,55,0.7)', 'rgba(120,80,10,0.85)');
|
|
21
|
+
ctx.font = '600 14px ' + getFontFam();
|
|
22
|
+
ctx.textAlign = 'left';
|
|
23
|
+
ctx.fillText(label + ' — Gear Train', 16, 24);
|
|
24
|
+
ctx.fillStyle = c('rgba(160,160,184,0.55)', 'rgba(60,50,30,0.7)');
|
|
25
|
+
ctx.font = '11px ' + getFontFam();
|
|
26
|
+
ctx.fillText('Mainspring to Balance Wheel', 16, 40);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function drawLabels() {
|
|
30
|
+
const ctx = getCtx();
|
|
31
|
+
const mov = getMov();
|
|
32
|
+
const hover = getHovered();
|
|
33
|
+
ctx.font = '10px ' + getFontFam();
|
|
34
|
+
ctx.textAlign = 'center';
|
|
35
|
+
for (let i = 0; i < mov.gears.length; i++) {
|
|
36
|
+
const g = mov.gears[i];
|
|
37
|
+
ctx.fillStyle = hover === i ? c('#ffd700', '#7a5a00') : c('rgba(200,200,220,0.8)', 'rgba(40,30,15,0.85)');
|
|
38
|
+
ctx.fillText(g.label, g.x, g.y + g.r + 14);
|
|
39
|
+
const rpmTxt = g.rpm < 1 ? (g.rpm * 60).toFixed(1) + '/h' : g.rpm.toFixed(1) + ' rpm';
|
|
40
|
+
ctx.fillStyle = hover === i ? c('rgba(255,215,0,0.6)', 'rgba(100,70,10,0.7)') : c('rgba(160,160,184,0.5)', 'rgba(60,50,30,0.65)');
|
|
41
|
+
ctx.font = '9px ' + getFontFam();
|
|
42
|
+
ctx.fillText(rpmTxt, g.x, g.y + g.r + 24);
|
|
43
|
+
ctx.font = '10px ' + getFontFam();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function drawExtraLabels() {
|
|
48
|
+
const ctx = getCtx();
|
|
49
|
+
const mov = getMov();
|
|
50
|
+
const p = mov.pallet;
|
|
51
|
+
const b = mov.balance;
|
|
52
|
+
ctx.font = '10px ' + getFontFam();
|
|
53
|
+
ctx.textAlign = 'center';
|
|
54
|
+
ctx.fillStyle = c('rgba(200,200,220,0.8)', 'rgba(40,30,15,0.85)');
|
|
55
|
+
ctx.fillText('Pallet Fork', p.x, p.y + 22);
|
|
56
|
+
ctx.fillStyle = c('rgba(160,160,184,0.5)', 'rgba(60,50,30,0.65)');
|
|
57
|
+
ctx.font = '9px ' + getFontFam();
|
|
58
|
+
ctx.fillText(p.bph + ' bph', p.x, p.y + 32);
|
|
59
|
+
ctx.font = '10px ' + getFontFam();
|
|
60
|
+
ctx.fillStyle = c('rgba(200,200,220,0.8)', 'rgba(40,30,15,0.85)');
|
|
61
|
+
ctx.fillText('Balance Wheel', b.x, b.y + b.r + 16);
|
|
62
|
+
ctx.fillStyle = c('rgba(160,160,184,0.5)', 'rgba(60,50,30,0.65)');
|
|
63
|
+
ctx.font = '9px ' + getFontFam();
|
|
64
|
+
ctx.fillText(b.hz + ' Hz / ' + b.vph + ' vph', b.x, b.y + b.r + 26);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function drawPowerFlowLine() {
|
|
68
|
+
const ctx = getCtx();
|
|
69
|
+
const mov = getMov();
|
|
70
|
+
const gears = mov.gears;
|
|
71
|
+
ctx.beginPath();
|
|
72
|
+
ctx.moveTo(gears[0].x + gears[0].r + 5, gears[0].y);
|
|
73
|
+
for (let i = 1; i < gears.length; i++) {
|
|
74
|
+
ctx.lineTo(gears[i].x - gears[i].r - 5, gears[i].y);
|
|
75
|
+
}
|
|
76
|
+
const eg = gears[gears.length - 1];
|
|
77
|
+
ctx.lineTo(eg.x + eg.r + 15, eg.y);
|
|
78
|
+
ctx.lineTo(mov.pallet.x - 15, mov.pallet.y);
|
|
79
|
+
ctx.lineTo(mov.pallet.x, mov.pallet.y);
|
|
80
|
+
ctx.strokeStyle = c('rgba(212,175,55,0.08)', 'rgba(139,105,20,0.12)');
|
|
81
|
+
ctx.lineWidth = 60; ctx.stroke();
|
|
82
|
+
ctx.strokeStyle = c('rgba(212,175,55,0.12)', 'rgba(139,105,20,0.15)');
|
|
83
|
+
ctx.lineWidth = 2;
|
|
84
|
+
ctx.setLineDash([4, 6]); ctx.stroke();
|
|
85
|
+
ctx.setLineDash([]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function drawConnections(mov: MovementDef, highlight: number | null) {
|
|
89
|
+
const gears = mov.gears;
|
|
90
|
+
for (let i = 0; i < gears.length; i++) {
|
|
91
|
+
if (i < gears.length - 1) {
|
|
92
|
+
const g1 = gears[i], g2 = gears[i + 1];
|
|
93
|
+
drawConnection({ x1: g1.x + g1.r * 0.7, y1: g1.y, x2: g2.x - g2.r * 0.7, y2: g2.y, active: highlight === i || highlight === i + 1 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const lg = gears[gears.length - 1];
|
|
97
|
+
drawConnection({ x1: lg.x + lg.r * 0.7, y1: lg.y, x2: mov.pallet.x - 10, y2: mov.pallet.y, active: highlight === gears.length - 1 });
|
|
98
|
+
drawConnection({ x1: mov.pallet.x, y1: mov.pallet.y, x2: mov.balance.x, y2: mov.balance.y, active: highlight === gears.length - 1 });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function drawScene(mov: MovementDef, opts: { angles: number[]; palletPhase: number; balancePhase: number; highlight: number | null; hover: number | null }) {
|
|
102
|
+
const ctx = getCtx();
|
|
103
|
+
setMov(mov);
|
|
104
|
+
setHovered(opts.hover);
|
|
105
|
+
detectTheme();
|
|
106
|
+
ctx.save();
|
|
107
|
+
ctx.clearRect(0, 0, W, H);
|
|
108
|
+
drawBackground();
|
|
109
|
+
drawTitleBar(mov.label);
|
|
110
|
+
drawConnections(mov, opts.highlight);
|
|
111
|
+
for (let i = 0; i < mov.gears.length; i++) {
|
|
112
|
+
const g = mov.gears[i];
|
|
113
|
+
drawGear({ x: g.x, y: g.y, r: g.r, teeth: g.teeth, angle: opts.angles[i], color: g.color, highlight: opts.hover === i });
|
|
114
|
+
}
|
|
115
|
+
drawPallet(mov.pallet.x, mov.pallet.y, opts.palletPhase);
|
|
116
|
+
drawBalance(mov.balance.x, mov.balance.y, mov.balance.r, opts.balancePhase);
|
|
117
|
+
drawLabels();
|
|
118
|
+
drawExtraLabels();
|
|
119
|
+
ctx.restore();
|
|
120
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { gearTrainExplorer } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const loader = gearTrainExplorer.i18n[locale] || gearTrainExplorer.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MovementDef } from './movements';
|
|
2
|
+
|
|
3
|
+
let _ctx: CanvasRenderingContext2D;
|
|
4
|
+
let _mov: MovementDef;
|
|
5
|
+
let _hovered: number | null = null;
|
|
6
|
+
let _isDark = true;
|
|
7
|
+
let _fontFam = 'system-ui, sans-serif';
|
|
8
|
+
|
|
9
|
+
export function getCtx() { return _ctx; }
|
|
10
|
+
export function getMov() { return _mov; }
|
|
11
|
+
export function getHovered() { return _hovered; }
|
|
12
|
+
export function isDark() { return _isDark; }
|
|
13
|
+
export function getFontFam() { return _fontFam; }
|
|
14
|
+
export function setMov(m: MovementDef) { _mov = m; }
|
|
15
|
+
export function setHovered(h: number | null) { _hovered = h; }
|
|
16
|
+
|
|
17
|
+
export function setCtx(ctx: CanvasRenderingContext2D) {
|
|
18
|
+
_ctx = ctx;
|
|
19
|
+
_fontFam = getComputedStyle(document.body).fontFamily || _fontFam;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function detectTheme() {
|
|
23
|
+
const bg = window.getComputedStyle(document.body).backgroundColor;
|
|
24
|
+
_isDark = !bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent' ? true : parseInt(bg.replace(/[^\d,]/g, '').split(',')[0]) < 128;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function c(h: string, l: string): string { return _isDark ? h : l; }
|
|
28
|
+
|
|
29
|
+
export const W = 900;
|
|
30
|
+
export const H = 520;
|
|
@@ -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
|
+
};
|