@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.
Files changed (88) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +6 -0
  3. package/src/entries.ts +10 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +1 -1
  6. package/src/tool/gmt-world-timer/bibliography.astro +11 -0
  7. package/src/tool/gmt-world-timer/bibliography.ts +7 -0
  8. package/src/tool/gmt-world-timer/client.ts +250 -0
  9. package/src/tool/gmt-world-timer/component.astro +13 -0
  10. package/src/tool/gmt-world-timer/components/GmtPanel.astro +18 -0
  11. package/src/tool/gmt-world-timer/entry.ts +34 -0
  12. package/src/tool/gmt-world-timer/gmt-world-timer.css +239 -0
  13. package/src/tool/gmt-world-timer/helpers.ts +28 -0
  14. package/src/tool/gmt-world-timer/i18n/de.ts +72 -0
  15. package/src/tool/gmt-world-timer/i18n/en.ts +72 -0
  16. package/src/tool/gmt-world-timer/i18n/es.ts +72 -0
  17. package/src/tool/gmt-world-timer/i18n/fr.ts +72 -0
  18. package/src/tool/gmt-world-timer/i18n/id.ts +72 -0
  19. package/src/tool/gmt-world-timer/i18n/it.ts +72 -0
  20. package/src/tool/gmt-world-timer/i18n/ja.ts +72 -0
  21. package/src/tool/gmt-world-timer/i18n/ko.ts +72 -0
  22. package/src/tool/gmt-world-timer/i18n/nl.ts +72 -0
  23. package/src/tool/gmt-world-timer/i18n/pl.ts +72 -0
  24. package/src/tool/gmt-world-timer/i18n/pt.ts +72 -0
  25. package/src/tool/gmt-world-timer/i18n/ru.ts +72 -0
  26. package/src/tool/gmt-world-timer/i18n/sv.ts +72 -0
  27. package/src/tool/gmt-world-timer/i18n/tr.ts +72 -0
  28. package/src/tool/gmt-world-timer/i18n/zh.ts +72 -0
  29. package/src/tool/gmt-world-timer/index.ts +11 -0
  30. package/src/tool/gmt-world-timer/seo.astro +11 -0
  31. package/src/tool/perpetual-calendar/bibliography.astro +16 -0
  32. package/src/tool/perpetual-calendar/bibliography.ts +16 -0
  33. package/src/tool/perpetual-calendar/calendar.ts +24 -0
  34. package/src/tool/perpetual-calendar/client.ts +98 -0
  35. package/src/tool/perpetual-calendar/component.astro +17 -0
  36. package/src/tool/perpetual-calendar/components/CalendarPanel.astro +49 -0
  37. package/src/tool/perpetual-calendar/dial.ts +176 -0
  38. package/src/tool/perpetual-calendar/entry.ts +48 -0
  39. package/src/tool/perpetual-calendar/helpers.ts +49 -0
  40. package/src/tool/perpetual-calendar/i18n/de.ts +85 -0
  41. package/src/tool/perpetual-calendar/i18n/en.ts +102 -0
  42. package/src/tool/perpetual-calendar/i18n/es.ts +85 -0
  43. package/src/tool/perpetual-calendar/i18n/fr.ts +85 -0
  44. package/src/tool/perpetual-calendar/i18n/id.ts +85 -0
  45. package/src/tool/perpetual-calendar/i18n/it.ts +85 -0
  46. package/src/tool/perpetual-calendar/i18n/ja.ts +85 -0
  47. package/src/tool/perpetual-calendar/i18n/ko.ts +85 -0
  48. package/src/tool/perpetual-calendar/i18n/nl.ts +85 -0
  49. package/src/tool/perpetual-calendar/i18n/pl.ts +85 -0
  50. package/src/tool/perpetual-calendar/i18n/pt.ts +85 -0
  51. package/src/tool/perpetual-calendar/i18n/ru.ts +85 -0
  52. package/src/tool/perpetual-calendar/i18n/sv.ts +85 -0
  53. package/src/tool/perpetual-calendar/i18n/tr.ts +85 -0
  54. package/src/tool/perpetual-calendar/i18n/zh.ts +85 -0
  55. package/src/tool/perpetual-calendar/index.ts +11 -0
  56. package/src/tool/perpetual-calendar/perpetual-calendar.css +181 -0
  57. package/src/tool/perpetual-calendar/seo.astro +16 -0
  58. package/src/tool/perpetual-calendar/state.ts +26 -0
  59. package/src/tool/tourbillon-visualizer/bibliography.astro +11 -0
  60. package/src/tool/tourbillon-visualizer/bibliography.ts +7 -0
  61. package/src/tool/tourbillon-visualizer/client.ts +122 -0
  62. package/src/tool/tourbillon-visualizer/component.astro +126 -0
  63. package/src/tool/tourbillon-visualizer/components/TourbillonPanel.astro +66 -0
  64. package/src/tool/tourbillon-visualizer/entry.ts +51 -0
  65. package/src/tool/tourbillon-visualizer/helpers.ts +35 -0
  66. package/src/tool/tourbillon-visualizer/i18n/de.ts +96 -0
  67. package/src/tool/tourbillon-visualizer/i18n/en.ts +96 -0
  68. package/src/tool/tourbillon-visualizer/i18n/es.ts +96 -0
  69. package/src/tool/tourbillon-visualizer/i18n/fr.ts +96 -0
  70. package/src/tool/tourbillon-visualizer/i18n/id.ts +96 -0
  71. package/src/tool/tourbillon-visualizer/i18n/it.ts +96 -0
  72. package/src/tool/tourbillon-visualizer/i18n/ja.ts +96 -0
  73. package/src/tool/tourbillon-visualizer/i18n/ko.ts +96 -0
  74. package/src/tool/tourbillon-visualizer/i18n/nl.ts +96 -0
  75. package/src/tool/tourbillon-visualizer/i18n/pl.ts +96 -0
  76. package/src/tool/tourbillon-visualizer/i18n/pt.ts +96 -0
  77. package/src/tool/tourbillon-visualizer/i18n/ru.ts +96 -0
  78. package/src/tool/tourbillon-visualizer/i18n/sv.ts +96 -0
  79. package/src/tool/tourbillon-visualizer/i18n/tr.ts +96 -0
  80. package/src/tool/tourbillon-visualizer/i18n/zh.ts +96 -0
  81. package/src/tool/tourbillon-visualizer/index.ts +11 -0
  82. package/src/tool/tourbillon-visualizer/renderer/base.ts +78 -0
  83. package/src/tool/tourbillon-visualizer/renderer/cage.ts +115 -0
  84. package/src/tool/tourbillon-visualizer/renderer/esc.ts +160 -0
  85. package/src/tool/tourbillon-visualizer/seo.astro +11 -0
  86. package/src/tool/tourbillon-visualizer/state.ts +21 -0
  87. package/src/tool/tourbillon-visualizer/tourbillon.ts +9 -0
  88. package/src/tools.ts +6 -0
@@ -0,0 +1,181 @@
1
+ .tool-main-card {
2
+ background: var(--bg-surface);
3
+ border: 1px solid var(--border-color);
4
+ border-radius: 1.25rem;
5
+ margin: 0 auto;
6
+ padding: 1.25rem;
7
+ box-shadow: var(--shadow-base);
8
+ }
9
+
10
+ .calendar-layout {
11
+ display: flex;
12
+ gap: 1rem;
13
+ align-items: stretch;
14
+ }
15
+
16
+ .dial-section {
17
+ flex: 4;
18
+ position: relative;
19
+ min-width: 0;
20
+ }
21
+
22
+ canvas#calendar-canvas {
23
+ display: block;
24
+ width: 100%;
25
+ height: auto;
26
+ border-radius: 0.75rem;
27
+ background: radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f1a 100%);
28
+ border: 1px solid rgba(212, 175, 55, 0.15);
29
+ }
30
+
31
+ .date-overlay {
32
+ position: absolute;
33
+ bottom: 18%;
34
+ left: 50%;
35
+ transform: translateX(-50%);
36
+ text-align: center;
37
+ pointer-events: none;
38
+ line-height: 1.2;
39
+ }
40
+
41
+ .date-weekday {
42
+ display: block;
43
+ font-size: clamp(0.8rem, 2vw, 1.3rem);
44
+ font-weight: 700;
45
+ color: var(--accent);
46
+ text-shadow: 0 1px 4px rgba(0,0,0,0.5);
47
+ letter-spacing: 0.08em;
48
+ text-transform: uppercase;
49
+ }
50
+
51
+ .date-full {
52
+ display: block;
53
+ font-size: clamp(0.5rem, 1vw, 0.75rem);
54
+ color: var(--text-base);
55
+ opacity: 0.5;
56
+ margin-top: 0.2em;
57
+ font-variant-numeric: tabular-nums;
58
+ }
59
+
60
+ .sidebar {
61
+ flex: 1;
62
+ min-width: 120px;
63
+ max-width: 180px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 1.25rem;
67
+ padding: 0.5rem 0;
68
+ }
69
+
70
+ .sidebar-group {
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 0.5rem;
74
+ }
75
+
76
+ .sidebar-label {
77
+ font-size: 0.55rem;
78
+ font-weight: 600;
79
+ text-transform: uppercase;
80
+ letter-spacing: 0.08em;
81
+ color: var(--text-base);
82
+ opacity: 0.35;
83
+ }
84
+
85
+ .sidebar-buttons {
86
+ display: flex;
87
+ gap: 0.35rem;
88
+ flex-wrap: wrap;
89
+ }
90
+
91
+ .sidebar button {
92
+ min-width: 28px;
93
+ height: 28px;
94
+ padding: 0 0.45rem;
95
+ font-size: 0.65rem;
96
+ font-weight: 500;
97
+ border: 1px solid var(--border-base);
98
+ border-radius: 0.35rem;
99
+ background: var(--bg-page);
100
+ color: var(--text-base);
101
+ cursor: pointer;
102
+ transition: all 0.15s ease;
103
+ display: inline-flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ }
107
+
108
+ .sidebar button:hover {
109
+ border-color: var(--accent);
110
+ color: var(--accent);
111
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg-page));
112
+ }
113
+
114
+ .sidebar button:active {
115
+ transform: scale(0.95);
116
+ }
117
+
118
+ .sidebar-info {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 0.3rem;
122
+ }
123
+
124
+ .sidebar-row {
125
+ display: flex;
126
+ justify-content: space-between;
127
+ align-items: center;
128
+ padding: 0.2rem 0;
129
+ border-bottom: 1px solid var(--border-base);
130
+ border-bottom-width: 0.5px;
131
+ }
132
+
133
+ .sidebar-row:last-child {
134
+ border-bottom: none;
135
+ }
136
+
137
+ .sidebar-row .slbl {
138
+ font-size: 0.55rem;
139
+ text-transform: uppercase;
140
+ letter-spacing: 0.05em;
141
+ color: var(--text-base);
142
+ opacity: 0.4;
143
+ font-weight: 500;
144
+ }
145
+
146
+ .sidebar-row .sval {
147
+ font-size: 0.65rem;
148
+ font-weight: 600;
149
+ color: var(--text-base);
150
+ font-variant-numeric: tabular-nums;
151
+ }
152
+
153
+ @media (max-width: 520px) {
154
+ .calendar-layout {
155
+ flex-direction: column;
156
+ }
157
+ .sidebar {
158
+ max-width: 100%;
159
+ flex-direction: row;
160
+ flex-wrap: wrap;
161
+ gap: 0.75rem;
162
+ padding: 0;
163
+ }
164
+ .sidebar-group {
165
+ flex-direction: row;
166
+ align-items: center;
167
+ gap: 0.4rem;
168
+ }
169
+ .sidebar-label {
170
+ margin-right: 0.25rem;
171
+ }
172
+ .sidebar-info {
173
+ flex-direction: row;
174
+ flex-wrap: wrap;
175
+ gap: 0.5rem;
176
+ }
177
+ .sidebar-row {
178
+ border-bottom: none;
179
+ gap: 0.3rem;
180
+ }
181
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { perpetualCalendar } 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 = perpetualCalendar.i18n[locale] || perpetualCalendar.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,26 @@
1
+ let _ctx: CanvasRenderingContext2D;
2
+ let _isDark = true;
3
+ let _fontFam = 'system-ui, sans-serif';
4
+
5
+ export function getCtx() { return _ctx; }
6
+ export function isDark() { return _isDark; }
7
+
8
+ export function setCtx(ctx: CanvasRenderingContext2D) {
9
+ _ctx = ctx;
10
+ _fontFam = getComputedStyle(document.body).fontFamily || _fontFam;
11
+ }
12
+
13
+ export function detectTheme() {
14
+ const bg = window.getComputedStyle(document.body).backgroundColor;
15
+ _isDark = !bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent' ? true : parseInt(bg.replace(/[^\d,]/g, '').split(',')[0]) < 128;
16
+ }
17
+
18
+ export function c(h: string, l: string): string { return _isDark ? h : l; }
19
+ export function getFontFam() { return _fontFam; }
20
+
21
+ export const W = 600;
22
+ export const H = 600;
23
+ export const CX = 300;
24
+ export const CY = 300;
25
+ export const OUTER_R = 280;
26
+ export const INNER_R = 220;
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { tourbillonVisualizer } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+ interface Props { locale?: KnownLocale; }
6
+ const { locale = 'en' } = Astro.props as Props;
7
+ const loader = tourbillonVisualizer.i18n[locale] || tourbillonVisualizer.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: 'Tourbillon - Wikipedia', url: 'https://en.wikipedia.org/wiki/Tourbillon' },
5
+ { name: 'Breguet Tourbillon History', url: 'https://www.swatchgroup.com/en/services/archive/2021/breguet-inventor-tourbillon' },
6
+ { name: 'Flying Tourbillon Explained', url: 'https://www.hautehorlogerie.org/zh/watches-and-culture/watchmaking-knowledge/encyclopedia/flying-tourbillon-' },
7
+ ];
@@ -0,0 +1,122 @@
1
+ const canvas = document.getElementById('tourb-canvas') as HTMLCanvasElement;
2
+ const ctx = canvas.getContext('2d')!;
3
+
4
+ import { setCtx, detT, W, H, CX, CY } from './state';
5
+ import { drawBg, drawPlate, drawSecondScale, drawScrews } from './renderer/base';
6
+ import { drawCage } from './renderer/cage';
7
+ import { drawBalance, drawHairspring, drawPallet, drawEscapeWheel } from './renderer/esc';
8
+ import { calc } from './tourbillon';
9
+
10
+ setCtx(ctx);
11
+
12
+ const REF = 700;
13
+
14
+ let cageAngle = 0;
15
+ let balancePhase = 0;
16
+ let palletPhase = 0;
17
+ let escAngle = 0;
18
+ let flying = false;
19
+ let gyro = false;
20
+ let zoom = false;
21
+ let curBeat = 28800;
22
+ let speedM = 1;
23
+ let paused = false;
24
+ let highlight: string | null = null;
25
+
26
+ function resize() {
27
+ const p = canvas.parentElement!;
28
+ const dw = p.clientWidth;
29
+ const dh = dw;
30
+ const dpr = Math.min(devicePixelRatio || 1, 2);
31
+ canvas.style.width = dw + 'px';
32
+ canvas.style.height = dh + 'px';
33
+ canvas.width = dw * dpr;
34
+ canvas.height = dh * dpr;
35
+ ctx.setTransform(dw / REF * dpr, 0, 0, dh / REF * dpr, 0, 0);
36
+ }
37
+
38
+ function upUI() {
39
+ const b = calc(curBeat);
40
+ const f = (id: string, v: string) => { const e = document.getElementById(id); if (e) e.textContent = v; };
41
+ f('cage-info', Math.round(((cageAngle * 180 / Math.PI) % 360 + 360) % 360) + '\u00B0');
42
+ f('balance-info', b.hz + ' Hz');
43
+ f('escape-info', b.rpm + ' rpm');
44
+ f('pallet-info', b.bph + ' bph');
45
+ f('spring-info', b.hz + ' Hz');
46
+ }
47
+
48
+ function tick() {
49
+ if (paused) return;
50
+ const b = calc(curBeat);
51
+ const dt = 1 * speedM;
52
+ cageAngle += 0.0015 * dt;
53
+ const balanceSpeed = 0.04 + b.hz * 0.03;
54
+ balancePhase += balanceSpeed * dt;
55
+ palletPhase += b.hz * 0.35 * dt;
56
+ escAngle += b.rpm * 0.0005 * dt;
57
+ }
58
+
59
+ function render() {
60
+ detT();
61
+ const b = calc(curBeat);
62
+ const cr = zoom ? 60 : 140;
63
+
64
+ ctx.save();
65
+ ctx.clearRect(0, 0, W, H);
66
+ drawBg();
67
+ drawPlate();
68
+ drawSecondScale();
69
+ drawScrews();
70
+
71
+ if (zoom) {
72
+ const zf = 2.5;
73
+ ctx.translate(CX, CY);
74
+ ctx.scale(zf, zf);
75
+ ctx.translate(-CX, -CY);
76
+ }
77
+
78
+ drawCage(cageAngle, flying, gyro, zoom);
79
+ ctx.save();
80
+ ctx.beginPath();
81
+ ctx.arc(CX, CY, cr - 5, 0, Math.PI * 2);
82
+ ctx.clip();
83
+ drawHairspring(balancePhase, b.hz, highlight === 'hairspring');
84
+ drawBalance(balancePhase, highlight === 'balance');
85
+ drawPallet(palletPhase, highlight === 'pallet');
86
+ drawEscapeWheel(escAngle, highlight === 'escape');
87
+ ctx.restore();
88
+ ctx.restore();
89
+ upUI();
90
+ }
91
+
92
+ function loop() { tick(); render(); requestAnimationFrame(loop); }
93
+
94
+ function switchOpt(opt: string, val: string) {
95
+ document.querySelectorAll(`[data-opt="${opt}"]`).forEach((b) => {
96
+ b.classList.toggle('active', (b as HTMLElement).dataset.val === val);
97
+ });
98
+ if (opt === 'type') { flying = val === 'flying'; gyro = val === 'gyro'; }
99
+ if (opt === 'speed') { speedM = parseFloat(val); paused = val === '0'; }
100
+ if (opt === 'beat') curBeat = parseInt(val);
101
+ if (opt === 'view') zoom = val === 'zoom';
102
+ }
103
+
104
+ function init() {
105
+ document.querySelectorAll('[data-opt]').forEach((b) => {
106
+ b.addEventListener('click', () => switchOpt(
107
+ (b as HTMLElement).dataset.opt!,
108
+ (b as HTMLElement).dataset.val!
109
+ ));
110
+ });
111
+ document.querySelectorAll('.tourb-cell').forEach((cell) => {
112
+ const target = cell.getAttribute('data-highlight');
113
+ if (!target) return;
114
+ cell.addEventListener('mouseenter', () => { highlight = target; });
115
+ cell.addEventListener('mouseleave', () => { highlight = null; });
116
+ });
117
+ }
118
+
119
+ new ResizeObserver(resize).observe(canvas.parentElement!);
120
+ resize();
121
+ init();
122
+ loop();
@@ -0,0 +1,126 @@
1
+ ---
2
+ import TourbillonPanel from './components/TourbillonPanel.astro';
3
+ interface Props { ui: Record<string, string>; }
4
+ const { ui } = Astro.props;
5
+ ---
6
+ <style is:global>
7
+ .tool-main-card {
8
+ background: var(--bg-surface);
9
+ border: 1px solid var(--border-color);
10
+ border-radius: 1.25rem;
11
+ margin: 0 auto;
12
+ padding: 1.25rem;
13
+ box-shadow: var(--shadow-base);
14
+ }
15
+ .tourb-col {
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: 0.75rem;
19
+ }
20
+ .tourb-canvas {
21
+ width: 100%;
22
+ }
23
+ canvas#tourb-canvas {
24
+ display: block;
25
+ width: 100%;
26
+ height: auto;
27
+ border-radius: 0.75rem;
28
+ background: radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f1a 100%);
29
+ border: 1px solid rgba(212,175,55,0.15);
30
+ }
31
+ .tourb-controls {
32
+ display: flex;
33
+ flex-wrap: wrap;
34
+ gap: 0.5rem;
35
+ padding: 0.75rem;
36
+ background: var(--bg-page);
37
+ border: 1px solid var(--border-base);
38
+ border-radius: 0.75rem;
39
+ }
40
+ .tourb-block {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ padding: 0.35rem 0.6rem;
45
+ border: 1px solid rgba(212,175,55,0.12);
46
+ border-radius: 0.4rem;
47
+ background: color-mix(in srgb, rgba(212,175,55,0.03), var(--bg-surface));
48
+ }
49
+ .tourb-lbl {
50
+ font-size: 0.6rem;
51
+ font-weight: 600;
52
+ text-transform: uppercase;
53
+ letter-spacing: 0.08em;
54
+ color: rgba(212,175,55,0.8);
55
+ white-space: nowrap;
56
+ }
57
+ .tourb-btns {
58
+ display: inline-flex;
59
+ gap: 0.15rem;
60
+ }
61
+ .tourb-btn {
62
+ padding: 0.25rem 0.5rem;
63
+ font-size: 0.7rem;
64
+ font-weight: 500;
65
+ border: 1px solid rgba(212,175,55,0.15);
66
+ border-radius: 0.3rem;
67
+ background: transparent;
68
+ color: var(--text-base);
69
+ opacity: 0.5;
70
+ cursor: pointer;
71
+ transition: all 0.15s ease;
72
+ }
73
+ .tourb-btn:hover {
74
+ border-color: var(--accent);
75
+ color: var(--accent);
76
+ opacity: 1;
77
+ }
78
+ .tourb-btn.active {
79
+ border-color: var(--accent);
80
+ color: var(--accent);
81
+ opacity: 1;
82
+ background: color-mix(in srgb, var(--accent) 10%, var(--bg-page));
83
+ box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 15%, transparent);
84
+ }
85
+ .tourb-cell-val {
86
+ font-size: 0.8rem;
87
+ font-weight: 600;
88
+ color: var(--accent);
89
+ font-variant-numeric: tabular-nums;
90
+ }
91
+ .tourb-data {
92
+ display: flex;
93
+ flex-wrap: wrap;
94
+ gap: 0.35rem;
95
+ padding: 0.6rem 0.75rem;
96
+ background: var(--bg-page);
97
+ border: 1px solid var(--border-base);
98
+ border-radius: 0.6rem;
99
+ }
100
+ .tourb-cell {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 0.4rem;
104
+ padding: 0.2rem 0.5rem;
105
+ border-radius: 0.3rem;
106
+ cursor: default;
107
+ transition: background 0.2s;
108
+ }
109
+ .tourb-cell:hover {
110
+ background: rgba(212,175,55,0.06);
111
+ }
112
+ .tourb-cell.highlight {
113
+ background: rgba(212,175,55,0.1);
114
+ box-shadow: 0 0 6px rgba(212,175,55,0.1);
115
+ }
116
+ .tourb-cell-lbl {
117
+ font-size: 0.6rem;
118
+ font-weight: 600;
119
+ text-transform: uppercase;
120
+ letter-spacing: 0.06em;
121
+ color: var(--text-base);
122
+ opacity: 0.3;
123
+ }
124
+ </style>
125
+ <div class="tool-main-card" data-ui={JSON.stringify(ui)}><TourbillonPanel labels={ui} /></div>
126
+ <script src="./client.ts"></script>
@@ -0,0 +1,66 @@
1
+ ---
2
+ interface Props { labels: Record<string, string>; }
3
+ const { labels } = Astro.props;
4
+ ---
5
+ <div class="tourb-col">
6
+ <div class="tourb-canvas">
7
+ <canvas id="tourb-canvas" width="700" height="700"></canvas>
8
+ </div>
9
+ <div class="tourb-controls">
10
+ <div class="tourb-block">
11
+ <span class="tourb-lbl">{labels.typeLabel || 'Type'}</span>
12
+ <span class="tourb-btns">
13
+ <button class="tourb-btn active" data-opt="type" data-val="classic">{labels.typeClassic || 'Classic'}</button>
14
+ <button class="tourb-btn" data-opt="type" data-val="flying">{labels.typeFlying || 'Flying'}</button>
15
+ <button class="tourb-btn" data-opt="type" data-val="gyro">Gyro</button>
16
+ </span>
17
+ </div>
18
+ <div class="tourb-block">
19
+ <span class="tourb-lbl">{labels.beatRateLabel || 'Rate'}</span>
20
+ <span class="tourb-btns">
21
+ <button class="tourb-btn" data-opt="beat" data-val="18000" title="Slow majestic beat">18k</button>
22
+ <button class="tourb-btn active" data-opt="beat" data-val="28800" title="Standard modern beat">28.8k</button>
23
+ <button class="tourb-btn" data-opt="beat" data-val="36000" title="High frequency rapid beat">36k</button>
24
+ </span>
25
+ </div>
26
+ <div class="tourb-block">
27
+ <span class="tourb-lbl">{labels.speedLabel || 'Speed'}</span>
28
+ <span class="tourb-btns">
29
+ <button class="tourb-btn" data-opt="speed" data-val="0.5">Slow</button>
30
+ <button class="tourb-btn active" data-opt="speed" data-val="1">1x</button>
31
+ <button class="tourb-btn" data-opt="speed" data-val="2">2x</button>
32
+ <button class="tourb-btn" data-opt="speed" data-val="5">5x</button>
33
+ <button class="tourb-btn" data-opt="speed" data-val="0">||</button>
34
+ </span>
35
+ </div>
36
+ <div class="tourb-block">
37
+ <span class="tourb-lbl">View</span>
38
+ <span class="tourb-btns">
39
+ <button class="tourb-btn active" data-opt="view" data-val="full">Full</button>
40
+ <button class="tourb-btn" data-opt="view" data-val="zoom">Zoom</button>
41
+ </span>
42
+ </div>
43
+ </div>
44
+ <div class="tourb-data">
45
+ <div class="tourb-cell" data-highlight="cage">
46
+ <span class="tourb-cell-lbl">{labels.cageRotationLabel || 'Cage'}</span>
47
+ <span class="tourb-cell-val" id="cage-info">0&deg;</span>
48
+ </div>
49
+ <div class="tourb-cell" data-highlight="balance">
50
+ <span class="tourb-cell-lbl">{labels.balanceLabel || 'Balance'}</span>
51
+ <span class="tourb-cell-val" id="balance-info">0 Hz</span>
52
+ </div>
53
+ <div class="tourb-cell" data-highlight="escape">
54
+ <span class="tourb-cell-lbl">{labels.escapeLabel || 'Escape'}</span>
55
+ <span class="tourb-cell-val" id="escape-info">0 rpm</span>
56
+ </div>
57
+ <div class="tourb-cell" data-highlight="pallet">
58
+ <span class="tourb-cell-lbl">{labels.palletLabel || 'Pallet'}</span>
59
+ <span class="tourb-cell-val" id="pallet-info">0 bph</span>
60
+ </div>
61
+ <div class="tourb-cell" data-highlight="hairspring">
62
+ <span class="tourb-cell-lbl">Spring</span>
63
+ <span class="tourb-cell-val" id="spring-info">4 Hz</span>
64
+ </div>
65
+ </div>
66
+ </div>
@@ -0,0 +1,51 @@
1
+ import type { ChronoToolEntry, ToolLocaleContent } from '../../types';
2
+
3
+ export type TourbillonUI = {
4
+ title: string;
5
+ typeLabel: string;
6
+ typeClassic: string;
7
+ typeFlying: string;
8
+ speedLabel: string;
9
+ speedNormal: string;
10
+ speedSlow: string;
11
+ speedPaused: string;
12
+ beatRateLabel: string;
13
+ rate18k: string;
14
+ rate28k: string;
15
+ rate36k: string;
16
+ cageRotationLabel: string;
17
+ showLabelsLabel: string;
18
+ step1: string;
19
+ step2: string;
20
+ step3: string;
21
+ tipTitle: string;
22
+ tipContent: string;
23
+ balanceLabel: string;
24
+ escapeLabel: string;
25
+ palletLabel: string;
26
+ cageLabel: string;
27
+ };
28
+
29
+ export type TourbillonLocaleContent = ToolLocaleContent<TourbillonUI>;
30
+
31
+ export const tourbillonVisualizer: ChronoToolEntry<TourbillonUI> = {
32
+ id: 'tourbillon-visualizer',
33
+ icons: { bg: 'mdi:rotate-orbit', fg: 'mdi:circle-outline' },
34
+ i18n: {
35
+ de: () => import('./i18n/de').then((m) => m.content),
36
+ en: () => import('./i18n/en').then((m) => m.content),
37
+ es: () => import('./i18n/es').then((m) => m.content),
38
+ fr: () => import('./i18n/fr').then((m) => m.content),
39
+ id: () => import('./i18n/id').then((m) => m.content),
40
+ it: () => import('./i18n/it').then((m) => m.content),
41
+ ja: () => import('./i18n/ja').then((m) => m.content),
42
+ ko: () => import('./i18n/ko').then((m) => m.content),
43
+ nl: () => import('./i18n/nl').then((m) => m.content),
44
+ pl: () => import('./i18n/pl').then((m) => m.content),
45
+ pt: () => import('./i18n/pt').then((m) => m.content),
46
+ ru: () => import('./i18n/ru').then((m) => m.content),
47
+ sv: () => import('./i18n/sv').then((m) => m.content),
48
+ tr: () => import('./i18n/tr').then((m) => m.content),
49
+ zh: () => import('./i18n/zh').then((m) => m.content),
50
+ },
51
+ };
@@ -0,0 +1,35 @@
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',
7
+ '@type': 'FAQPage',
8
+ 'mainEntity': faq.map((f) => ({
9
+ '@type': 'Question',
10
+ 'name': f.question,
11
+ 'acceptedAnswer': { '@type': 'Answer', 'text': f.answer },
12
+ })),
13
+ } as unknown as WithContext<Thing>;
14
+ }
15
+
16
+ function buildApp(title: string): WithContext<Thing> {
17
+ return {
18
+ '@context': 'https://schema.org',
19
+ '@type': 'SoftwareApplication',
20
+ 'name': title, 'operatingSystem': 'All',
21
+ 'applicationCategory': 'UtilitiesApplication',
22
+ 'browserRequirements': 'Requires HTML5. Requires JavaScript.',
23
+ } as unknown as WithContext<Thing>;
24
+ }
25
+
26
+ function buildHowTo(title: string, howTo: HowToStep[]): WithContext<Thing> {
27
+ return {
28
+ '@context': 'https://schema.org', '@type': 'HowTo', 'name': title,
29
+ 'step': howTo.map((h) => ({ '@type': 'HowToStep', 'name': h.name, 'text': h.text })),
30
+ } as unknown as WithContext<Thing>;
31
+ }
32
+
33
+ export function buildSchemas(title: string, faq: FAQItem[], howTo: HowToStep[]): WithContext<Thing>[] {
34
+ return [buildFAQ(faq), buildApp(title), buildHowTo(title, howTo)];
35
+ }