@jjlmoya/utils-chrono 1.3.0 → 1.5.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 (196) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/de.ts +11 -9
  3. package/src/category/i18n/en.ts +11 -9
  4. package/src/category/i18n/fr.ts +11 -9
  5. package/src/category/i18n/id.ts +11 -9
  6. package/src/category/i18n/it.ts +11 -9
  7. package/src/category/i18n/ja.ts +11 -9
  8. package/src/category/i18n/ko.ts +11 -9
  9. package/src/category/i18n/nl.ts +11 -9
  10. package/src/category/i18n/pl.ts +11 -9
  11. package/src/category/i18n/pt.ts +11 -9
  12. package/src/category/i18n/ru.ts +11 -9
  13. package/src/category/i18n/sv.ts +11 -9
  14. package/src/category/i18n/tr.ts +11 -9
  15. package/src/category/i18n/zh.ts +11 -9
  16. package/src/category/index.ts +8 -0
  17. package/src/entries.ts +13 -1
  18. package/src/index.ts +4 -0
  19. package/src/tests/locale_completeness.test.ts +1 -1
  20. package/src/tests/no_en_dash.test.ts +41 -0
  21. package/src/tests/no_h1_in_components.test.ts +1 -1
  22. package/src/tests/tool_validation.test.ts +1 -1
  23. package/src/tool/beat-rate-converter/bibliography.ts +1 -1
  24. package/src/tool/beat-rate-converter/components/ConverterPanel.astro +57 -20
  25. package/src/tool/beat-rate-converter/i18n/en.ts +5 -5
  26. package/src/tool/crown-reference-guide/bibliography.ts +3 -3
  27. package/src/tool/crown-reference-guide/i18n/de.ts +37 -29
  28. package/src/tool/crown-reference-guide/i18n/en.ts +38 -30
  29. package/src/tool/crown-reference-guide/i18n/es.ts +36 -28
  30. package/src/tool/crown-reference-guide/i18n/fr.ts +38 -30
  31. package/src/tool/crown-reference-guide/i18n/id.ts +38 -30
  32. package/src/tool/crown-reference-guide/i18n/it.ts +38 -30
  33. package/src/tool/crown-reference-guide/i18n/ja.ts +38 -30
  34. package/src/tool/crown-reference-guide/i18n/ko.ts +38 -30
  35. package/src/tool/crown-reference-guide/i18n/nl.ts +38 -30
  36. package/src/tool/crown-reference-guide/i18n/pl.ts +38 -30
  37. package/src/tool/crown-reference-guide/i18n/pt.ts +38 -30
  38. package/src/tool/crown-reference-guide/i18n/ru.ts +41 -33
  39. package/src/tool/crown-reference-guide/i18n/sv.ts +38 -30
  40. package/src/tool/crown-reference-guide/i18n/tr.ts +38 -30
  41. package/src/tool/crown-reference-guide/i18n/zh.ts +37 -29
  42. package/src/tool/demagnetizing-timer/components/TimerPanel.astro +45 -17
  43. package/src/tool/demagnetizing-timer/i18n/de.ts +3 -3
  44. package/src/tool/demagnetizing-timer/i18n/en.ts +2 -2
  45. package/src/tool/demagnetizing-timer/i18n/es.ts +2 -2
  46. package/src/tool/demagnetizing-timer/i18n/fr.ts +1 -1
  47. package/src/tool/demagnetizing-timer/i18n/id.ts +2 -2
  48. package/src/tool/demagnetizing-timer/i18n/it.ts +2 -2
  49. package/src/tool/demagnetizing-timer/i18n/ja.ts +1 -1
  50. package/src/tool/demagnetizing-timer/i18n/ko.ts +1 -1
  51. package/src/tool/demagnetizing-timer/i18n/nl.ts +2 -2
  52. package/src/tool/demagnetizing-timer/i18n/pl.ts +3 -3
  53. package/src/tool/demagnetizing-timer/i18n/pt.ts +2 -2
  54. package/src/tool/demagnetizing-timer/i18n/ru.ts +10 -10
  55. package/src/tool/demagnetizing-timer/i18n/sv.ts +2 -2
  56. package/src/tool/demagnetizing-timer/i18n/tr.ts +2 -2
  57. package/src/tool/demagnetizing-timer/i18n/zh.ts +1 -1
  58. package/src/tool/lume-color-simulator/bibliography.astro +16 -0
  59. package/src/tool/lume-color-simulator/bibliography.ts +16 -0
  60. package/src/tool/lume-color-simulator/client.ts +186 -0
  61. package/src/tool/lume-color-simulator/component.astro +17 -0
  62. package/src/tool/lume-color-simulator/components/LumePanel.astro +98 -0
  63. package/src/tool/lume-color-simulator/entry.ts +57 -0
  64. package/src/tool/lume-color-simulator/i18n/de.ts +174 -0
  65. package/src/tool/lume-color-simulator/i18n/en.ts +174 -0
  66. package/src/tool/lume-color-simulator/i18n/es.ts +174 -0
  67. package/src/tool/lume-color-simulator/i18n/fr.ts +174 -0
  68. package/src/tool/lume-color-simulator/i18n/id.ts +174 -0
  69. package/src/tool/lume-color-simulator/i18n/it.ts +175 -0
  70. package/src/tool/lume-color-simulator/i18n/ja.ts +174 -0
  71. package/src/tool/lume-color-simulator/i18n/ko.ts +174 -0
  72. package/src/tool/lume-color-simulator/i18n/nl.ts +175 -0
  73. package/src/tool/lume-color-simulator/i18n/pl.ts +174 -0
  74. package/src/tool/lume-color-simulator/i18n/pt.ts +174 -0
  75. package/src/tool/lume-color-simulator/i18n/ru.ts +174 -0
  76. package/src/tool/lume-color-simulator/i18n/sv.ts +174 -0
  77. package/src/tool/lume-color-simulator/i18n/tr.ts +174 -0
  78. package/src/tool/lume-color-simulator/i18n/zh.ts +174 -0
  79. package/src/tool/lume-color-simulator/index.ts +11 -0
  80. package/src/tool/lume-color-simulator/lume-color-simulator.css +208 -0
  81. package/src/tool/lume-color-simulator/seo.astro +16 -0
  82. package/src/tool/moon-phase-visualizer/bibliography.astro +16 -0
  83. package/src/tool/moon-phase-visualizer/bibliography.ts +16 -0
  84. package/src/tool/moon-phase-visualizer/client.ts +243 -0
  85. package/src/tool/moon-phase-visualizer/component.astro +17 -0
  86. package/src/tool/moon-phase-visualizer/components/MoonPanel.astro +63 -0
  87. package/src/tool/moon-phase-visualizer/entry.ts +51 -0
  88. package/src/tool/moon-phase-visualizer/i18n/de.ts +175 -0
  89. package/src/tool/moon-phase-visualizer/i18n/en.ts +175 -0
  90. package/src/tool/moon-phase-visualizer/i18n/es.ts +175 -0
  91. package/src/tool/moon-phase-visualizer/i18n/fr.ts +175 -0
  92. package/src/tool/moon-phase-visualizer/i18n/id.ts +175 -0
  93. package/src/tool/moon-phase-visualizer/i18n/it.ts +176 -0
  94. package/src/tool/moon-phase-visualizer/i18n/ja.ts +175 -0
  95. package/src/tool/moon-phase-visualizer/i18n/ko.ts +175 -0
  96. package/src/tool/moon-phase-visualizer/i18n/nl.ts +176 -0
  97. package/src/tool/moon-phase-visualizer/i18n/pl.ts +175 -0
  98. package/src/tool/moon-phase-visualizer/i18n/pt.ts +175 -0
  99. package/src/tool/moon-phase-visualizer/i18n/ru.ts +175 -0
  100. package/src/tool/moon-phase-visualizer/i18n/sv.ts +175 -0
  101. package/src/tool/moon-phase-visualizer/i18n/tr.ts +175 -0
  102. package/src/tool/moon-phase-visualizer/i18n/zh.ts +175 -0
  103. package/src/tool/moon-phase-visualizer/index.ts +11 -0
  104. package/src/tool/moon-phase-visualizer/moon-phase-visualizer.css +216 -0
  105. package/src/tool/moon-phase-visualizer/seo.astro +16 -0
  106. package/src/tool/power-reserve-estimator/bibliography.ts +2 -2
  107. package/src/tool/power-reserve-estimator/components/EstimatorPanel.astro +146 -39
  108. package/src/tool/power-reserve-estimator/i18n/de.ts +2 -2
  109. package/src/tool/power-reserve-estimator/i18n/en.ts +3 -3
  110. package/src/tool/power-reserve-estimator/i18n/es.ts +2 -2
  111. package/src/tool/power-reserve-estimator/i18n/fr.ts +2 -2
  112. package/src/tool/power-reserve-estimator/i18n/id.ts +2 -2
  113. package/src/tool/power-reserve-estimator/i18n/it.ts +2 -2
  114. package/src/tool/power-reserve-estimator/i18n/nl.ts +2 -2
  115. package/src/tool/power-reserve-estimator/i18n/pt.ts +2 -2
  116. package/src/tool/strap-taper-calculator/i18n/en.ts +2 -2
  117. package/src/tool/strap-taper-calculator/i18n/ru.ts +4 -4
  118. package/src/tool/tachymeter-calculator/bibliography.astro +16 -0
  119. package/src/tool/tachymeter-calculator/bibliography.ts +16 -0
  120. package/src/tool/tachymeter-calculator/client.ts +180 -0
  121. package/src/tool/tachymeter-calculator/component.astro +15 -0
  122. package/src/tool/tachymeter-calculator/components/CalculatorPanel.astro +121 -0
  123. package/src/tool/tachymeter-calculator/entry.ts +43 -0
  124. package/src/tool/tachymeter-calculator/i18n/de.ts +172 -0
  125. package/src/tool/tachymeter-calculator/i18n/en.ts +172 -0
  126. package/src/tool/tachymeter-calculator/i18n/es.ts +172 -0
  127. package/src/tool/tachymeter-calculator/i18n/fr.ts +172 -0
  128. package/src/tool/tachymeter-calculator/i18n/id.ts +172 -0
  129. package/src/tool/tachymeter-calculator/i18n/it.ts +172 -0
  130. package/src/tool/tachymeter-calculator/i18n/ja.ts +172 -0
  131. package/src/tool/tachymeter-calculator/i18n/ko.ts +172 -0
  132. package/src/tool/tachymeter-calculator/i18n/nl.ts +172 -0
  133. package/src/tool/tachymeter-calculator/i18n/pl.ts +172 -0
  134. package/src/tool/tachymeter-calculator/i18n/pt.ts +172 -0
  135. package/src/tool/tachymeter-calculator/i18n/ru.ts +172 -0
  136. package/src/tool/tachymeter-calculator/i18n/sv.ts +172 -0
  137. package/src/tool/tachymeter-calculator/i18n/tr.ts +172 -0
  138. package/src/tool/tachymeter-calculator/i18n/zh.ts +172 -0
  139. package/src/tool/tachymeter-calculator/index.ts +11 -0
  140. package/src/tool/tachymeter-calculator/seo.astro +16 -0
  141. package/src/tool/tachymeter-calculator/tachymeter-calculator.css +492 -0
  142. package/src/tool/tachymeter-calculator/utils.ts +10 -0
  143. package/src/tool/watch-accuracy-tracker/i18n/pl.ts +1 -1
  144. package/src/tool/watch-accuracy-tracker/i18n/ru.ts +6 -6
  145. package/src/tool/watch-savings-planner/i18n/en.ts +5 -5
  146. package/src/tool/watch-size-comparator/bibliography.astro +16 -0
  147. package/src/tool/watch-size-comparator/bibliography.ts +16 -0
  148. package/src/tool/watch-size-comparator/client.ts +287 -0
  149. package/src/tool/watch-size-comparator/component.astro +17 -0
  150. package/src/tool/watch-size-comparator/components/WatchForm.astro +121 -0
  151. package/src/tool/watch-size-comparator/drawing/index.ts +79 -0
  152. package/src/tool/watch-size-comparator/drawing/measures.ts +57 -0
  153. package/src/tool/watch-size-comparator/drawing/utils.ts +37 -0
  154. package/src/tool/watch-size-comparator/drawing/watch.ts +78 -0
  155. package/src/tool/watch-size-comparator/entry.ts +62 -0
  156. package/src/tool/watch-size-comparator/i18n/de.ts +189 -0
  157. package/src/tool/watch-size-comparator/i18n/en.ts +189 -0
  158. package/src/tool/watch-size-comparator/i18n/es.ts +189 -0
  159. package/src/tool/watch-size-comparator/i18n/fr.ts +189 -0
  160. package/src/tool/watch-size-comparator/i18n/id.ts +189 -0
  161. package/src/tool/watch-size-comparator/i18n/it.ts +190 -0
  162. package/src/tool/watch-size-comparator/i18n/ja.ts +189 -0
  163. package/src/tool/watch-size-comparator/i18n/ko.ts +189 -0
  164. package/src/tool/watch-size-comparator/i18n/nl.ts +190 -0
  165. package/src/tool/watch-size-comparator/i18n/pl.ts +189 -0
  166. package/src/tool/watch-size-comparator/i18n/pt.ts +189 -0
  167. package/src/tool/watch-size-comparator/i18n/ru.ts +189 -0
  168. package/src/tool/watch-size-comparator/i18n/sv.ts +189 -0
  169. package/src/tool/watch-size-comparator/i18n/tr.ts +189 -0
  170. package/src/tool/watch-size-comparator/i18n/zh.ts +189 -0
  171. package/src/tool/watch-size-comparator/index.ts +11 -0
  172. package/src/tool/watch-size-comparator/seo.astro +16 -0
  173. package/src/tool/watch-size-comparator/watch-size-comparator.css +373 -0
  174. package/src/tool/water-resistance-converter/bibliography.ts +2 -2
  175. package/src/tool/water-resistance-converter/i18n/de.ts +5 -5
  176. package/src/tool/water-resistance-converter/i18n/en.ts +6 -6
  177. package/src/tool/water-resistance-converter/i18n/es.ts +6 -6
  178. package/src/tool/water-resistance-converter/i18n/fr.ts +6 -6
  179. package/src/tool/water-resistance-converter/i18n/id.ts +6 -6
  180. package/src/tool/water-resistance-converter/i18n/it.ts +6 -6
  181. package/src/tool/water-resistance-converter/i18n/ja.ts +6 -6
  182. package/src/tool/water-resistance-converter/i18n/ko.ts +6 -6
  183. package/src/tool/water-resistance-converter/i18n/nl.ts +6 -6
  184. package/src/tool/water-resistance-converter/i18n/pl.ts +6 -6
  185. package/src/tool/water-resistance-converter/i18n/pt.ts +6 -6
  186. package/src/tool/water-resistance-converter/i18n/ru.ts +8 -8
  187. package/src/tool/water-resistance-converter/i18n/sv.ts +6 -6
  188. package/src/tool/water-resistance-converter/i18n/tr.ts +6 -6
  189. package/src/tool/water-resistance-converter/i18n/zh.ts +3 -3
  190. package/src/tool/wrist-presence-calculator/i18n/de.ts +1 -1
  191. package/src/tool/wrist-presence-calculator/i18n/fr.ts +1 -1
  192. package/src/tool/wrist-presence-calculator/i18n/pl.ts +1 -1
  193. package/src/tool/wrist-presence-calculator/i18n/pt.ts +1 -1
  194. package/src/tool/wrist-presence-calculator/i18n/ru.ts +21 -21
  195. package/src/tool/wrist-presence-calculator/i18n/sv.ts +1 -1
  196. package/src/tools.ts +8 -0
@@ -0,0 +1,287 @@
1
+ import { drawWatch, getFitColor, getFitRatio } from './drawing';
2
+
3
+ interface WatchProfile {
4
+ id: string;
5
+ name: string;
6
+ diameter: number;
7
+ lugToLug: number;
8
+ thickness: number;
9
+ }
10
+
11
+ const STORAGE_KEY = 'jjlmoya_chrono_watch_sizes';
12
+ const UNIT_KEY = 'jjlmoya_chrono_unit';
13
+ const mainEl = document.querySelector('.tool-main-card') as HTMLElement;
14
+ const ui = mainEl ? JSON.parse(mainEl.dataset.ui || '{}') : {};
15
+
16
+ const canvas = document.getElementById('size-canvas') as HTMLCanvasElement;
17
+ const ctx = canvas.getContext('2d')!;
18
+ const watchList = document.getElementById('watch-list') as HTMLElement;
19
+ const fitInfo = document.getElementById('fit-info') as HTMLElement;
20
+
21
+ const inputName = document.getElementById('watch-name-input') as HTMLInputElement;
22
+ const inputDiameter = document.getElementById('input-diameter') as HTMLInputElement;
23
+ const inputL2L = document.getElementById('input-l2l') as HTMLInputElement;
24
+ const inputThickness = document.getElementById('input-thickness') as HTMLInputElement;
25
+ const inputWrist = document.getElementById('input-wrist') as HTMLInputElement;
26
+ const addBtn = document.getElementById('add-watch-btn') as HTMLElement;
27
+ const btnUnitCm = document.getElementById('btn-unit-cm') as HTMLButtonElement;
28
+ const btnUnitIn = document.getElementById('btn-unit-in') as HTMLButtonElement;
29
+ const unitDiameter = document.getElementById('unit-diameter') as HTMLElement;
30
+ const unitL2l = document.getElementById('unit-l2l') as HTMLElement;
31
+ const unitThickness = document.getElementById('unit-thickness') as HTMLElement;
32
+
33
+ let watches: WatchProfile[] = [];
34
+ let activeId = '';
35
+ let unit: 'cm' | 'in' = 'cm';
36
+
37
+ function loadUnit(): 'cm' | 'in' {
38
+ const stored = localStorage.getItem(UNIT_KEY);
39
+ return stored === 'in' ? 'in' : 'cm';
40
+ }
41
+
42
+ function saveUnit(): void {
43
+ localStorage.setItem(UNIT_KEY, unit);
44
+ }
45
+
46
+ function mmToDisplay(mm: number): string {
47
+ return unit === 'in' ? (mm / 25.4).toFixed(2) : Math.round(mm * 10) / 10 + '';
48
+ }
49
+
50
+ function mmUnit(): string {
51
+ return unit === 'in' ? 'in' : 'mm';
52
+ }
53
+
54
+ function getWristCm(): number {
55
+ const val = parseFloat(inputWrist.value);
56
+ return unit === 'in' ? val * 2.54 : val;
57
+ }
58
+
59
+ function loadWatches(): void {
60
+ const data = localStorage.getItem(STORAGE_KEY);
61
+ watches = data ? JSON.parse(data) : [];
62
+ }
63
+
64
+ function saveWatches(): void {
65
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(watches));
66
+ }
67
+
68
+ function getFitLabel(l2l: number, wristCm: number): string {
69
+ const ratio = getFitRatio(l2l, wristCm);
70
+ if (ratio < 0.5) return ui.excellentFit || 'Excellent';
71
+ if (ratio < 0.58) return ui.goodFit || 'Good';
72
+ if (ratio < 0.65) return ui.borderlineFit || 'Borderline';
73
+ return ui.largeFit || 'Too Large';
74
+ }
75
+
76
+ function getFitDescription(l2l: number, wristCm: number): string {
77
+ const ratio = getFitRatio(l2l, wristCm);
78
+ if (ratio < 0.5) return ui.excellentDesc || 'Proportional-lug-to-lug stays well within your wrist.';
79
+ if (ratio < 0.58) return ui.goodDesc || 'Good fit-overhangs slightly but still comfortable.';
80
+ if (ratio < 0.65) return ui.borderlineDesc || 'Borderline-lugs approach the edge of your wrist.';
81
+ return ui.largeDesc || 'Too large-lugs likely overhang your wrist.';
82
+ }
83
+
84
+ function getFitBadgeClass(l2l: number, wristCm: number): string {
85
+ const ratio = getFitRatio(l2l, wristCm);
86
+ if (ratio < 0.5) return 'excellent';
87
+ if (ratio < 0.58) return 'good';
88
+ if (ratio < 0.65) return 'borderline';
89
+ return 'large';
90
+ }
91
+
92
+ function updateInputUnits(): void {
93
+ const u = mmUnit();
94
+ unitDiameter.textContent = u;
95
+ unitL2l.textContent = u;
96
+ unitThickness.textContent = u;
97
+ }
98
+
99
+ function updateUnitButtons(): void {
100
+ btnUnitCm.classList.toggle('active', unit === 'cm');
101
+ btnUnitIn.classList.toggle('active', unit === 'in');
102
+ }
103
+
104
+ function parseMm(input: HTMLInputElement, d: number): number {
105
+ const val = parseFloat(input.value);
106
+ return (unit === 'in' ? val * 25.4 : val) || d;
107
+ }
108
+
109
+ function onRemoveClick(w: WatchProfile): void {
110
+ watches = watches.filter((x) => x.id !== w.id);
111
+ saveWatches();
112
+ if (activeId === w.id) {
113
+ activeId = watches.length > 0 ? watches[watches.length - 1].id : '';
114
+ }
115
+ renderWatchList();
116
+ selectWatch(activeId);
117
+ }
118
+
119
+ function createRemoveButton(w: WatchProfile): HTMLButtonElement {
120
+ const btn = document.createElement('button');
121
+ btn.className = 'btn-remove';
122
+ btn.title = ui.remove || 'Remove';
123
+ btn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="currentColor"/></svg>';
124
+ btn.addEventListener('click', (e) => {
125
+ e.stopPropagation();
126
+ onRemoveClick(w);
127
+ });
128
+ return btn;
129
+ }
130
+
131
+ function createWatchListItem(w: WatchProfile): HTMLDivElement {
132
+ const wristCm = getWristCm();
133
+ const item = document.createElement('div');
134
+ item.className = `watch-list-item ${w.id === activeId ? 'active' : ''}`;
135
+ item.dataset.id = w.id;
136
+
137
+ const dot = document.createElement('span');
138
+ dot.className = 'watch-dot';
139
+ dot.style.background = getFitColor(w.lugToLug, wristCm);
140
+
141
+ const nameSpan = document.createElement('span');
142
+ nameSpan.className = 'watch-name-label';
143
+ nameSpan.textContent = w.name;
144
+
145
+ const dimsSpan = document.createElement('span');
146
+ dimsSpan.className = 'watch-dims-label';
147
+ dimsSpan.textContent = `${mmToDisplay(w.diameter)}\u00d7${mmToDisplay(w.lugToLug)}${mmUnit()}`;
148
+
149
+ const badge = document.createElement('span');
150
+ const badgeClass = getFitBadgeClass(w.lugToLug, wristCm);
151
+ badge.className = `watch-fit-badge fit-${badgeClass}`;
152
+ badge.textContent = getFitLabel(w.lugToLug, wristCm);
153
+
154
+ item.append(dot, nameSpan, dimsSpan, badge, createRemoveButton(w));
155
+ item.addEventListener('click', () => { selectWatch(w.id); renderWatchList(); });
156
+ return item;
157
+ }
158
+
159
+ function renderWatchList(): void {
160
+ watchList.innerHTML = '';
161
+ if (watches.length === 0) {
162
+ watchList.innerHTML = `<div class="empty-state">${ui.addWatch || 'Add a watch'} \u2191</div>`;
163
+ return;
164
+ }
165
+ watches.forEach((w) => {
166
+ watchList.appendChild(createWatchListItem(w));
167
+ });
168
+ }
169
+
170
+ function updateFitInfo(w: WatchProfile, wristCm: number): void {
171
+ const color = getFitColor(w.lugToLug, wristCm);
172
+ fitInfo.innerHTML = `<strong>${getFitLabel(w.lugToLug, wristCm)}</strong> \u2014 ${getFitDescription(w.lugToLug, wristCm)}`;
173
+ fitInfo.style.borderLeft = `3px solid ${color}`;
174
+ }
175
+
176
+ function selectWatch(id: string): void {
177
+ activeId = id;
178
+ const w = watches.find((x) => x.id === id);
179
+ if (w) {
180
+ inputDiameter.value = mmToDisplay(w.diameter);
181
+ inputL2L.value = mmToDisplay(w.lugToLug);
182
+ inputThickness.value = mmToDisplay(w.thickness);
183
+ const wristCm = getWristCm();
184
+ drawWatch({ ctx, canvas, ui, diameter: w.diameter, l2l: w.lugToLug, thickness: w.thickness, wristCm, unit });
185
+ updateFitInfo(w, wristCm);
186
+ } else {
187
+ inputDiameter.value = mmToDisplay(40);
188
+ inputL2L.value = mmToDisplay(48);
189
+ inputThickness.value = mmToDisplay(12);
190
+ drawWatch({ ctx, canvas, ui, diameter: 40, l2l: 48, thickness: 12, wristCm: getWristCm(), unit });
191
+ fitInfo.innerHTML = `<strong>${ui.addWatch || 'Add a watch'}</strong> \u2014 ${ui.estimateNote || 'Enter dimensions and add a watch to see how it fits your wrist.'}`;
192
+ fitInfo.style.borderLeft = 'none';
193
+ }
194
+ }
195
+
196
+ function addWatch(): void {
197
+ const name = inputName.value.trim();
198
+ if (!name) {
199
+ inputName.focus();
200
+ return;
201
+ }
202
+ const id = 'ws_' + Date.now();
203
+ watches.push({
204
+ id,
205
+ name,
206
+ diameter: parseMm(inputDiameter, 40),
207
+ lugToLug: parseMm(inputL2L, 48),
208
+ thickness: parseMm(inputThickness, 12),
209
+ });
210
+ saveWatches();
211
+ activeId = id;
212
+ inputName.value = '';
213
+ renderWatchList();
214
+ selectWatch(id);
215
+ }
216
+
217
+ function onInputChange(): void {
218
+ const wristCm = getWristCm();
219
+ const dia = parseMm(inputDiameter, 40), l2l = parseMm(inputL2L, 48), thick = parseMm(inputThickness, 12);
220
+ const w = watches.find((x) => x.id === activeId);
221
+ if (w) {
222
+ w.diameter = dia;
223
+ w.lugToLug = l2l;
224
+ w.thickness = thick;
225
+ saveWatches();
226
+ drawWatch({ ctx, canvas, ui, diameter: dia, l2l, thickness: thick, wristCm, unit });
227
+ updateFitInfo(w, wristCm);
228
+ renderWatchList();
229
+ } else {
230
+ drawWatch({ ctx, canvas, ui, diameter: dia, l2l, thickness: thick, wristCm, unit });
231
+ }
232
+ }
233
+
234
+ function handleUnitChange(newUnit: 'cm' | 'in'): void {
235
+ if (unit === newUnit) return;
236
+ const prevCm = getWristCm();
237
+ const prevDiaMm = parseMm(inputDiameter, 40);
238
+ const prevL2lMm = parseMm(inputL2L, 48);
239
+ const prevThickMm = parseMm(inputThickness, 12);
240
+ unit = newUnit;
241
+ saveUnit();
242
+ updateUnitButtons();
243
+ updateInputUnits();
244
+
245
+ inputWrist.value = unit === 'in' ? (prevCm / 2.54).toFixed(1) : prevCm.toFixed(1);
246
+ inputWrist.min = unit === 'in' ? '4.7' : '12';
247
+ inputWrist.max = unit === 'in' ? '9.8' : '25';
248
+
249
+ inputDiameter.value = unit === 'in' ? (prevDiaMm / 25.4).toFixed(2) : Math.round(prevDiaMm).toString();
250
+ inputL2L.value = unit === 'in' ? (prevL2lMm / 25.4).toFixed(2) : Math.round(prevL2lMm).toString();
251
+ inputThickness.value = unit === 'in' ? (prevThickMm / 25.4).toFixed(2) : Math.round(prevThickMm).toString();
252
+
253
+ onInputChange();
254
+ }
255
+
256
+ btnUnitCm.addEventListener('click', () => handleUnitChange('cm'));
257
+ btnUnitIn.addEventListener('click', () => handleUnitChange('in'));
258
+ addBtn.addEventListener('click', addWatch);
259
+ inputName.addEventListener('keydown', (e) => { if (e.key === 'Enter') addWatch(); });
260
+ inputDiameter.addEventListener('input', onInputChange);
261
+ inputL2L.addEventListener('input', onInputChange);
262
+ inputThickness.addEventListener('input', onInputChange);
263
+ inputWrist.addEventListener('input', onInputChange);
264
+
265
+ unit = loadUnit();
266
+ updateUnitButtons();
267
+ updateInputUnits();
268
+
269
+ if (unit === 'in') {
270
+ inputWrist.value = (17 / 2.54).toFixed(1);
271
+ inputWrist.min = '4.7'; inputWrist.max = '9.8';
272
+ inputDiameter.value = (40 / 25.4).toFixed(2);
273
+ inputL2L.value = (48 / 25.4).toFixed(2);
274
+ inputThickness.value = (12 / 25.4).toFixed(2);
275
+ } else {
276
+ inputWrist.value = '17'; inputWrist.min = '12'; inputWrist.max = '25';
277
+ }
278
+ inputWrist.step = '0.5';
279
+ loadWatches();
280
+ if (watches.length > 0) {
281
+ activeId = watches[watches.length - 1].id;
282
+ renderWatchList();
283
+ selectWatch(activeId);
284
+ } else {
285
+ selectWatch('');
286
+ renderWatchList();
287
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ import WatchForm from './components/WatchForm.astro';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <link href="./watch-size-comparator.css" rel="stylesheet" />
12
+
13
+ <div class="tool-main-card" data-ui={JSON.stringify(ui)}>
14
+ <WatchForm labels={ui} />
15
+ </div>
16
+
17
+ <script src="./client.ts"></script>
@@ -0,0 +1,121 @@
1
+ ---
2
+ interface Props {
3
+ labels: Record<string, string>;
4
+ }
5
+
6
+ const { labels } = Astro.props;
7
+ ---
8
+
9
+ <div class="comparator-panel">
10
+ <div class="panel-section">
11
+ <div class="section-label">{labels.yourWatches || "Your Watches"}</div>
12
+ <div class="watch-input-row">
13
+ <input
14
+ type="text"
15
+ id="watch-name-input"
16
+ placeholder={labels.watchNamePlaceholder || "e.g. Rolex Submariner"}
17
+ />
18
+ <button type="button" class="btn-add" id="add-watch-btn"
19
+ >{labels.addWatch || "Add Watch"}</button
20
+ >
21
+ </div>
22
+ </div>
23
+
24
+ <div class="dims-grid" id="dims-grid">
25
+ <div class="dim-field">
26
+ <label for="input-diameter"
27
+ >{labels.caseDiameter || "Case Diameter"}</label
28
+ >
29
+ <div class="input-with-unit">
30
+ <input
31
+ type="number"
32
+ id="input-diameter"
33
+ min="20"
34
+ max="60"
35
+ value="40"
36
+ step="0.5"
37
+ />
38
+ <span class="dim-unit" id="unit-diameter">mm</span>
39
+ </div>
40
+ </div>
41
+ <div class="dim-field">
42
+ <label for="input-l2l">{labels.lugToLug || "Lug-to-Lug"}</label>
43
+ <div class="input-with-unit">
44
+ <input
45
+ type="number"
46
+ id="input-l2l"
47
+ min="30"
48
+ max="80"
49
+ value="48"
50
+ step="0.5"
51
+ />
52
+ <span class="dim-unit" id="unit-l2l">mm</span>
53
+ </div>
54
+ </div>
55
+ <div class="dim-field">
56
+ <label for="input-thickness">{labels.thickness || "Thickness"}</label>
57
+ <div class="input-with-unit">
58
+ <input
59
+ type="number"
60
+ id="input-thickness"
61
+ min="5"
62
+ max="25"
63
+ value="12"
64
+ step="0.5"
65
+ />
66
+ <span class="dim-unit" id="unit-thickness">mm</span>
67
+ </div>
68
+ </div>
69
+ <div class="dim-field">
70
+ <label for="input-wrist">{labels.wristSize || "Wrist Size"}</label>
71
+ <div class="wrist-row">
72
+ <input
73
+ type="number"
74
+ id="input-wrist"
75
+ min="12"
76
+ max="25"
77
+ value="17"
78
+ step="0.5"
79
+ />
80
+ <div class="unit-toggle">
81
+ <button type="button" class="unit-btn active" id="btn-unit-cm">{labels.unitCm || "CM"}</button>
82
+ <button type="button" class="unit-btn" id="btn-unit-in">{labels.unitInches || "IN"}</button>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+
88
+ <div class="visualizer-wrapper">
89
+ <canvas id="size-canvas" width="480" height="260"></canvas>
90
+ </div>
91
+
92
+ <div class="legend-row">
93
+ <span class="legend-item">
94
+ <span class="legend-swatch" style="background:#22c55e"></span>
95
+ {labels.excellentFit || "Excellent Fit"}
96
+ </span>
97
+ <span class="legend-item">
98
+ <span class="legend-swatch" style="background:#86efac"></span>
99
+ {labels.goodFit || "Good Fit"}
100
+ </span>
101
+ <span class="legend-item">
102
+ <span class="legend-swatch" style="background:#facc15"></span>
103
+ {labels.borderlineFit || "Borderline"}
104
+ </span>
105
+ <span class="legend-item">
106
+ <span class="legend-swatch" style="background:#ef4444"></span>
107
+ {labels.largeFit || "Too Large"}
108
+ </span>
109
+ </div>
110
+
111
+ <div class="info-box" id="fit-info">
112
+ <strong>{labels.fitsWell || "Fits well"}</strong>-{
113
+ labels.excellentDesc ||
114
+ "Case diameter is proportional to your wrist. Lug-to-lug stays within your wrist width."
115
+ }
116
+ </div>
117
+
118
+ <div class="watch-list" id="watch-list">
119
+ <div class="empty-state">{labels.addWatch || "Add a watch"} ↑</div>
120
+ </div>
121
+ </div>
@@ -0,0 +1,79 @@
1
+ import { drawWatchBody } from './watch';
2
+ import { drawDimensionArrows, drawVertDimension } from './measures';
3
+ import { getFitColor, getFitRatio } from './utils';
4
+
5
+ export { getFitColor, getFitRatio };
6
+
7
+ interface LabelInfo {
8
+ primary: string; secondary: string; color: string; dimColor: string;
9
+ }
10
+
11
+ function u(obj: Record<string, string>, key: string, fallback: string): string {
12
+ return obj[key] || fallback;
13
+ }
14
+
15
+ function drawWarning(ctx: CanvasRenderingContext2D, cx: number, H: number, message: string): void {
16
+ ctx.save();
17
+ ctx.fillStyle = '#ef4444';
18
+ ctx.font = 'bold 11px sans-serif';
19
+ ctx.textAlign = 'center';
20
+ ctx.textBaseline = 'bottom';
21
+ ctx.fillText('\u26A0 ' + message, cx, H - 44);
22
+ ctx.restore();
23
+ }
24
+
25
+ function drawInfoLabel(ctx: CanvasRenderingContext2D, cx: number, y: number, info: LabelInfo): void {
26
+ ctx.save();
27
+ ctx.fillStyle = info.color;
28
+ ctx.font = 'bold 12px sans-serif';
29
+ ctx.textAlign = 'center';
30
+ ctx.textBaseline = 'top';
31
+ ctx.fillText(info.primary, cx, y);
32
+ ctx.fillStyle = info.dimColor;
33
+ ctx.font = '10px sans-serif';
34
+ ctx.fillText(info.secondary, cx, y + 16);
35
+ ctx.restore();
36
+ }
37
+
38
+ function d(mm: number, u: 'cm' | 'in'): string {
39
+ return u === 'in' ? (mm / 25.4).toFixed(1) : (Math.round(mm * 10) / 10).toFixed(1).replace(/\.0$/, '');
40
+ }
41
+ function du(u: 'cm' | 'in'): string {
42
+ return u === 'in' ? 'in' : 'mm';
43
+ }
44
+
45
+ export function drawWatch(p: { ctx: CanvasRenderingContext2D; canvas: HTMLCanvasElement; ui: Record<string, string>; diameter: number; l2l: number; thickness: number; wristCm: number; unit: 'cm' | 'in' }): void {
46
+ const { canvas, ui, diameter, l2l, thickness, wristCm, unit } = p;
47
+ const ctx = p.ctx, W = canvas.width, H = canvas.height;
48
+ const tooLargeMsg = u(ui, 'watchTooLarge', 'Watch extends beyond wrist');
49
+ const caseLabel = u(ui, 'caseDiameter', '').trim(), l2lLabel = u(ui, 'lugToLug', 'L2L').trim();
50
+ const wristLabel = u(ui, 'wristSize', 'wrist').trim(), thickLabel = u(ui, 'thickness', 'thick').trim();
51
+ ctx.clearRect(0, 0, W, H);
52
+
53
+ const wristWidthMm = (wristCm * 10) / Math.PI;
54
+ const maxDim = Math.max(diameter, l2l);
55
+ const scale = Math.min((W - 80) / maxDim, (H - 100) / l2l);
56
+
57
+ const cx = W / 2, cy = H / 2 + 8, r = (diameter / 2) * scale;
58
+ const l2lPx = l2l * scale, wristPx = wristWidthMm * scale;
59
+ const color = getFitColor(l2l, wristCm), isTooWide = l2lPx > wristPx;
60
+ const dimColor = 'rgba(128,128,128,0.5)', ratio = Math.round((l2l / wristWidthMm) * 100);
61
+
62
+ const wristDisplay = unit === 'in' ? (wristCm / 2.54).toFixed(1) + 'in' : wristCm + 'cm';
63
+
64
+ drawWatchBody(ctx, { cx, cy, r, l2lPx, color });
65
+
66
+ if (isTooWide) {
67
+ drawWarning(ctx, cx, H, tooLargeMsg);
68
+ }
69
+
70
+ drawDimensionArrows(ctx, { cx, y: cy + r + 22, halfW: r, label: `${d(diameter, unit)}${du(unit)} ${caseLabel}`.trim(), color });
71
+
72
+ if (Math.abs(l2lPx - r * 2) > 2) {
73
+ const dimX = cx + Math.max(wristPx / 2, l2lPx / 2) + 26;
74
+ drawVertDimension(ctx, { x: dimX, y1: cy - l2lPx / 2, y2: cy + l2lPx / 2, label: `${d(l2l, unit)}${du(unit)} ${l2lLabel}`.trim(), color: dimColor });
75
+ }
76
+
77
+ drawDimensionArrows(ctx, { cx, y: H - 14, halfW: wristPx / 2, label: `${wristDisplay} ${wristLabel}`.trim(), color: dimColor });
78
+ drawInfoLabel(ctx, cx, 8, { primary: `${d(diameter, unit)}\u00d7${d(l2l, unit)}\u00d7${d(thickness, unit)}${du(unit)}`, secondary: `${ratio}% ${wristLabel} width \u00b7 ${d(thickness, unit)}${du(unit)} ${thickLabel}`, color, dimColor });
79
+ }
@@ -0,0 +1,57 @@
1
+ export function drawDimensionArrows(ctx: CanvasRenderingContext2D, p: { cx: number; y: number; halfW: number; label: string; color: string }): void {
2
+ const { cx, y, halfW, label, color } = p;
3
+ const x1 = cx - halfW, x2 = cx + halfW;
4
+ ctx.save();
5
+ ctx.strokeStyle = color;
6
+ ctx.fillStyle = color;
7
+ ctx.lineWidth = 1;
8
+ ctx.font = '10px sans-serif';
9
+ ctx.textAlign = 'center';
10
+ ctx.textBaseline = 'top';
11
+ ctx.beginPath();
12
+ ctx.moveTo(x1, y);
13
+ ctx.lineTo(x2, y);
14
+ ctx.stroke();
15
+ const a = 4;
16
+ ctx.beginPath();
17
+ ctx.moveTo(x1, y - a);
18
+ ctx.lineTo(x1, y + a);
19
+ ctx.stroke();
20
+ ctx.beginPath();
21
+ ctx.moveTo(x2, y - a);
22
+ ctx.lineTo(x2, y + a);
23
+ ctx.stroke();
24
+ ctx.fillText(label, cx, y + 5);
25
+ ctx.restore();
26
+ }
27
+
28
+ export function drawVertDimension(ctx: CanvasRenderingContext2D, p: { x: number; y1: number; y2: number; label: string; color: string }): void {
29
+ const { x, y1, y2, label, color } = p;
30
+ ctx.save();
31
+ ctx.strokeStyle = color;
32
+ ctx.fillStyle = color;
33
+ ctx.lineWidth = 1;
34
+ ctx.font = '10px sans-serif';
35
+ ctx.textAlign = 'center';
36
+ ctx.beginPath();
37
+ ctx.moveTo(x, y1);
38
+ ctx.lineTo(x, y2);
39
+ ctx.stroke();
40
+ const a = 4;
41
+ ctx.beginPath();
42
+ ctx.moveTo(x - a, y1);
43
+ ctx.lineTo(x + a, y1);
44
+ ctx.stroke();
45
+ ctx.beginPath();
46
+ ctx.moveTo(x - a, y2);
47
+ ctx.lineTo(x + a, y2);
48
+ ctx.stroke();
49
+ ctx.save();
50
+ ctx.translate(x - 10, (y1 + y2) / 2);
51
+ ctx.rotate(-Math.PI / 2);
52
+ ctx.textAlign = 'center';
53
+ ctx.textBaseline = 'bottom';
54
+ ctx.fillText(label, 0, 0);
55
+ ctx.restore();
56
+ ctx.restore();
57
+ }
@@ -0,0 +1,37 @@
1
+ export interface RectConfig {
2
+ x: number; y: number; w: number; h: number; r: number;
3
+ }
4
+
5
+ export function roundRect(ctx: CanvasRenderingContext2D, p: RectConfig): void {
6
+ const { x, y, w, h, r } = p;
7
+ ctx.beginPath();
8
+ ctx.moveTo(x + r, y);
9
+ ctx.lineTo(x + w - r, y);
10
+ ctx.arcTo(x + w, y, x + w, y + r, r);
11
+ ctx.lineTo(x + w, y + h - r);
12
+ ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
13
+ ctx.lineTo(x + r, y + h);
14
+ ctx.arcTo(x, y + h, x, y + h - r, r);
15
+ ctx.lineTo(x, y + r);
16
+ ctx.arcTo(x, y, x + r, y, r);
17
+ ctx.closePath();
18
+ }
19
+
20
+ export function hexToRgba(hex: string, a: number): string {
21
+ const r = parseInt(hex.slice(1, 3), 16);
22
+ const g = parseInt(hex.slice(3, 5), 16);
23
+ const b = parseInt(hex.slice(5, 7), 16);
24
+ return `rgba(${r},${g},${b},${a})`;
25
+ }
26
+
27
+ export function getFitRatio(l2l: number, wristCm: number): number {
28
+ return l2l / (wristCm * 10);
29
+ }
30
+
31
+ export function getFitColor(l2l: number, wristCm: number): string {
32
+ const ratio = getFitRatio(l2l, wristCm);
33
+ if (ratio < 0.5) return '#22c55e';
34
+ if (ratio < 0.58) return '#86efac';
35
+ if (ratio < 0.65) return '#facc15';
36
+ return '#ef4444';
37
+ }
@@ -0,0 +1,78 @@
1
+ import { roundRect, hexToRgba } from './utils';
2
+ import type { RectConfig } from './utils';
3
+
4
+ function drawLugs(ctx: CanvasRenderingContext2D, p: { cx: number; cy: number; l2lPx: number; lugW: number; lugH: number; color: string }): void {
5
+ const { cx, cy, l2lPx, lugW, lugH, color } = p;
6
+ const lx = cx - lugW / 2;
7
+ const topLug: RectConfig = { x: lx, y: cy - l2lPx / 2, w: lugW, h: lugH, r: 2 };
8
+ const botLug: RectConfig = { x: lx, y: cy + l2lPx / 2 - lugH, w: lugW, h: lugH, r: 2 };
9
+
10
+ roundRect(ctx, topLug);
11
+ ctx.fillStyle = hexToRgba(color, 0.2);
12
+ ctx.fill();
13
+ ctx.strokeStyle = hexToRgba(color, 0.5);
14
+ ctx.lineWidth = 1.2;
15
+ ctx.stroke();
16
+
17
+ roundRect(ctx, botLug);
18
+ ctx.fillStyle = hexToRgba(color, 0.2);
19
+ ctx.fill();
20
+ ctx.strokeStyle = hexToRgba(color, 0.5);
21
+ ctx.lineWidth = 1.2;
22
+ ctx.stroke();
23
+ }
24
+
25
+ function drawWatchCase(ctx: CanvasRenderingContext2D, p: { cx: number; cy: number; r: number; color: string }): void {
26
+ const { cx, cy, r, color } = p;
27
+ ctx.save();
28
+ ctx.shadowColor = 'rgba(0,0,0,0.15)';
29
+ ctx.shadowBlur = 10;
30
+ ctx.shadowOffsetY = 2;
31
+ ctx.beginPath();
32
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
33
+ ctx.fillStyle = hexToRgba(color, 0.15);
34
+ ctx.fill();
35
+ ctx.strokeStyle = color;
36
+ ctx.lineWidth = 2.5;
37
+ ctx.stroke();
38
+ ctx.restore();
39
+ }
40
+
41
+ function drawDial(ctx: CanvasRenderingContext2D, p: { cx: number; cy: number; r: number; color: string }): void {
42
+ const { cx, cy, r, color } = p;
43
+ ctx.save();
44
+ ctx.beginPath();
45
+ ctx.arc(cx, cy, r * 0.75, 0, Math.PI * 2);
46
+ ctx.fillStyle = hexToRgba(color, 0.08);
47
+ ctx.fill();
48
+ ctx.strokeStyle = hexToRgba(color, 0.3);
49
+ ctx.lineWidth = 0.5;
50
+ ctx.stroke();
51
+ ctx.restore();
52
+
53
+ for (let i = 0; i < 12; i++) {
54
+ const a = (i * 30 - 90) * Math.PI / 180;
55
+ const outer = r * 0.7, inner = r * 0.62;
56
+ ctx.beginPath();
57
+ ctx.moveTo(cx + Math.cos(a) * inner, cy + Math.sin(a) * inner);
58
+ ctx.lineTo(cx + Math.cos(a) * outer, cy + Math.sin(a) * outer);
59
+ ctx.strokeStyle = hexToRgba(color, 0.4);
60
+ ctx.lineWidth = i % 3 === 0 ? 1.5 : 0.8;
61
+ ctx.stroke();
62
+ }
63
+
64
+ ctx.beginPath();
65
+ ctx.arc(cx, cy, 2, 0, Math.PI * 2);
66
+ ctx.fillStyle = hexToRgba(color, 0.7);
67
+ ctx.fill();
68
+ }
69
+
70
+ export function drawWatchBody(ctx: CanvasRenderingContext2D, p: { cx: number; cy: number; r: number; l2lPx: number; color: string }): void {
71
+ const { cx, cy, r, l2lPx, color } = p;
72
+ const lugH = (l2lPx - r * 2) / 2;
73
+ if (lugH > 0) {
74
+ drawLugs(ctx, { cx, cy, l2lPx, lugW: r * 0.6, lugH, color });
75
+ }
76
+ drawWatchCase(ctx, { cx, cy, r, color });
77
+ drawDial(ctx, { cx, cy, r, color });
78
+ }