@jjlmoya/utils-textiles 1.1.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 (134) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +76 -0
  3. package/src/category/i18n/es.ts +76 -0
  4. package/src/category/i18n/fr.ts +73 -0
  5. package/src/category/index.ts +17 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +11 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +32 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +253 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/seo_length.test.ts +22 -0
  21. package/src/tests/title_separators.test.ts +40 -0
  22. package/src/tests/tool_validation.test.ts +17 -0
  23. package/src/tool/burnTest/bibliography.astro +34 -0
  24. package/src/tool/burnTest/component.astro +602 -0
  25. package/src/tool/burnTest/i18n/en.ts +225 -0
  26. package/src/tool/burnTest/i18n/es.ts +225 -0
  27. package/src/tool/burnTest/i18n/fr.ts +225 -0
  28. package/src/tool/burnTest/index.ts +34 -0
  29. package/src/tool/burnTest/logic.ts +3 -0
  30. package/src/tool/burnTest/seo.astro +40 -0
  31. package/src/tool/burnTest/ui.ts +36 -0
  32. package/src/tool/clothingSizeConverter/bibliography.astro +34 -0
  33. package/src/tool/clothingSizeConverter/component.astro +1226 -0
  34. package/src/tool/clothingSizeConverter/i18n/en.ts +356 -0
  35. package/src/tool/clothingSizeConverter/i18n/es.ts +356 -0
  36. package/src/tool/clothingSizeConverter/i18n/fr.ts +356 -0
  37. package/src/tool/clothingSizeConverter/index.ts +34 -0
  38. package/src/tool/clothingSizeConverter/logic.ts +30 -0
  39. package/src/tool/clothingSizeConverter/seo.astro +40 -0
  40. package/src/tool/clothingSizeConverter/ui.ts +70 -0
  41. package/src/tool/fabricProjectCalculator/bibliography.astro +19 -0
  42. package/src/tool/fabricProjectCalculator/component.astro +914 -0
  43. package/src/tool/fabricProjectCalculator/i18n/en.ts +250 -0
  44. package/src/tool/fabricProjectCalculator/i18n/es.ts +250 -0
  45. package/src/tool/fabricProjectCalculator/i18n/fr.ts +250 -0
  46. package/src/tool/fabricProjectCalculator/index.ts +34 -0
  47. package/src/tool/fabricProjectCalculator/logic.ts +89 -0
  48. package/src/tool/fabricProjectCalculator/seo.astro +40 -0
  49. package/src/tool/fabricProjectCalculator/ui.ts +57 -0
  50. package/src/tool/fabricTruth/bibliography.astro +40 -0
  51. package/src/tool/fabricTruth/component.astro +708 -0
  52. package/src/tool/fabricTruth/i18n/en.ts +282 -0
  53. package/src/tool/fabricTruth/i18n/es.ts +316 -0
  54. package/src/tool/fabricTruth/i18n/fr.ts +282 -0
  55. package/src/tool/fabricTruth/index.ts +34 -0
  56. package/src/tool/fabricTruth/logic.ts +91 -0
  57. package/src/tool/fabricTruth/seo.astro +40 -0
  58. package/src/tool/fabricTruth/ui.ts +28 -0
  59. package/src/tool/fiberPrep/bibliography.astro +33 -0
  60. package/src/tool/fiberPrep/component.astro +987 -0
  61. package/src/tool/fiberPrep/i18n/en.ts +225 -0
  62. package/src/tool/fiberPrep/i18n/es.ts +225 -0
  63. package/src/tool/fiberPrep/i18n/fr.ts +225 -0
  64. package/src/tool/fiberPrep/index.ts +34 -0
  65. package/src/tool/fiberPrep/logic.ts +36 -0
  66. package/src/tool/fiberPrep/seo.astro +40 -0
  67. package/src/tool/fiberPrep/ui.ts +38 -0
  68. package/src/tool/knittingGauge/bibliography.astro +14 -0
  69. package/src/tool/knittingGauge/component.astro +828 -0
  70. package/src/tool/knittingGauge/i18n/en.ts +234 -0
  71. package/src/tool/knittingGauge/i18n/es.ts +234 -0
  72. package/src/tool/knittingGauge/i18n/fr.ts +234 -0
  73. package/src/tool/knittingGauge/index.ts +34 -0
  74. package/src/tool/knittingGauge/logic.ts +28 -0
  75. package/src/tool/knittingGauge/seo.astro +40 -0
  76. package/src/tool/knittingGauge/ui.ts +41 -0
  77. package/src/tool/laundryGuide/bibliography.astro +33 -0
  78. package/src/tool/laundryGuide/component.astro +486 -0
  79. package/src/tool/laundryGuide/data-en.ts +166 -0
  80. package/src/tool/laundryGuide/data-es.ts +166 -0
  81. package/src/tool/laundryGuide/data-fr.ts +103 -0
  82. package/src/tool/laundryGuide/i18n/en.ts +275 -0
  83. package/src/tool/laundryGuide/i18n/es.ts +275 -0
  84. package/src/tool/laundryGuide/i18n/fr.ts +291 -0
  85. package/src/tool/laundryGuide/index.ts +29 -0
  86. package/src/tool/laundryGuide/logic.ts +19 -0
  87. package/src/tool/laundryGuide/seo.astro +40 -0
  88. package/src/tool/needleConverter/bibliography.astro +14 -0
  89. package/src/tool/needleConverter/component.astro +518 -0
  90. package/src/tool/needleConverter/i18n/en.ts +217 -0
  91. package/src/tool/needleConverter/i18n/es.ts +217 -0
  92. package/src/tool/needleConverter/i18n/fr.ts +217 -0
  93. package/src/tool/needleConverter/index.ts +34 -0
  94. package/src/tool/needleConverter/logic.ts +31 -0
  95. package/src/tool/needleConverter/seo.astro +15 -0
  96. package/src/tool/needleConverter/ui.ts +17 -0
  97. package/src/tool/sewingPatternScaler/bibliography.astro +14 -0
  98. package/src/tool/sewingPatternScaler/component.astro +550 -0
  99. package/src/tool/sewingPatternScaler/i18n/en.ts +196 -0
  100. package/src/tool/sewingPatternScaler/i18n/es.ts +231 -0
  101. package/src/tool/sewingPatternScaler/i18n/fr.ts +187 -0
  102. package/src/tool/sewingPatternScaler/index.ts +34 -0
  103. package/src/tool/sewingPatternScaler/logic.ts +35 -0
  104. package/src/tool/sewingPatternScaler/seo.astro +49 -0
  105. package/src/tool/sewingPatternScaler/ui.ts +29 -0
  106. package/src/tool/shoeSizeConverter/bibliography.astro +34 -0
  107. package/src/tool/shoeSizeConverter/component.astro +437 -0
  108. package/src/tool/shoeSizeConverter/i18n/en.ts +188 -0
  109. package/src/tool/shoeSizeConverter/i18n/es.ts +188 -0
  110. package/src/tool/shoeSizeConverter/i18n/fr.ts +188 -0
  111. package/src/tool/shoeSizeConverter/index.ts +34 -0
  112. package/src/tool/shoeSizeConverter/logic.ts +8 -0
  113. package/src/tool/shoeSizeConverter/seo.astro +40 -0
  114. package/src/tool/shoeSizeConverter/ui.ts +21 -0
  115. package/src/tool/stainChemistry/bibliography.astro +40 -0
  116. package/src/tool/stainChemistry/component.astro +717 -0
  117. package/src/tool/stainChemistry/i18n/en.ts +303 -0
  118. package/src/tool/stainChemistry/i18n/es.ts +300 -0
  119. package/src/tool/stainChemistry/i18n/fr.ts +310 -0
  120. package/src/tool/stainChemistry/index.ts +34 -0
  121. package/src/tool/stainChemistry/logic.ts +11 -0
  122. package/src/tool/stainChemistry/seo.astro +40 -0
  123. package/src/tool/stainChemistry/ui.ts +51 -0
  124. package/src/tool/yarnCalculator/bibliography.astro +19 -0
  125. package/src/tool/yarnCalculator/component.astro +792 -0
  126. package/src/tool/yarnCalculator/i18n/en.ts +310 -0
  127. package/src/tool/yarnCalculator/i18n/es.ts +310 -0
  128. package/src/tool/yarnCalculator/i18n/fr.ts +310 -0
  129. package/src/tool/yarnCalculator/index.ts +34 -0
  130. package/src/tool/yarnCalculator/logic.ts +84 -0
  131. package/src/tool/yarnCalculator/seo.astro +14 -0
  132. package/src/tool/yarnCalculator/ui.ts +42 -0
  133. package/src/tools.ts +16 -0
  134. package/src/types.ts +72 -0
@@ -0,0 +1,792 @@
1
+ ---
2
+ import type { YarnCalculatorUI } from './ui';
3
+
4
+ interface Props {
5
+ ui?: Record<string, unknown>;
6
+ }
7
+
8
+ const { ui = {} } = Astro.props;
9
+ const yarnUI = ui as YarnCalculatorUI;
10
+ const initLabels = yarnUI.sizeLabels?.sweater ?? ['S', 'M', 'L', 'XL'];
11
+ ---
12
+
13
+ <script define:vars={{ ui }} is:inline>
14
+ window.__yarnCalcUI = ui;
15
+ </script>
16
+
17
+ <div class="yc-wrapper" id="yarn-calc">
18
+ <div class="yc-card">
19
+ <div class="yc-sidebar">
20
+ <div class="input-block">
21
+ <span class="section-label">{yarnUI.sectionProject}</span>
22
+ <div class="project-grid" id="project-selector">
23
+ <div class="project-item active" data-id="sweater">
24
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.38 3.46L16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"/></svg>
25
+ <span>{yarnUI.projectSweater}</span>
26
+ </div>
27
+ <div class="project-item" data-id="scarf">
28
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v16M8 8a4 4 0 1 1 8 0M8 12a4 4 0 1 1 8 0M8 16a4 4 0 1 1 8 0"/></svg>
29
+ <span>{yarnUI.projectScarf}</span>
30
+ </div>
31
+ <div class="project-item" data-id="hat">
32
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 0-10 10c0 4.42 5.5 8 10 8s10-3.58 10-8A10 10 0 0 0 12 2zM12 4v4M12 12v4"/></svg>
33
+ <span>{yarnUI.projectHat}</span>
34
+ </div>
35
+ <div class="project-item" data-id="socks">
36
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12V4a2 2 0 0 1 2-2h4l4 12h6v6a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-4l-2-6z"/></svg>
37
+ <span>{yarnUI.projectSocks}</span>
38
+ </div>
39
+ <div class="project-item" data-id="blanket">
40
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="4"/></svg>
41
+ <span>{yarnUI.projectBlanket}</span>
42
+ </div>
43
+ <div class="project-item" data-id="cardigan">
44
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.38 3.46L16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23zM12 8v12"/></svg>
45
+ <span>{yarnUI.projectCardigan}</span>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="input-block">
51
+ <span class="section-label">{yarnUI.sectionSize}</span>
52
+ <div class="pill-selector" id="size-selector">
53
+ <button class="pill-btn active" data-val="s">{initLabels[0]}</button>
54
+ <button class="pill-btn" data-val="m">{initLabels[1]}</button>
55
+ <button class="pill-btn" data-val="l">{initLabels[2]}</button>
56
+ <button class="pill-btn" data-val="xl">{initLabels[3]}</button>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="input-block">
61
+ <span class="section-label">{yarnUI.sectionWeight}</span>
62
+ <div class="pill-selector" id="weight-selector">
63
+ <button class="pill-btn active" data-val="fingering">{yarnUI.weightFine}</button>
64
+ <button class="pill-btn" data-val="dk">{yarnUI.weightMedium}</button>
65
+ <button class="pill-btn" data-val="worsted">{yarnUI.weightThick}</button>
66
+ <button class="pill-btn" data-val="bulky">{yarnUI.weightXXL}</button>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="input-block">
71
+ <span class="section-label">{yarnUI.sectionPattern}</span>
72
+ <div class="switch-row" id="cable-switch">
73
+ <span class="cable-label">{yarnUI.labelCables}</span>
74
+ <div class="switch-toggle"></div>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="input-block">
79
+ <span class="section-label">{yarnUI.sectionBall}</span>
80
+ <div class="custom-input-box">
81
+ <div class="ball-grid">
82
+ <div>
83
+ <label class="label-tiny">{yarnUI.labelGrams}</label>
84
+ <input type="number" id="ball-weight" class="custom-input" value="100" />
85
+ </div>
86
+ <div>
87
+ <label class="label-tiny">{yarnUI.labelMeters}</label>
88
+ <input type="number" id="ball-meters" class="custom-input" value="200" />
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <div class="yc-main">
96
+ <div class="results-panel">
97
+ <div class="main-stat">
98
+ <span class="stat-prefix">{yarnUI.statPrefix}</span>
99
+ <div class="stat-row">
100
+ <span class="stat-value" id="total-meters">1.250</span>
101
+ <span class="stat-suffix">{yarnUI.statSuffix}</span>
102
+ </div>
103
+ <p class="stat-note">{yarnUI.statNote}</p>
104
+ </div>
105
+
106
+ <div class="balls-cta">
107
+ <div class="balls-icon">
108
+ <svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor"><path d="M17,18C15.89,18 15,18.89 15,20A2,2 0 0,0 17,22A2,2 0 0,0 19,20C19,18.89 18.11,18 17,18M7,18C5.89,18 5,18.89 5,20A2,2 0 0,0 7,22A2,2 0 0,0 9,20C9,18.89 8.11,18 7,18M7,16L7.1,15.93L8.5,13.43L15.33,13.43C16.08,13.43 16.74,13.04 17.11,12.44L21.43,4.6L19.7,3.65L15.33,11.43L8.7,11.43L3.1,1.11L1,1.11V3.11H3L6.6,10.66L5.25,13.11C5.1,13.41 5,13.7 5,14C5,15.11 5.89,16 7,16H19V14H7.42C7.28,14 7.16,13.89 7.16,13.74V13.71L8.1,12H15.55C16.3,12 16.96,11.61 17.33,11.01L22.25,2.11L20.52,1.11L15.55,10H8.38L4.62,3H1V5H3.14L6.9,12L5.25,15.11C5.1,15.41 5,15.7 5,16C5,17.11 5.89,18 7,18H19V16H7.42C7.28,16 7.16,15.89 7.16,15.74Z"/></svg>
109
+ </div>
110
+ <span class="balls-count" id="total-balls">—</span>
111
+ <span class="balls-desc" id="balls-detail">—</span>
112
+ </div>
113
+
114
+ <div class="panic-alert">
115
+ <div class="alert-icon">
116
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 9v2m0 4h.01M5.93 21h12.14a2 2 0 0 0 1.73-3L13.73 4a2 2 0 0 0-3.46 0L4.2 18a2 2 0 0 0 1.73 3z"/></svg>
117
+ </div>
118
+ <div>
119
+ <strong>{yarnUI.alertTitle}</strong> {yarnUI.alertText}
120
+ </div>
121
+ </div>
122
+
123
+ <div class="garment-scheme">
124
+ <div class="scheme-container" id="scheme-draw">
125
+ <svg width="320" height="240" viewBox="0 0 200 160" id="main-svg"></svg>
126
+ <div id="lbl-left" class="scheme-label" style="opacity: 0"></div>
127
+ <div id="lbl-right" class="scheme-label" style="opacity: 0"></div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </div>
134
+
135
+ <script>
136
+ import { PROJECTS, WEIGHT_IDX, computeTotal, computeBalls, parseParam } from './logic';
137
+ import type { SizeKey, WeightKey } from './logic';
138
+ import type { YarnCalculatorUI } from './ui';
139
+
140
+ let ui: YarnCalculatorUI;
141
+ const state = {
142
+ project: 'sweater',
143
+ size: 'm' as SizeKey,
144
+ weight: 'fingering' as WeightKey,
145
+ cables: false,
146
+ ballW: 100,
147
+ ballM: 200,
148
+ };
149
+
150
+ interface SchemeEls {
151
+ svg: Element;
152
+ lblLeft: HTMLElement;
153
+ lblRight: HTMLElement;
154
+ }
155
+
156
+ function setText(id: string, v: string) {
157
+ const el = document.getElementById(id);
158
+ if (el) (el as HTMLElement).innerText = v;
159
+ }
160
+
161
+ function updateQuery() {
162
+ const p = new URLSearchParams();
163
+ p.set('p', state.project); p.set('s', state.size); p.set('w', state.weight);
164
+ p.set('c', state.cables ? '1' : '0'); p.set('bw', String(state.ballW)); p.set('bm', String(state.ballM));
165
+ window.history.replaceState({}, '', `${window.location.pathname}?${p.toString()}`);
166
+ }
167
+
168
+ function getSchemeEls(): SchemeEls | null {
169
+ const svg = document.getElementById('main-svg');
170
+ const lblLeft = document.getElementById('lbl-left') as HTMLElement | null;
171
+ const lblRight = document.getElementById('lbl-right') as HTMLElement | null;
172
+ if (!svg || !lblLeft || !lblRight) return null;
173
+ svg.innerHTML = '';
174
+ lblLeft.style.opacity = '0'; lblRight.style.opacity = '0';
175
+ return { svg, lblLeft, lblRight };
176
+ }
177
+
178
+ function setSweaterScheme(els: SchemeEls, bM: number, sMTotal: number) {
179
+ els.svg.innerHTML = `
180
+ <line class="scheme-callout" x1="10" y1="35" x2="55" y2="75"/>
181
+ <circle class="scheme-dot" cx="55" cy="75" r="3"/>
182
+ <line class="scheme-callout" x1="190" y1="90" x2="130" y2="100"/>
183
+ <circle class="scheme-dot" cx="130" cy="100" r="3"/>
184
+ <rect class="scheme-item filled" x="60" y="40" width="80" height="100"/>
185
+ <path class="scheme-item filled" d="M60 40 L30 100 L50 110 L60 60 Z"/>
186
+ <path class="scheme-item filled" d="M140 40 L170 100 L150 110 L140 60 Z"/>
187
+ `;
188
+ els.lblLeft.style.cssText = 'opacity:1; top:10%; left:5%';
189
+ els.lblLeft.innerHTML = `<span>${ui.schemeSleeveLabel}</span><strong>${sMTotal}m</strong><em>${Math.round(sMTotal / 2)}m ${ui.schemeSleeveEach}</em>`;
190
+ els.lblRight.style.cssText = 'opacity:1; top:50%; right:5%';
191
+ els.lblRight.innerHTML = `<span>${ui.schemeBodyLabel}</span><strong>${bM}m</strong>`;
192
+ }
193
+
194
+ function setHatScheme(els: SchemeEls, total: number) {
195
+ els.svg.innerHTML = `
196
+ <line class="scheme-callout" x1="190" y1="60" x2="120" y2="70"/>
197
+ <circle class="scheme-dot" cx="120" cy="70" r="3"/>
198
+ <path class="scheme-item filled" d="M60 130 Q 60 40, 100 40 Q 140 40, 140 130 Z"/>
199
+ `;
200
+ els.lblRight.style.cssText = 'opacity:1; top:25%; right:10%';
201
+ els.lblRight.innerHTML = `<span>${ui.schemeTotalHat}</span><strong>${total}m</strong>`;
202
+ }
203
+
204
+ function setFlatScheme(els: SchemeEls, total: number) {
205
+ els.svg.innerHTML = `
206
+ <line class="scheme-callout" x1="190" y1="60" x2="120" y2="80"/>
207
+ <circle class="scheme-dot" cx="120" cy="80" r="3"/>
208
+ <rect class="scheme-item filled" x="40" y="60" width="120" height="60"/>
209
+ `;
210
+ els.lblRight.style.cssText = 'opacity:1; top:20%; right:10%';
211
+ els.lblRight.innerHTML = `<span>${ui.schemeTotalFlat}</span><strong>${total}m</strong>`;
212
+ }
213
+
214
+ function setSocksScheme(els: SchemeEls, total: number) {
215
+ els.svg.innerHTML = `
216
+ <line class="scheme-callout" x1="190" y1="50" x2="100" y2="80"/>
217
+ <circle class="scheme-dot" cx="100" cy="80" r="3"/>
218
+ <path class="scheme-item filled" d="M80 40 L80 120 L120 120 L120 100 L100 100 L100 40 Z"/>
219
+ `;
220
+ els.lblRight.style.cssText = 'opacity:1; top:15%; right:10%';
221
+ els.lblRight.innerHTML = `<span>${ui.schemeTotalSocks}</span><strong>${total}m</strong>`;
222
+ }
223
+
224
+ function drawScheme(total: number, parts: { body: number; sleeves: number }) {
225
+ const els = getSchemeEls();
226
+ if (!els) return;
227
+ const bM = Math.round(total * parts.body);
228
+ const sMTotal = Math.round(total * parts.sleeves);
229
+ if (state.project === 'sweater' || state.project === 'cardigan') setSweaterScheme(els, bM, sMTotal);
230
+ else if (state.project === 'hat') setHatScheme(els, total);
231
+ else if (state.project === 'scarf' || state.project === 'blanket') setFlatScheme(els, total);
232
+ else if (state.project === 'socks') setSocksScheme(els, total);
233
+ }
234
+
235
+ function calculate() {
236
+ const proj = PROJECTS[state.project];
237
+ const totalM = computeTotal(proj[state.size][WEIGHT_IDX[state.weight]], state.cables);
238
+ const balls = computeBalls(totalM, state.ballM);
239
+ setText('total-meters', totalM.toLocaleString());
240
+ const fmt = balls > 1 ? ui.ballsFormatPlural : ui.ballsFormatSingular;
241
+ setText('total-balls', fmt.replace('{n}', String(balls)));
242
+ setText('balls-detail', ui.ballsDetailFormat.replace('{w}', String(state.ballW)).replace('{m}', String(state.ballM)));
243
+ drawScheme(totalM, proj.parts);
244
+ updateQuery();
245
+ }
246
+
247
+ function updateSizeLabels() {
248
+ const btns = document.querySelectorAll('#size-selector .pill-btn');
249
+ const labels = ui.sizeLabels[state.project as keyof typeof ui.sizeLabels] ?? ['S', 'M', 'L', 'XL'];
250
+ btns.forEach((btn, idx) => { (btn as HTMLElement).innerText = labels[idx] ?? ''; });
251
+ }
252
+
253
+ function attachProjectItems() {
254
+ const items = document.querySelectorAll('.project-item');
255
+ items.forEach(item => {
256
+ item.addEventListener('click', () => {
257
+ items.forEach(i => i.classList.remove('active'));
258
+ item.classList.add('active');
259
+ state.project = (item as HTMLElement).dataset.id ?? 'sweater';
260
+ updateSizeLabels();
261
+ calculate();
262
+ });
263
+ });
264
+ }
265
+
266
+ function attachSizeWeight() {
267
+ const sBtns = document.querySelectorAll('#size-selector .pill-btn');
268
+ sBtns.forEach(btn => {
269
+ btn.addEventListener('click', () => {
270
+ sBtns.forEach(b => b.classList.remove('active'));
271
+ btn.classList.add('active');
272
+ state.size = ((btn as HTMLElement).dataset.val ?? 'm') as SizeKey;
273
+ calculate();
274
+ });
275
+ });
276
+ const wBtns = document.querySelectorAll('#weight-selector .pill-btn');
277
+ wBtns.forEach(btn => {
278
+ btn.addEventListener('click', () => {
279
+ wBtns.forEach(b => b.classList.remove('active'));
280
+ btn.classList.add('active');
281
+ state.weight = ((btn as HTMLElement).dataset.val ?? 'fingering') as WeightKey;
282
+ calculate();
283
+ });
284
+ });
285
+ }
286
+
287
+ function attachCableAndBalls() {
288
+ const cRow = document.getElementById('cable-switch');
289
+ cRow?.addEventListener('click', () => {
290
+ cRow.classList.toggle('active');
291
+ state.cables = cRow.classList.contains('active');
292
+ calculate();
293
+ });
294
+ const bW = document.getElementById('ball-weight') as HTMLInputElement | null;
295
+ const bM = document.getElementById('ball-meters') as HTMLInputElement | null;
296
+ [bW, bM].forEach(el => el?.addEventListener('input', e => {
297
+ const val = parseInt((e.target as HTMLInputElement).value) || 1;
298
+ if (el?.id === 'ball-weight') state.ballW = val; else state.ballM = val;
299
+ calculate();
300
+ }));
301
+ }
302
+
303
+ function restoreState() {
304
+ const params = new URLSearchParams(window.location.search);
305
+ if (params.has('p')) state.project = params.get('p') ?? state.project;
306
+ if (params.has('s')) state.size = (params.get('s') ?? state.size) as SizeKey;
307
+ if (params.has('w')) state.weight = (params.get('w') ?? state.weight) as WeightKey;
308
+ if (params.has('c')) state.cables = params.get('c') === '1';
309
+ state.ballW = parseParam(params, 'bw', 100);
310
+ state.ballM = parseParam(params, 'bm', 200);
311
+ }
312
+
313
+ function syncDomToState() {
314
+ document.querySelectorAll('.project-item').forEach(i => {
315
+ i.classList.toggle('active', (i as HTMLElement).dataset.id === state.project);
316
+ });
317
+ document.querySelectorAll('#size-selector .pill-btn').forEach(btn => {
318
+ btn.classList.toggle('active', (btn as HTMLElement).dataset.val === state.size);
319
+ });
320
+ document.querySelectorAll('#weight-selector .pill-btn').forEach(btn => {
321
+ btn.classList.toggle('active', (btn as HTMLElement).dataset.val === state.weight);
322
+ });
323
+ document.getElementById('cable-switch')?.classList.toggle('active', state.cables);
324
+ const bW = document.getElementById('ball-weight') as HTMLInputElement | null;
325
+ const bM = document.getElementById('ball-meters') as HTMLInputElement | null;
326
+ if (bW) bW.value = String(state.ballW);
327
+ if (bM) bM.value = String(state.ballM);
328
+ }
329
+
330
+ function init() {
331
+ const savedUI = (window as Record<string, unknown>).__yarnCalcUI as YarnCalculatorUI | undefined;
332
+ if (!savedUI) return;
333
+ ui = savedUI;
334
+ restoreState();
335
+ syncDomToState();
336
+ attachProjectItems();
337
+ attachSizeWeight();
338
+ attachCableAndBalls();
339
+ updateSizeLabels();
340
+ calculate();
341
+ }
342
+
343
+ document.addEventListener('astro:page-load', init);
344
+ init();
345
+ </script>
346
+
347
+ <style>
348
+ .yc-wrapper {
349
+ --yc-p: #10b981;
350
+
351
+ width: 100%;
352
+ padding: 1rem 0;
353
+ }
354
+
355
+ .yc-card {
356
+ background: var(--bg-surface);
357
+ width: calc(100% - 24px);
358
+ max-width: 1100px;
359
+ margin: 0 auto;
360
+ border-radius: 24px;
361
+ overflow: hidden;
362
+ display: flex;
363
+ flex-direction: column;
364
+ color: var(--text-main);
365
+ }
366
+
367
+ @media (min-width: 1024px) {
368
+ .yc-card {
369
+ flex-direction: row;
370
+ min-height: 700px;
371
+ }
372
+ }
373
+
374
+ .yc-sidebar {
375
+ flex: 0 0 auto;
376
+ width: 100%;
377
+ background: var(--bg-surface);
378
+ padding: 32px;
379
+ display: flex;
380
+ flex-direction: column;
381
+ gap: 40px;
382
+ }
383
+
384
+ @media (min-width: 1024px) {
385
+ .yc-sidebar {
386
+ width: 440px;
387
+ padding: 48px;
388
+ border-right: 1px solid var(--border-color);
389
+ overflow-y: auto;
390
+ }
391
+ }
392
+
393
+ .yc-main {
394
+ flex: 1;
395
+ background: var(--bg-surface);
396
+ padding: 32px;
397
+ display: flex;
398
+ flex-direction: column;
399
+ }
400
+
401
+ @media (min-width: 1024px) {
402
+ .yc-main {
403
+ padding: 64px 48px;
404
+ }
405
+ }
406
+
407
+ .section-label {
408
+ font-size: 11px;
409
+ font-weight: 800;
410
+ text-transform: uppercase;
411
+ letter-spacing: 0.12em;
412
+ color: var(--text-muted);
413
+ margin-bottom: 20px;
414
+ display: block;
415
+ }
416
+
417
+ .project-grid {
418
+ display: grid;
419
+ grid-template-columns: repeat(3, 1fr);
420
+ gap: 12px;
421
+ }
422
+
423
+ .project-item {
424
+ aspect-ratio: 1;
425
+ background: var(--bg-muted);
426
+ border: 1px solid var(--border-color);
427
+ border-radius: 16px;
428
+ display: flex;
429
+ flex-direction: column;
430
+ align-items: center;
431
+ justify-content: center;
432
+ cursor: pointer;
433
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
434
+ gap: 8px;
435
+ }
436
+
437
+ .project-item svg {
438
+ width: 32px;
439
+ height: 32px;
440
+ color: var(--text-muted);
441
+ transition: color 0.2s;
442
+ }
443
+
444
+ .project-item span {
445
+ font-size: 10px;
446
+ font-weight: 700;
447
+ color: var(--text-muted);
448
+ }
449
+
450
+ .project-item.active {
451
+ background: #f0fdf4;
452
+ border-color: var(--yc-p);
453
+ box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.1);
454
+ }
455
+
456
+ .project-item.active svg {
457
+ color: var(--yc-p);
458
+ }
459
+
460
+ .project-item.active span {
461
+ color: #065f46;
462
+ }
463
+
464
+ .input-block {
465
+ display: flex;
466
+ flex-direction: column;
467
+ gap: 8px;
468
+ }
469
+
470
+ .pill-selector {
471
+ display: flex;
472
+ background: var(--bg-muted);
473
+ padding: 4px;
474
+ border-radius: 12px;
475
+ margin-top: 4px;
476
+ }
477
+
478
+ .pill-btn {
479
+ flex: 1;
480
+ border: none;
481
+ background: transparent;
482
+ padding: 10px;
483
+ font-size: 11px;
484
+ font-weight: 700;
485
+ color: var(--text-muted);
486
+ cursor: pointer;
487
+ border-radius: 8px;
488
+ transition: all 0.2s;
489
+ }
490
+
491
+ .pill-btn.active {
492
+ background: var(--yc-p);
493
+ color: #fff;
494
+ box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.2);
495
+ }
496
+
497
+ .custom-input-box {
498
+ background: var(--bg-muted);
499
+ padding: 16px;
500
+ border-radius: 20px;
501
+ border: 1px solid var(--border-color);
502
+ }
503
+
504
+ .ball-grid {
505
+ display: grid;
506
+ grid-template-columns: 1fr 1fr;
507
+ gap: 16px;
508
+ }
509
+
510
+ .label-tiny {
511
+ font-size: 9px;
512
+ font-weight: 800;
513
+ color: var(--text-muted);
514
+ margin-bottom: 8px;
515
+ display: block;
516
+ }
517
+
518
+ .custom-input {
519
+ width: 100%;
520
+ padding: 14px 16px;
521
+ background: var(--bg-surface);
522
+ border: 1px solid var(--border-color);
523
+ border-radius: 12px;
524
+ font-size: 15px;
525
+ font-weight: 700;
526
+ color: var(--text-main);
527
+ transition: all 0.2s;
528
+ }
529
+
530
+ .custom-input:focus {
531
+ outline: none;
532
+ border-color: var(--yc-p);
533
+ box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1);
534
+ }
535
+
536
+ .switch-row {
537
+ display: flex;
538
+ align-items: center;
539
+ justify-content: space-between;
540
+ padding: 12px 16px;
541
+ background: var(--bg-muted);
542
+ border-radius: 12px;
543
+ border: 1px solid var(--border-color);
544
+ cursor: pointer;
545
+ }
546
+
547
+ .cable-label {
548
+ font-size: 13px;
549
+ font-weight: 800;
550
+ }
551
+
552
+ .switch-toggle {
553
+ width: 44px;
554
+ height: 24px;
555
+ background: var(--border-color);
556
+ border-radius: 100px;
557
+ position: relative;
558
+ transition: all 0.3s;
559
+ }
560
+
561
+ .switch-toggle::before {
562
+ content: "";
563
+ position: absolute;
564
+ width: 18px;
565
+ height: 18px;
566
+ background: #fff;
567
+ border-radius: 50%;
568
+ top: 3px;
569
+ left: 3px;
570
+ transition: all 0.3s;
571
+ }
572
+
573
+ .switch-row.active {
574
+ border-color: var(--yc-p);
575
+ background: #f0fdf4;
576
+ }
577
+
578
+ .switch-row.active .switch-toggle {
579
+ background: var(--yc-p);
580
+ }
581
+
582
+ .switch-row.active .switch-toggle::before {
583
+ transform: translateX(20px);
584
+ }
585
+
586
+ .results-panel {
587
+ flex: 1;
588
+ display: flex;
589
+ flex-direction: column;
590
+ justify-content: center;
591
+ }
592
+
593
+ .main-stat {
594
+ text-align: center;
595
+ margin-bottom: 48px;
596
+ }
597
+
598
+ .stat-prefix {
599
+ font-size: 12px;
600
+ font-weight: 800;
601
+ color: var(--text-muted);
602
+ text-transform: uppercase;
603
+ letter-spacing: 0.2em;
604
+ display: block;
605
+ margin-bottom: 8px;
606
+ }
607
+
608
+ .stat-row {
609
+ display: flex;
610
+ align-items: baseline;
611
+ justify-content: center;
612
+ }
613
+
614
+ .stat-value {
615
+ font-size: 96px;
616
+ font-weight: 950;
617
+ color: var(--text-main);
618
+ line-height: 1;
619
+ letter-spacing: -0.05em;
620
+ }
621
+
622
+ .stat-suffix {
623
+ font-size: 28px;
624
+ font-weight: 900;
625
+ color: var(--text-muted);
626
+ margin-left: 8px;
627
+ }
628
+
629
+ .stat-note {
630
+ font-size: 14px;
631
+ font-weight: 600;
632
+ color: var(--text-muted);
633
+ margin-top: 12px;
634
+ }
635
+
636
+ .balls-cta {
637
+ background: var(--yc-p);
638
+ color: #fff;
639
+ padding: 40px;
640
+ border-radius: 32px;
641
+ text-align: center;
642
+ box-shadow: 0 20px 40px -10px rgba(16, 185, 129, 0.4);
643
+ position: relative;
644
+ overflow: hidden;
645
+ display: flex;
646
+ flex-direction: column;
647
+ align-items: center;
648
+ gap: 8px;
649
+ }
650
+
651
+ .balls-icon {
652
+ font-size: 24px;
653
+ margin-bottom: 4px;
654
+ opacity: 0.9;
655
+ }
656
+
657
+ .balls-count {
658
+ display: block;
659
+ font-size: 32px;
660
+ font-weight: 900;
661
+ line-height: 1;
662
+ }
663
+
664
+ .balls-desc {
665
+ display: block;
666
+ font-size: 14px;
667
+ font-weight: 700;
668
+ opacity: 0.8;
669
+ }
670
+
671
+ .panic-alert {
672
+ margin-top: 32px;
673
+ display: flex;
674
+ gap: 16px;
675
+ padding: 24px;
676
+ background: #fffbeb;
677
+ border: 1px solid #fde68a;
678
+ border-radius: 20px;
679
+ color: #92400e;
680
+ font-size: 14px;
681
+ line-height: 1.6;
682
+ }
683
+
684
+ .alert-icon {
685
+ flex-shrink: 0;
686
+ color: #f59e0b;
687
+ width: 20px;
688
+ }
689
+
690
+ .garment-scheme {
691
+ margin-top: 48px;
692
+ border-top: 1px solid var(--border-color);
693
+ padding-top: 48px;
694
+ }
695
+
696
+ .scheme-container {
697
+ background: var(--bg-muted);
698
+ border-radius: 24px;
699
+ padding: 64px 48px;
700
+ display: flex;
701
+ justify-content: center;
702
+ position: relative;
703
+ min-height: 340px;
704
+ overflow: hidden;
705
+ }
706
+
707
+ :global(.scheme-item) {
708
+ stroke-width: 4;
709
+ fill: none;
710
+ stroke: var(--border-color);
711
+ transition: all 0.4s cubic-bezier(0.19, 1, 0.22, 1);
712
+ }
713
+
714
+ :global(.scheme-item.filled) {
715
+ stroke: var(--yc-p);
716
+ fill: #10b98108;
717
+ }
718
+
719
+ :global(.scheme-callout) {
720
+ stroke: var(--yc-p);
721
+ stroke-width: 2;
722
+ fill: none;
723
+ transition: all 0.4s ease;
724
+ stroke-dasharray: 6 3;
725
+ opacity: 0.7;
726
+ }
727
+
728
+ :global(.scheme-dot) {
729
+ fill: var(--yc-p);
730
+ transition: all 0.4s ease;
731
+ }
732
+
733
+ .scheme-label {
734
+ position: absolute;
735
+ display: flex;
736
+ flex-direction: column;
737
+ gap: 4px;
738
+ padding: 12px 16px;
739
+ background: var(--bg-surface);
740
+ border: 1.5px solid var(--border-color);
741
+ border-left: 4px solid var(--yc-p);
742
+ border-radius: 8px;
743
+ box-shadow: 0 8px 16px -4px rgba(16, 185, 129, 0.1);
744
+ pointer-events: none;
745
+ transition: all 0.4s cubic-bezier(0.19, 1, 0.22, 1);
746
+ z-index: 10;
747
+ }
748
+
749
+ .scheme-label :global(span) {
750
+ font-size: 9px;
751
+ font-weight: 900;
752
+ text-transform: uppercase;
753
+ letter-spacing: 0.1em;
754
+ color: var(--text-muted);
755
+ }
756
+
757
+ .scheme-label :global(strong) {
758
+ color: var(--text-main);
759
+ font-weight: 950;
760
+ font-size: 15px;
761
+ }
762
+
763
+ .scheme-label :global(em) {
764
+ font-size: 11px;
765
+ font-style: normal;
766
+ color: var(--yc-p);
767
+ font-weight: 800;
768
+ margin-top: 2px;
769
+ }
770
+
771
+ .theme-dark .project-item.active {
772
+ background: #064e3b;
773
+ }
774
+
775
+ .theme-dark .project-item.active span {
776
+ color: #6ee7b7;
777
+ }
778
+
779
+ .theme-dark .switch-row.active {
780
+ background: #064e3b;
781
+ }
782
+
783
+ .theme-dark .panic-alert {
784
+ background: #451a03;
785
+ border-color: #d97706;
786
+ color: #fbbf24;
787
+ }
788
+
789
+ .theme-dark .scheme-container {
790
+ background: var(--bg-surface);
791
+ }
792
+ </style>