@jjlmoya/utils-chrono 1.5.0 → 1.7.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 (64) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +4 -0
  3. package/src/entries.ts +7 -1
  4. package/src/index.ts +2 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/service-interval-tracker/bibliography.astro +16 -0
  8. package/src/tool/service-interval-tracker/bibliography.ts +12 -0
  9. package/src/tool/service-interval-tracker/client.ts +233 -0
  10. package/src/tool/service-interval-tracker/component.astro +50 -0
  11. package/src/tool/service-interval-tracker/components/AddEditModal.astro +75 -0
  12. package/src/tool/service-interval-tracker/components/DashboardHeader.astro +14 -0
  13. package/src/tool/service-interval-tracker/components/EmptyState.astro +26 -0
  14. package/src/tool/service-interval-tracker/entry.ts +56 -0
  15. package/src/tool/service-interval-tracker/helpers.ts +82 -0
  16. package/src/tool/service-interval-tracker/i18n/de.ts +117 -0
  17. package/src/tool/service-interval-tracker/i18n/en.ts +170 -0
  18. package/src/tool/service-interval-tracker/i18n/es.ts +117 -0
  19. package/src/tool/service-interval-tracker/i18n/fr.ts +98 -0
  20. package/src/tool/service-interval-tracker/i18n/id.ts +89 -0
  21. package/src/tool/service-interval-tracker/i18n/it.ts +88 -0
  22. package/src/tool/service-interval-tracker/i18n/ja.ts +88 -0
  23. package/src/tool/service-interval-tracker/i18n/ko.ts +88 -0
  24. package/src/tool/service-interval-tracker/i18n/nl.ts +88 -0
  25. package/src/tool/service-interval-tracker/i18n/pl.ts +88 -0
  26. package/src/tool/service-interval-tracker/i18n/pt.ts +88 -0
  27. package/src/tool/service-interval-tracker/i18n/ru.ts +88 -0
  28. package/src/tool/service-interval-tracker/i18n/sv.ts +88 -0
  29. package/src/tool/service-interval-tracker/i18n/tr.ts +88 -0
  30. package/src/tool/service-interval-tracker/i18n/zh.ts +88 -0
  31. package/src/tool/service-interval-tracker/index.ts +11 -0
  32. package/src/tool/service-interval-tracker/renderer.ts +91 -0
  33. package/src/tool/service-interval-tracker/seo.astro +16 -0
  34. package/src/tool/service-interval-tracker/service-interval-tracker.css +767 -0
  35. package/src/tool/service-interval-tracker/utils.ts +58 -0
  36. package/src/tool/strap-length-calculator/bibliography.astro +16 -0
  37. package/src/tool/strap-length-calculator/bibliography.ts +12 -0
  38. package/src/tool/strap-length-calculator/client.ts +151 -0
  39. package/src/tool/strap-length-calculator/component.astro +46 -0
  40. package/src/tool/strap-length-calculator/components/BespokeResults.astro +60 -0
  41. package/src/tool/strap-length-calculator/components/CalculatorInputs.astro +64 -0
  42. package/src/tool/strap-length-calculator/components/Visualizer.astro +8 -0
  43. package/src/tool/strap-length-calculator/drawing.ts +265 -0
  44. package/src/tool/strap-length-calculator/entry.ts +52 -0
  45. package/src/tool/strap-length-calculator/helpers.ts +110 -0
  46. package/src/tool/strap-length-calculator/i18n/de.ts +311 -0
  47. package/src/tool/strap-length-calculator/i18n/en.ts +311 -0
  48. package/src/tool/strap-length-calculator/i18n/es.ts +311 -0
  49. package/src/tool/strap-length-calculator/i18n/fr.ts +311 -0
  50. package/src/tool/strap-length-calculator/i18n/id.ts +86 -0
  51. package/src/tool/strap-length-calculator/i18n/it.ts +311 -0
  52. package/src/tool/strap-length-calculator/i18n/ja.ts +86 -0
  53. package/src/tool/strap-length-calculator/i18n/ko.ts +86 -0
  54. package/src/tool/strap-length-calculator/i18n/nl.ts +311 -0
  55. package/src/tool/strap-length-calculator/i18n/pl.ts +311 -0
  56. package/src/tool/strap-length-calculator/i18n/pt.ts +311 -0
  57. package/src/tool/strap-length-calculator/i18n/ru.ts +86 -0
  58. package/src/tool/strap-length-calculator/i18n/sv.ts +86 -0
  59. package/src/tool/strap-length-calculator/i18n/tr.ts +86 -0
  60. package/src/tool/strap-length-calculator/i18n/zh.ts +86 -0
  61. package/src/tool/strap-length-calculator/index.ts +11 -0
  62. package/src/tool/strap-length-calculator/seo.astro +16 -0
  63. package/src/tool/strap-length-calculator/strap-length-calculator.css +234 -0
  64. package/src/tools.ts +4 -0
@@ -0,0 +1,58 @@
1
+ export interface WatchEntry {
2
+ id: string;
3
+ name: string;
4
+ movement: 'automatic' | 'manual' | 'quartz' | 'kinetic';
5
+ lastService: string | null;
6
+ intervalYears: number;
7
+ notes: string;
8
+ createdAt: string;
9
+ }
10
+
11
+ export type WatchStatus = 'healthy' | 'due' | 'overdue' | 'unknown';
12
+ export type MovementType = 'automatic' | 'manual' | 'quartz' | 'kinetic';
13
+
14
+ export function generateId(): string {
15
+ return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
16
+ }
17
+
18
+ export function getStatus(w: WatchEntry): WatchStatus {
19
+ if (!w.lastService) return 'unknown';
20
+ const last = new Date(w.lastService).getTime();
21
+ const next = last + w.intervalYears * 31536000000;
22
+ const now = Date.now();
23
+ const sixMonths = 180 * 86400000;
24
+ if (next < now) return 'overdue';
25
+ if (next - now < sixMonths) return 'due';
26
+ return 'healthy';
27
+ }
28
+
29
+ export function formatDate(iso: string): string {
30
+ return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
31
+ }
32
+
33
+ export function daysFrom(iso: string): number {
34
+ return Math.round((Date.now() - new Date(iso).getTime()) / 86400000);
35
+ }
36
+
37
+ export function progressPct(w: WatchEntry): number {
38
+ if (!w.lastService) return 0;
39
+ const elapsed = Date.now() - new Date(w.lastService).getTime();
40
+ const total = w.intervalYears * 31536000000;
41
+ return Math.min(100, Math.round((elapsed / total) * 100));
42
+ }
43
+
44
+ export function nextServiceDate(w: WatchEntry): string | null {
45
+ if (!w.lastService) return null;
46
+ const d = new Date(new Date(w.lastService).getTime() + w.intervalYears * 31536000000);
47
+ return d.toISOString().split('T')[0];
48
+ }
49
+
50
+ export function movementIcon(type: MovementType): string {
51
+ const icons: Record<MovementType, string> = {
52
+ automatic: '\u2699',
53
+ manual: '\u23F0',
54
+ quartz: '\u26A1',
55
+ kinetic: '\u21BB',
56
+ };
57
+ return icons[type] || '\u2699';
58
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { strapLengthCalculator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const loader = strapLengthCalculator.i18n[locale] || strapLengthCalculator.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Delugs Watch Strap Size Guide',
6
+ url: 'https://delugs.com/pages/strap-length-guide',
7
+ },
8
+ {
9
+ name: 'Strapcode Watch Band Size Guide',
10
+ url: 'https://www.strapcode.com/pages/watchband-size-guide',
11
+ },
12
+ ];
@@ -0,0 +1,151 @@
1
+ import { calculateStrap, toMillimeters, formatLength } from './helpers';
2
+ import { drawFlatLayout, drawWristProfile } from './drawing';
3
+
4
+ const container = document.querySelector('.str') as HTMLElement;
5
+ const UI = JSON.parse(container.dataset.ui || '{}');
6
+
7
+ const wristIn = document.getElementById('str-wrist') as HTMLInputElement;
8
+ const lugIn = document.getElementById('str-lug') as HTMLInputElement;
9
+ const fitIn = document.getElementById('str-fit') as HTMLSelectElement;
10
+ const unitIn = document.getElementById('str-unit') as HTMLSelectElement;
11
+ const wristUnitLabel = document.getElementById('str-wrist-unit-label') as HTMLElement;
12
+
13
+ const standardVal = document.getElementById('str-standard-val') as HTMLElement;
14
+ const standardDesc = document.getElementById('str-standard-desc') as HTMLElement;
15
+ const bespokeLong = document.getElementById('str-bespoke-long') as HTMLElement;
16
+ const bespokeShort = document.getElementById('str-bespoke-short') as HTMLElement;
17
+ const natoVal = document.getElementById('str-nato-val') as HTMLElement;
18
+
19
+ const svgFlat = document.getElementById('str-svg-flat') as unknown as SVGSVGElement;
20
+ const svgWrist = document.getElementById('str-svg-wrist') as unknown as SVGSVGElement;
21
+
22
+ const STORAGE_KEY = 'jjlmoya_chrono_strap_calc';
23
+
24
+ let currentUnit: 'mm' | 'in' = 'mm';
25
+
26
+ interface StrapState {
27
+ wrist: string;
28
+ lug: string;
29
+ fit: string;
30
+ unit: string;
31
+ }
32
+
33
+ function saveState(): void {
34
+ const state: StrapState = {
35
+ wrist: wristIn.value,
36
+ lug: lugIn.value,
37
+ fit: fitIn.value,
38
+ unit: unitIn.value,
39
+ };
40
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
41
+ }
42
+
43
+ function setUnitDisplay(unit: 'mm' | 'in'): void {
44
+ currentUnit = unit;
45
+ if (unit === 'in') {
46
+ wristIn.min = '4.5';
47
+ wristIn.max = '9.5';
48
+ wristIn.step = '0.05';
49
+ wristUnitLabel.textContent = UI.inches || 'in';
50
+ } else {
51
+ wristIn.min = '110';
52
+ wristIn.max = '240';
53
+ wristIn.step = '1';
54
+ wristUnitLabel.textContent = UI.millimeters || 'mm';
55
+ }
56
+ }
57
+
58
+ function loadState(): void {
59
+ let raw: string | null;
60
+ try {
61
+ raw = localStorage.getItem(STORAGE_KEY);
62
+ } catch {
63
+ return;
64
+ }
65
+ if (!raw) return;
66
+ let state: StrapState;
67
+ try {
68
+ state = JSON.parse(raw);
69
+ } catch {
70
+ return;
71
+ }
72
+
73
+ const fits = ['tight', 'regular', 'loose'] as const;
74
+ const units = ['mm', 'in'] as const;
75
+ if (state.wrist) wristIn.value = state.wrist;
76
+ if (state.lug) lugIn.value = state.lug;
77
+ if (fits.includes(state.fit as typeof fits[number])) fitIn.value = state.fit;
78
+ if (units.includes(state.unit as typeof units[number])) {
79
+ unitIn.value = state.unit;
80
+ setUnitDisplay(state.unit);
81
+ }
82
+ }
83
+
84
+ function update() {
85
+ const wrist = parseFloat(wristIn.value) || 170;
86
+ const lug = parseFloat(lugIn.value) || 45;
87
+ const fit = fitIn.value as 'tight' | 'regular' | 'loose';
88
+ const unit = unitIn.value as 'mm' | 'in';
89
+
90
+ const res = calculateStrap({ wrist, lug, fit, unit });
91
+
92
+ standardVal.textContent = res.standardText;
93
+ standardDesc.textContent = UI[`size${res.sizeKey.toUpperCase()}`] || res.sizeKey;
94
+ bespokeLong.textContent = formatLength(res.bespokeLong, unit);
95
+ bespokeShort.textContent = formatLength(res.bespokeShort, unit);
96
+ natoVal.textContent = res.natoText;
97
+
98
+ const wristMm = toMillimeters(wrist, unit);
99
+ let fitAllowance = 37;
100
+ if (fit === 'tight') {
101
+ fitAllowance = 32;
102
+ } else if (fit === 'loose') {
103
+ fitAllowance = 42;
104
+ }
105
+ const totalStrap = wristMm - lug + fitAllowance;
106
+
107
+ drawFlatLayout({
108
+ svg: svgFlat,
109
+ wrist: wristMm,
110
+ lug,
111
+ totalStrap,
112
+ bespokeLong: res.bespokeLong,
113
+ bespokeShort: res.bespokeShort,
114
+ });
115
+ drawWristProfile(svgWrist, wristMm, lug);
116
+
117
+ saveState();
118
+ }
119
+
120
+ function handleUnitChange() {
121
+ const nextUnit = unitIn.value as 'mm' | 'in';
122
+ if (nextUnit === currentUnit) {
123
+ return;
124
+ }
125
+
126
+ const prevVal = parseFloat(wristIn.value) || 170;
127
+ if (nextUnit === 'in') {
128
+ wristIn.min = '4.5';
129
+ wristIn.max = '9.5';
130
+ wristIn.step = '0.05';
131
+ wristIn.value = (prevVal / 25.4).toFixed(2);
132
+ wristUnitLabel.textContent = UI.inches || 'in';
133
+ } else {
134
+ wristIn.min = '110';
135
+ wristIn.max = '240';
136
+ wristIn.step = '1';
137
+ wristIn.value = Math.round(prevVal * 25.4).toString();
138
+ wristUnitLabel.textContent = UI.millimeters || 'mm';
139
+ }
140
+
141
+ currentUnit = nextUnit;
142
+ update();
143
+ }
144
+
145
+ wristIn.addEventListener('input', update);
146
+ lugIn.addEventListener('input', update);
147
+ fitIn.addEventListener('change', update);
148
+ unitIn.addEventListener('change', handleUnitChange);
149
+
150
+ loadState();
151
+ update();
@@ -0,0 +1,46 @@
1
+ ---
2
+ import CalculatorInputs from './components/CalculatorInputs.astro';
3
+ import Visualizer from './components/Visualizer.astro';
4
+ import BespokeResults from './components/BespokeResults.astro';
5
+
6
+ interface Props {
7
+ ui: Record<string, string>;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ ---
12
+
13
+ <div class="str" data-ui={JSON.stringify(ui)}>
14
+ <header class="str-top">
15
+ <h2 class="str-h">{ui.title || "Strap Length Calculator"}</h2>
16
+ </header>
17
+
18
+ <div class="str-body">
19
+ <CalculatorInputs
20
+ wristLabel={ui.wristLabel || "Wrist circumference"}
21
+ wristPlaceholder={ui.wristPlaceholder || "e.g. 170"}
22
+ lugLabel={ui.lugLabel || "Watch lug-to-lug"}
23
+ lugPlaceholder={ui.lugPlaceholder || "e.g. 45"}
24
+ fitLabel={ui.fitLabel || "Preferred fit"}
25
+ fitTight={ui.fitTight || "Tight"}
26
+ fitRegular={ui.fitRegular || "Regular"}
27
+ fitLoose={ui.fitLoose || "Loose"}
28
+ unitLabel={ui.unitLabel || "Unit"}
29
+ millimeters={ui.millimeters || "mm"}
30
+ inches={ui.inches || "in"}
31
+ />
32
+
33
+ <Visualizer />
34
+
35
+ <BespokeResults
36
+ standardLabel={ui.standardLabel || "Standard retail size"}
37
+ bespokeLabel={ui.bespokeLabel || "Bespoke custom size"}
38
+ natoLabel={ui.natoLabel || "NATO strap length"}
39
+ longSide={ui.longSide || "Long side"}
40
+ shortSide={ui.shortSide || "Short side"}
41
+ totalLength={ui.totalLength || "Total length"}
42
+ />
43
+ </div>
44
+ </div>
45
+
46
+ <script src="./client.ts"></script>
@@ -0,0 +1,60 @@
1
+ ---
2
+ interface Props {
3
+ standardLabel: string;
4
+ bespokeLabel: string;
5
+ natoLabel: string;
6
+ longSide: string;
7
+ shortSide: string;
8
+ totalLength: string;
9
+ }
10
+
11
+ const {
12
+ standardLabel,
13
+ bespokeLabel,
14
+ natoLabel,
15
+ longSide,
16
+ shortSide,
17
+ } = Astro.props;
18
+ ---
19
+
20
+ <div class="str-results">
21
+ <div class="str-result-card">
22
+ <div class="str-result-icon">
23
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
24
+ </div>
25
+ <div class="str-result-info">
26
+ <span class="str-result-title">{standardLabel}</span>
27
+ <span class="str-result-value" id="str-standard-val">—</span>
28
+ <span class="str-result-desc" id="str-standard-desc">—</span>
29
+ </div>
30
+ </div>
31
+
32
+ <div class="str-result-card str-result-bespoke">
33
+ <div class="str-result-icon">
34
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
35
+ </div>
36
+ <div class="str-result-info">
37
+ <span class="str-result-title">{bespokeLabel}</span>
38
+ <div class="str-result-split-values">
39
+ <div class="str-split-item">
40
+ <span class="str-split-lbl">{longSide}</span>
41
+ <span class="str-split-val" id="str-bespoke-long">—</span>
42
+ </div>
43
+ <div class="str-split-item">
44
+ <span class="str-split-lbl">{shortSide}</span>
45
+ <span class="str-split-val" id="str-bespoke-short">—</span>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="str-result-card">
52
+ <div class="str-result-icon">
53
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
54
+ </div>
55
+ <div class="str-result-info">
56
+ <span class="str-result-title">{natoLabel}</span>
57
+ <span class="str-result-value" id="str-nato-val">—</span>
58
+ </div>
59
+ </div>
60
+ </div>
@@ -0,0 +1,64 @@
1
+ ---
2
+ interface Props {
3
+ wristLabel: string;
4
+ wristPlaceholder: string;
5
+ lugLabel: string;
6
+ lugPlaceholder: string;
7
+ fitLabel: string;
8
+ fitTight: string;
9
+ fitRegular: string;
10
+ fitLoose: string;
11
+ unitLabel: string;
12
+ millimeters: string;
13
+ inches: string;
14
+ }
15
+
16
+ const {
17
+ wristLabel,
18
+ wristPlaceholder,
19
+ lugLabel,
20
+ lugPlaceholder,
21
+ fitLabel,
22
+ fitTight,
23
+ fitRegular,
24
+ fitLoose,
25
+ unitLabel,
26
+ millimeters,
27
+ inches,
28
+ } = Astro.props;
29
+ ---
30
+
31
+ <div class="str-inputs">
32
+ <div class="str-field">
33
+ <label class="str-label" for="str-wrist">{wristLabel}</label>
34
+ <div class="str-input-wrapper">
35
+ <input class="str-input" id="str-wrist" type="number" min="100" max="300" step="1" value="170" placeholder={wristPlaceholder} />
36
+ <span class="str-input-unit" id="str-wrist-unit-label">{millimeters}</span>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="str-field">
41
+ <label class="str-label" for="str-lug">{lugLabel}</label>
42
+ <div class="str-input-wrapper">
43
+ <input class="str-input" id="str-lug" type="number" min="20" max="70" step="1" value="45" placeholder={lugPlaceholder} />
44
+ <span class="str-input-unit">{millimeters}</span>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="str-field">
49
+ <label class="str-label" for="str-fit">{fitLabel}</label>
50
+ <select class="str-input str-select" id="str-fit">
51
+ <option value="tight">{fitTight}</option>
52
+ <option value="regular" selected>{fitRegular}</option>
53
+ <option value="loose">{fitLoose}</option>
54
+ </select>
55
+ </div>
56
+
57
+ <div class="str-field">
58
+ <label class="str-label" for="str-unit">{unitLabel}</label>
59
+ <select class="str-input str-select" id="str-unit">
60
+ <option value="mm" selected>{millimeters}</option>
61
+ <option value="in">{inches}</option>
62
+ </select>
63
+ </div>
64
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="str-visualizer-container">
2
+ <div class="str-vis-block">
3
+ <svg id="str-svg-flat" viewBox="0 0 600 120" width="100%" height="auto" class="str-svg"></svg>
4
+ </div>
5
+ <div class="str-vis-block">
6
+ <svg id="str-svg-wrist" viewBox="0 0 300 200" width="100%" height="auto" class="str-svg"></svg>
7
+ </div>
8
+ </div>
@@ -0,0 +1,265 @@
1
+ function createSvgElement(tag: string, attrs: Record<string, string>): SVGElement {
2
+ const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
3
+ Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
4
+ return el;
5
+ }
6
+
7
+ export interface FlatLayoutParams {
8
+ svg: SVGSVGElement;
9
+ wrist: number;
10
+ lug: number;
11
+ totalStrap: number;
12
+ bespokeLong: number;
13
+ bespokeShort: number;
14
+ }
15
+
16
+ function drawShortStrap(
17
+ svg: SVGSVGElement,
18
+ startX: number,
19
+ centerY: number,
20
+ shortWidth: number,
21
+ ): void {
22
+ svg.appendChild(createSvgElement('rect', {
23
+ x: startX.toString(),
24
+ y: (centerY - 12).toString(),
25
+ width: shortWidth.toString(),
26
+ height: '24',
27
+ rx: '4',
28
+ fill: 'rgba(160, 110, 80, 0.85)',
29
+ stroke: 'rgba(160, 110, 80, 0.95)',
30
+ 'stroke-width': '1.5',
31
+ }));
32
+ svg.appendChild(createSvgElement('rect', {
33
+ x: (startX - 14).toString(),
34
+ y: (centerY - 16).toString(),
35
+ width: '14',
36
+ height: '32',
37
+ rx: '3',
38
+ fill: 'var(--border-color, #ccc)',
39
+ stroke: 'var(--text-base, #888)',
40
+ 'stroke-width': '1.5',
41
+ }));
42
+ }
43
+
44
+ function drawWatchCase(
45
+ svg: SVGSVGElement,
46
+ watchX: number,
47
+ centerY: number,
48
+ watchWidth: number,
49
+ ): void {
50
+ svg.appendChild(createSvgElement('rect', {
51
+ x: watchX.toString(),
52
+ y: (centerY - 22).toString(),
53
+ width: watchWidth.toString(),
54
+ height: '44',
55
+ rx: '22',
56
+ fill: 'rgba(230, 230, 235, 0.15)',
57
+ stroke: 'var(--border-color, #bbb)',
58
+ 'stroke-width': '2',
59
+ }));
60
+ svg.appendChild(createSvgElement('circle', {
61
+ cx: (watchX + watchWidth / 2).toString(),
62
+ cy: centerY.toString(),
63
+ r: '16',
64
+ fill: 'rgba(255, 255, 255, 0.05)',
65
+ stroke: 'var(--border-color, #bbb)',
66
+ 'stroke-width': '1.5',
67
+ }));
68
+ }
69
+
70
+ function drawLongStrap(
71
+ svg: SVGSVGElement,
72
+ longX: number,
73
+ centerY: number,
74
+ longWidth: number,
75
+ ): void {
76
+ svg.appendChild(createSvgElement('rect', {
77
+ x: longX.toString(),
78
+ y: (centerY - 12).toString(),
79
+ width: longWidth.toString(),
80
+ height: '24',
81
+ rx: '4',
82
+ fill: 'rgba(160, 110, 80, 0.85)',
83
+ stroke: 'rgba(160, 110, 80, 0.95)',
84
+ 'stroke-width': '1.5',
85
+ }));
86
+ }
87
+
88
+ function drawStrapHoles(
89
+ svg: SVGSVGElement,
90
+ longX: number,
91
+ centerY: number,
92
+ holeParams: { scale: number; longWidth: number },
93
+ ): void {
94
+ const holeCount = 7;
95
+ const holeSpacing = 6.5 * holeParams.scale;
96
+ const firstHoleOffset = 35 * holeParams.scale;
97
+ for (let i = 0; i < holeCount; i++) {
98
+ const hx = longX + firstHoleOffset + (i * holeSpacing);
99
+ if (hx < longX + holeParams.longWidth - 8) {
100
+ svg.appendChild(createSvgElement('circle', {
101
+ cx: hx.toString(),
102
+ cy: centerY.toString(),
103
+ r: '2',
104
+ fill: 'rgba(0, 0, 0, 0.4)',
105
+ }));
106
+ }
107
+ }
108
+ }
109
+
110
+ function drawPinIndicator(
111
+ svg: SVGSVGElement,
112
+ pinX: number,
113
+ label: string,
114
+ ): void {
115
+ svg.appendChild(createSvgElement('line', {
116
+ x1: pinX.toString(),
117
+ y1: '15',
118
+ x2: pinX.toString(),
119
+ y2: '105',
120
+ stroke: 'var(--accent, #f43f5e)',
121
+ 'stroke-width': '2',
122
+ 'stroke-dasharray': '4 3',
123
+ }));
124
+ const pinLabel = createSvgElement('text', {
125
+ x: pinX.toString(),
126
+ y: '10',
127
+ fill: 'var(--accent, #f43f5e)',
128
+ 'font-size': '10',
129
+ 'font-weight': '700',
130
+ 'text-anchor': 'middle',
131
+ });
132
+ pinLabel.textContent = label;
133
+ svg.appendChild(pinLabel);
134
+ }
135
+
136
+ export function drawFlatLayout(params: FlatLayoutParams): void {
137
+ const { svg, lug, bespokeLong, bespokeShort, totalStrap } = params;
138
+ svg.innerHTML = '';
139
+
140
+ const scale = 2.2;
141
+ const centerY = 60;
142
+ const watchWidth = lug * scale;
143
+ const shortWidth = bespokeShort * scale;
144
+ const longWidth = bespokeLong * scale;
145
+
146
+ const startX = 300 - (watchWidth / 2) - shortWidth;
147
+ const watchX = startX + shortWidth;
148
+ const longX = watchX + watchWidth;
149
+
150
+ drawShortStrap(svg, startX, centerY, shortWidth);
151
+ drawWatchCase(svg, watchX, centerY, watchWidth);
152
+ drawLongStrap(svg, longX, centerY, longWidth);
153
+ drawStrapHoles(svg, longX, centerY, { scale, longWidth });
154
+
155
+ const pinDistance = totalStrap - bespokeShort;
156
+ const pinX = longX + (pinDistance * scale);
157
+ drawPinIndicator(svg, pinX, 'Active Fit');
158
+ }
159
+
160
+ function drawWristBackground(
161
+ svg: SVGSVGElement,
162
+ cx: number,
163
+ cy: number,
164
+ dims: { rx: number; ry: number },
165
+ ): void {
166
+ svg.appendChild(createSvgElement('ellipse', {
167
+ cx: cx.toString(),
168
+ cy: cy.toString(),
169
+ rx: dims.rx.toString(),
170
+ ry: dims.ry.toString(),
171
+ fill: 'rgba(230, 230, 235, 0.05)',
172
+ stroke: 'var(--border-color, #ccc)',
173
+ 'stroke-width': '1.5',
174
+ 'stroke-dasharray': '4 4',
175
+ }));
176
+ }
177
+
178
+ function drawWristWatchHead(
179
+ svg: SVGSVGElement,
180
+ cx: number,
181
+ watchParams: { watchX: number; watchY: number; watchWidth: number },
182
+ ): void {
183
+ const { watchX, watchY, watchWidth } = watchParams;
184
+ svg.appendChild(createSvgElement('rect', {
185
+ x: watchX.toString(),
186
+ y: (watchY - 6).toString(),
187
+ width: watchWidth.toString(),
188
+ height: '12',
189
+ rx: '3',
190
+ fill: 'rgba(230, 230, 235, 0.15)',
191
+ stroke: 'var(--border-color, #bbb)',
192
+ 'stroke-width': '2',
193
+ }));
194
+ svg.appendChild(createSvgElement('ellipse', {
195
+ cx: cx.toString(),
196
+ cy: (watchY - 6).toString(),
197
+ rx: (watchWidth * 0.35).toString(),
198
+ ry: '5',
199
+ fill: 'rgba(255, 255, 255, 0.05)',
200
+ stroke: 'var(--border-color, #bbb)',
201
+ 'stroke-width': '1.5',
202
+ }));
203
+ }
204
+
205
+ function drawWristStraps(
206
+ svg: SVGSVGElement,
207
+ cx: number,
208
+ cy: number,
209
+ strapParams: { watchX: number; watchY: number; watchWidth: number; rx: number; ry: number },
210
+ ): void {
211
+ const { watchX, watchY, watchWidth, rx, ry } = strapParams;
212
+ svg.appendChild(createSvgElement('path', {
213
+ d: `M ${watchX} ${watchY} Q ${cx - rx - 10} ${cy - 10} ${cx} ${cy + ry}`,
214
+ fill: 'none',
215
+ stroke: 'rgba(160, 110, 80, 0.85)',
216
+ 'stroke-width': '4',
217
+ }));
218
+ svg.appendChild(createSvgElement('path', {
219
+ d: `M ${watchX + watchWidth} ${watchY} Q ${cx + rx + 10} ${cy - 10} ${cx} ${cy + ry}`,
220
+ fill: 'none',
221
+ stroke: 'rgba(160, 110, 80, 0.85)',
222
+ 'stroke-width': '4',
223
+ }));
224
+ }
225
+
226
+ function drawWristBuckle(
227
+ svg: SVGSVGElement,
228
+ cx: number,
229
+ cy: number,
230
+ ry: number,
231
+ ): void {
232
+ svg.appendChild(createSvgElement('rect', {
233
+ x: (cx - 6).toString(),
234
+ y: (cy + ry - 4).toString(),
235
+ width: '12',
236
+ height: '8',
237
+ rx: '1.5',
238
+ fill: 'var(--border-color, #ccc)',
239
+ stroke: 'var(--text-base, #888)',
240
+ 'stroke-width': '1',
241
+ }));
242
+ }
243
+
244
+ export function drawWristProfile(
245
+ svg: SVGSVGElement,
246
+ wrist: number,
247
+ lug: number,
248
+ ): void {
249
+ svg.innerHTML = '';
250
+
251
+ const cx = 150;
252
+ const cy = 100;
253
+ const rx = 85;
254
+ const ry = 52;
255
+
256
+ drawWristBackground(svg, cx, cy, { rx, ry });
257
+
258
+ const watchY = cy - ry;
259
+ const watchWidth = Math.min(80, lug * 1.5);
260
+ const watchX = cx - watchWidth / 2;
261
+
262
+ drawWristWatchHead(svg, cx, { watchX, watchY, watchWidth });
263
+ drawWristStraps(svg, cx, cy, { watchX, watchY, watchWidth, rx, ry });
264
+ drawWristBuckle(svg, cx, cy, ry);
265
+ }