@jjlmoya/utils-diy 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 (135) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +110 -0
  3. package/src/category/i18n/es.ts +110 -0
  4. package/src/category/i18n/fr.ts +101 -0
  5. package/src/category/index.ts +25 -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 +57 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -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/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +22 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/balusterCalculator/bibliography.astro +14 -0
  25. package/src/tool/balusterCalculator/component.astro +605 -0
  26. package/src/tool/balusterCalculator/i18n/en.ts +264 -0
  27. package/src/tool/balusterCalculator/i18n/es.ts +264 -0
  28. package/src/tool/balusterCalculator/i18n/fr.ts +264 -0
  29. package/src/tool/balusterCalculator/index.ts +33 -0
  30. package/src/tool/balusterCalculator/seo.astro +15 -0
  31. package/src/tool/balusterCalculator/ui.ts +15 -0
  32. package/src/tool/clayCalculator/bibliography.astro +14 -0
  33. package/src/tool/clayCalculator/component.astro +731 -0
  34. package/src/tool/clayCalculator/i18n/en.ts +183 -0
  35. package/src/tool/clayCalculator/i18n/es.ts +183 -0
  36. package/src/tool/clayCalculator/i18n/fr.ts +183 -0
  37. package/src/tool/clayCalculator/index.ts +33 -0
  38. package/src/tool/clayCalculator/seo.astro +15 -0
  39. package/src/tool/clayCalculator/ui.ts +19 -0
  40. package/src/tool/concreteCalculator/bibliography.astro +13 -0
  41. package/src/tool/concreteCalculator/component.astro +1089 -0
  42. package/src/tool/concreteCalculator/i18n/en.ts +201 -0
  43. package/src/tool/concreteCalculator/i18n/es.ts +201 -0
  44. package/src/tool/concreteCalculator/i18n/fr.ts +201 -0
  45. package/src/tool/concreteCalculator/index.ts +28 -0
  46. package/src/tool/concreteCalculator/seo.astro +14 -0
  47. package/src/tool/concreteCalculator/ui.ts +35 -0
  48. package/src/tool/cutOptimizer/bibliography.astro +13 -0
  49. package/src/tool/cutOptimizer/component.astro +825 -0
  50. package/src/tool/cutOptimizer/i18n/en.ts +178 -0
  51. package/src/tool/cutOptimizer/i18n/es.ts +178 -0
  52. package/src/tool/cutOptimizer/i18n/fr.ts +178 -0
  53. package/src/tool/cutOptimizer/index.ts +28 -0
  54. package/src/tool/cutOptimizer/optimizer.ts +199 -0
  55. package/src/tool/cutOptimizer/seo.astro +14 -0
  56. package/src/tool/cutOptimizer/ui.ts +25 -0
  57. package/src/tool/drillCalculator/bibliography.astro +13 -0
  58. package/src/tool/drillCalculator/component.astro +1210 -0
  59. package/src/tool/drillCalculator/engine.ts +63 -0
  60. package/src/tool/drillCalculator/i18n/en.ts +137 -0
  61. package/src/tool/drillCalculator/i18n/es.ts +137 -0
  62. package/src/tool/drillCalculator/i18n/fr.ts +137 -0
  63. package/src/tool/drillCalculator/index.ts +28 -0
  64. package/src/tool/drillCalculator/seo.astro +14 -0
  65. package/src/tool/drillCalculator/ui.ts +41 -0
  66. package/src/tool/drillSharpener/bibliography.astro +13 -0
  67. package/src/tool/drillSharpener/component.astro +712 -0
  68. package/src/tool/drillSharpener/engine.ts +42 -0
  69. package/src/tool/drillSharpener/i18n/en.ts +121 -0
  70. package/src/tool/drillSharpener/i18n/es.ts +121 -0
  71. package/src/tool/drillSharpener/i18n/fr.ts +121 -0
  72. package/src/tool/drillSharpener/index.ts +31 -0
  73. package/src/tool/drillSharpener/seo.astro +14 -0
  74. package/src/tool/drillSharpener/ui.ts +53 -0
  75. package/src/tool/epoxyCalculator/bibliography.astro +14 -0
  76. package/src/tool/epoxyCalculator/component.astro +888 -0
  77. package/src/tool/epoxyCalculator/i18n/en.ts +195 -0
  78. package/src/tool/epoxyCalculator/i18n/es.ts +195 -0
  79. package/src/tool/epoxyCalculator/i18n/fr.ts +195 -0
  80. package/src/tool/epoxyCalculator/index.ts +33 -0
  81. package/src/tool/epoxyCalculator/seo.astro +15 -0
  82. package/src/tool/epoxyCalculator/ui.ts +22 -0
  83. package/src/tool/furnitureFit/bibliography.astro +13 -0
  84. package/src/tool/furnitureFit/component.astro +552 -0
  85. package/src/tool/furnitureFit/engine.ts +51 -0
  86. package/src/tool/furnitureFit/i18n/en.ts +152 -0
  87. package/src/tool/furnitureFit/i18n/es.ts +152 -0
  88. package/src/tool/furnitureFit/i18n/fr.ts +152 -0
  89. package/src/tool/furnitureFit/index.ts +28 -0
  90. package/src/tool/furnitureFit/seo.astro +14 -0
  91. package/src/tool/furnitureFit/ui.ts +21 -0
  92. package/src/tool/mortarCalculator/bibliography.astro +13 -0
  93. package/src/tool/mortarCalculator/component.astro +1139 -0
  94. package/src/tool/mortarCalculator/i18n/en.ts +219 -0
  95. package/src/tool/mortarCalculator/i18n/es.ts +205 -0
  96. package/src/tool/mortarCalculator/i18n/fr.ts +220 -0
  97. package/src/tool/mortarCalculator/index.ts +24 -0
  98. package/src/tool/mortarCalculator/seo.astro +14 -0
  99. package/src/tool/mortarCalculator/ui.ts +37 -0
  100. package/src/tool/passepartoutCalculator/bibliography.astro +13 -0
  101. package/src/tool/passepartoutCalculator/component.astro +741 -0
  102. package/src/tool/passepartoutCalculator/i18n/en.ts +178 -0
  103. package/src/tool/passepartoutCalculator/i18n/es.ts +178 -0
  104. package/src/tool/passepartoutCalculator/i18n/fr.ts +178 -0
  105. package/src/tool/passepartoutCalculator/index.ts +28 -0
  106. package/src/tool/passepartoutCalculator/seo.astro +14 -0
  107. package/src/tool/passepartoutCalculator/ui.ts +17 -0
  108. package/src/tool/stairCalculator/bibliography.astro +52 -0
  109. package/src/tool/stairCalculator/component.astro +766 -0
  110. package/src/tool/stairCalculator/engine.ts +128 -0
  111. package/src/tool/stairCalculator/i18n/en.ts +149 -0
  112. package/src/tool/stairCalculator/i18n/es.ts +149 -0
  113. package/src/tool/stairCalculator/i18n/fr.ts +149 -0
  114. package/src/tool/stairCalculator/index.ts +31 -0
  115. package/src/tool/stairCalculator/seo.astro +211 -0
  116. package/src/tool/stairCalculator/ui.ts +109 -0
  117. package/src/tool/thermalExpansionCalculator/bibliography.astro +13 -0
  118. package/src/tool/thermalExpansionCalculator/component.astro +771 -0
  119. package/src/tool/thermalExpansionCalculator/engine.ts +23 -0
  120. package/src/tool/thermalExpansionCalculator/i18n/en.ts +174 -0
  121. package/src/tool/thermalExpansionCalculator/i18n/es.ts +174 -0
  122. package/src/tool/thermalExpansionCalculator/i18n/fr.ts +174 -0
  123. package/src/tool/thermalExpansionCalculator/index.ts +28 -0
  124. package/src/tool/thermalExpansionCalculator/seo.astro +14 -0
  125. package/src/tool/thermalExpansionCalculator/ui.ts +41 -0
  126. package/src/tool/voltageDropCalculator/bibliography.astro +13 -0
  127. package/src/tool/voltageDropCalculator/component.astro +1022 -0
  128. package/src/tool/voltageDropCalculator/i18n/en.ts +155 -0
  129. package/src/tool/voltageDropCalculator/i18n/es.ts +155 -0
  130. package/src/tool/voltageDropCalculator/i18n/fr.ts +148 -0
  131. package/src/tool/voltageDropCalculator/index.ts +28 -0
  132. package/src/tool/voltageDropCalculator/seo.astro +14 -0
  133. package/src/tool/voltageDropCalculator/ui.ts +22 -0
  134. package/src/tools.ts +16 -0
  135. package/src/types.ts +72 -0
@@ -0,0 +1,825 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { CutOptimizerUI } from './ui';
4
+
5
+ interface Props {
6
+ ui?: Record<string, unknown>;
7
+ }
8
+ const { ui } = Astro.props;
9
+ const t = (ui ?? {}) as CutOptimizerUI;
10
+ ---
11
+
12
+ <div
13
+ class="co-root"
14
+ data-co-root
15
+ data-co-stock-label={t.stockLabel}
16
+ data-co-panel-label={t.panelLabel}
17
+ data-co-waste-label={t.wasteLabel}
18
+ data-co-eff-label={t.effLabel}
19
+ data-co-empty={t.emptyState}
20
+ data-co-no-cuts={t.noValidCuts}
21
+ data-co-no-panels={t.noValidPanels}
22
+ >
23
+ <div class="co-grid">
24
+
25
+ <div class="co-sidebar">
26
+ <div class="co-card">
27
+ <h3 class="co-card-title">
28
+ <Icon name="mdi:cog" />
29
+ {t.configTitle}
30
+ </h3>
31
+
32
+ <div class="co-mode-toggle">
33
+ <button class="co-mode-btn" data-co-mode-btn="linear">{t.modeLinearLabel}</button>
34
+ <button class="co-mode-btn" data-co-mode-btn="panel">{t.modePanelLabel}</button>
35
+ </div>
36
+
37
+ <div class="co-fields">
38
+ <div class="co-field">
39
+ <label class="co-field-label">{t.labelStockLength}</label>
40
+ <input type="number" data-co-stock-len data-co-cfg-inp value="2400" class="co-input" />
41
+ </div>
42
+ <div class="co-field co-hidden" data-co-stock-wid-wrap>
43
+ <label class="co-field-label">{t.labelStockWidth}</label>
44
+ <input type="number" data-co-stock-wid data-co-cfg-inp value="1200" class="co-input" />
45
+ </div>
46
+ <div class="co-field">
47
+ <label class="co-field-label">{t.labelKerf}</label>
48
+ <input type="number" data-co-kerf data-co-cfg-inp value="3" class="co-input" />
49
+ </div>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="co-card">
54
+ <div class="co-card-title-row">
55
+ <h3 class="co-card-title">
56
+ <Icon name="mdi:format-list-bulleted" />
57
+ {t.cutsTitle}
58
+ </h3>
59
+ <button class="co-btn-add" data-co-btn-add title="+">
60
+ <Icon name="mdi:plus" />
61
+ </button>
62
+ </div>
63
+ <div class="co-cut-list" data-co-list></div>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="co-main">
68
+ <div class="co-stats-grid">
69
+ <div class="co-stat-card">
70
+ <div class="co-stat-label">{t.statStockUsed}</div>
71
+ <div class="co-stat-value" data-co-stat-used>0</div>
72
+ </div>
73
+ <div class="co-stat-card">
74
+ <div class="co-stat-label">{t.statTotalCuts}</div>
75
+ <div class="co-stat-value" data-co-stat-cuts>0</div>
76
+ </div>
77
+ <div class="co-stat-card">
78
+ <div class="co-stat-label">{t.statWaste}</div>
79
+ <div class="co-stat-value co-stat-red" data-co-stat-waste>0</div>
80
+ </div>
81
+ <div class="co-stat-card">
82
+ <div class="co-stat-label">{t.statEfficiency}</div>
83
+ <div class="co-stat-value co-stat-green" data-co-stat-eff>0%</div>
84
+ </div>
85
+ </div>
86
+
87
+ <div class="co-card co-viz-card">
88
+ <h3 class="co-card-title">
89
+ <Icon name="mdi:eye-outline" />
90
+ {t.vizTitle}
91
+ </h3>
92
+ <div class="co-viz" data-co-viz>
93
+ <div class="co-empty-state" data-co-initial-empty>{t.emptyState}</div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ </div>
99
+
100
+ <template data-co-row-tmpl>
101
+ <div class="co-row">
102
+ <div class="co-row-len-wrap">
103
+ <input type="number" data-co-inp-len placeholder={t.placeholderLength} class="co-row-input" />
104
+ <span class="co-row-unit">mm</span>
105
+ </div>
106
+ <div class="co-row-wid-wrap co-hidden" data-co-wid-wrap>
107
+ <input type="number" data-co-inp-wid placeholder={t.placeholderWidth} class="co-row-input co-row-input-blue" />
108
+ <span class="co-row-unit">mm</span>
109
+ </div>
110
+ <div class="co-row-qty-wrap">
111
+ <input type="number" data-co-inp-qty placeholder={t.placeholderQty} value="1" class="co-row-input co-row-input-qty" />
112
+ </div>
113
+ <button class="co-btn-rm" data-co-btn-rm title={t.btnRemoveTitle}>
114
+ <Icon name="mdi:close" />
115
+ </button>
116
+ </div>
117
+ </template>
118
+ </div>
119
+
120
+ <script>
121
+ import { optimize1D, optimize2D } from './optimizer';
122
+ import type { CutRequest, OptimizationResult, OptimizedPiece, Rect } from './optimizer';
123
+
124
+ interface CoState {
125
+ mode: string;
126
+ cuts: CutRequest[];
127
+ calc: () => void;
128
+ }
129
+
130
+ interface CoCfg {
131
+ stockLen: number;
132
+ stockWid: number;
133
+ kerf: number;
134
+ }
135
+
136
+ function coNum(el: Element | null): number {
137
+ if (!(el instanceof HTMLInputElement)) return 0;
138
+ return parseFloat(el.value) || 0;
139
+ }
140
+
141
+ function coAttr(root: Element, key: string): string {
142
+ return root.getAttribute(key) ?? '';
143
+ }
144
+
145
+ function coSetText(root: Element, sel: string, text: string): void {
146
+ const el = root.querySelector(sel);
147
+ if (el) el.textContent = text;
148
+ }
149
+
150
+ function coDebounce(fn: () => void, ms: number): () => void {
151
+ let timer = 0;
152
+ return () => { clearTimeout(timer); timer = window.setTimeout(fn, ms); };
153
+ }
154
+
155
+ function coCfg(root: Element): CoCfg {
156
+ return {
157
+ stockLen: coNum(root.querySelector('[data-co-stock-len]')) || 2400,
158
+ stockWid: coNum(root.querySelector('[data-co-stock-wid]')) || 1200,
159
+ kerf: coNum(root.querySelector('[data-co-kerf]')),
160
+ };
161
+ }
162
+
163
+ function coValidCuts(state: CoState): CutRequest[] {
164
+ if (state.mode === 'linear') {
165
+ return state.cuts.filter((c) => c.length > 0 && c.quantity > 0);
166
+ }
167
+ return state.cuts.filter((c) => c.length > 0 && c.width > 0 && c.quantity > 0);
168
+ }
169
+
170
+ function coStats1D(root: Element, result: OptimizationResult, stockLen: number): void {
171
+ const totalUsed = result.stockPieces.reduce((a, p) => a + (p.stockLength - p.waste), 0);
172
+ const total = result.totalStockUsed * stockLen;
173
+ const eff = total > 0 ? ((totalUsed / total) * 100).toFixed(1) : '0.0';
174
+ coSetText(root, '[data-co-stat-used]', result.totalStockUsed.toString());
175
+ coSetText(root, '[data-co-stat-cuts]', result.totalCuts.toString());
176
+ coSetText(root, '[data-co-stat-waste]', `${result.totalWaste.toFixed(0)} mm`);
177
+ coSetText(root, '[data-co-stat-eff]', `${eff}%`);
178
+ }
179
+
180
+ function coStats2D(root: Element, result: OptimizationResult, cfg: CoCfg): void {
181
+ const totalArea = result.totalStockUsed * cfg.stockLen * cfg.stockWid;
182
+ const usedArea = totalArea - result.totalWaste;
183
+ const eff = totalArea > 0 ? ((usedArea / totalArea) * 100).toFixed(1) : '0.0';
184
+ coSetText(root, '[data-co-stat-used]', result.totalStockUsed.toString());
185
+ coSetText(root, '[data-co-stat-cuts]', result.totalCuts.toString());
186
+ coSetText(root, '[data-co-stat-waste]', `${(result.totalWaste / 1_000_000).toFixed(4)} m²`);
187
+ coSetText(root, '[data-co-stat-eff]', `${eff}%`);
188
+ }
189
+
190
+ function coMakeSegment(cutLen: number, stockMax: number): HTMLElement {
191
+ const seg = document.createElement('div');
192
+ seg.className = 'co-seg';
193
+ seg.style.width = `${(cutLen / stockMax) * 100}%`;
194
+ seg.title = `${cutLen}mm`;
195
+ seg.textContent = cutLen < 50 ? '' : `${cutLen}`;
196
+ return seg;
197
+ }
198
+
199
+ function coMakeBar1D(root: Element, piece: OptimizedPiece, stockMax: number): HTMLElement {
200
+ const wrap = document.createElement('div');
201
+ wrap.className = 'co-bar-wrap';
202
+ const hdr = document.createElement('div');
203
+ hdr.className = 'co-bar-hdr';
204
+ hdr.innerHTML = `<span>${coAttr(root, 'data-co-stock-label')} #${piece.stockId}</span><span>${coAttr(root, 'data-co-waste-label')} ${piece.waste.toFixed(0)}mm</span>`;
205
+ const bar = document.createElement('div');
206
+ bar.className = 'co-bar';
207
+ (piece.cuts as number[]).forEach((c) => bar.appendChild(coMakeSegment(c, stockMax)));
208
+ wrap.appendChild(hdr);
209
+ wrap.appendChild(bar);
210
+ return wrap;
211
+ }
212
+
213
+ function coRender1D(root: Element, pieces: OptimizedPiece[], stockMax: number): void {
214
+ const viz = root.querySelector('[data-co-viz]') as HTMLElement | null;
215
+ if (!viz) return;
216
+ viz.innerHTML = '';
217
+ if (pieces.length === 0) {
218
+ viz.innerHTML = `<div class="co-empty-state">${coAttr(root, 'data-co-no-cuts')}</div>`;
219
+ return;
220
+ }
221
+ pieces.forEach((p) => viz.appendChild(coMakeBar1D(root, p, stockMax)));
222
+ }
223
+
224
+ function coMakeRect(rect: Rect, stockLen: number, stockWid: number): HTMLElement {
225
+ const el = document.createElement('div');
226
+ el.className = 'co-rect';
227
+ el.style.left = `${(rect.x / stockLen) * 100}%`;
228
+ el.style.top = `${(rect.y / stockWid) * 100}%`;
229
+ el.style.width = `${(rect.w / stockLen) * 100}%`;
230
+ el.style.height = `${(rect.h / stockWid) * 100}%`;
231
+ el.title = `${rect.w} x ${rect.h}`;
232
+ if (rect.w > 50 && rect.h > 50) el.textContent = `${rect.w}×${rect.h}`;
233
+ return el;
234
+ }
235
+
236
+ function coMakePanel2D(root: Element, piece: OptimizedPiece, stockLen: number, stockWid: number): HTMLElement {
237
+ const wrap = document.createElement('div');
238
+ wrap.className = 'co-panel-wrap';
239
+ const hdr = document.createElement('div');
240
+ hdr.className = 'co-bar-hdr';
241
+ hdr.innerHTML = `<span>${coAttr(root, 'data-co-panel-label')} #${piece.stockId}</span><span>${coAttr(root, 'data-co-eff-label')} ${((piece.utilization ?? 0) * 100).toFixed(1)}%</span>`;
242
+ const sheet = document.createElement('div');
243
+ sheet.className = 'co-sheet';
244
+ sheet.style.aspectRatio = `${stockLen}/${stockWid}`;
245
+ (piece.cuts as Rect[]).forEach((r) => sheet.appendChild(coMakeRect(r, stockLen, stockWid)));
246
+ wrap.appendChild(hdr);
247
+ wrap.appendChild(sheet);
248
+ return wrap;
249
+ }
250
+
251
+ function coRender2D(root: Element, pieces: OptimizedPiece[], stockLen: number, stockWid: number): void {
252
+ const viz = root.querySelector('[data-co-viz]') as HTMLElement | null;
253
+ if (!viz) return;
254
+ viz.innerHTML = '';
255
+ if (pieces.length === 0) {
256
+ viz.innerHTML = `<div class="co-empty-state">${coAttr(root, 'data-co-no-panels')}</div>`;
257
+ return;
258
+ }
259
+ pieces.forEach((p) => viz.appendChild(coMakePanel2D(root, p, stockLen, stockWid)));
260
+ }
261
+
262
+ function coCalc(root: Element, state: CoState): void {
263
+ const cfg = coCfg(root);
264
+ const valid = coValidCuts(state);
265
+ const empty: OptimizationResult = { totalStockUsed: 0, totalWaste: 0, totalCuts: 0, stockPieces: [] };
266
+ if (state.mode === 'linear') {
267
+ const res = valid.length > 0 ? optimize1D(valid, cfg.stockLen, cfg.kerf) : empty;
268
+ coStats1D(root, res, cfg.stockLen);
269
+ coRender1D(root, res.stockPieces, cfg.stockLen);
270
+ } else {
271
+ const res = valid.length > 0 ? optimize2D(valid, cfg.stockLen, cfg.stockWid, cfg.kerf) : empty;
272
+ coStats2D(root, res, cfg);
273
+ coRender2D(root, res.stockPieces, cfg.stockLen, cfg.stockWid);
274
+ }
275
+ }
276
+
277
+ function coMakeRow(root: Element, state: CoState, index: number): Element {
278
+ const tmpl = root.querySelector('[data-co-row-tmpl]') as HTMLTemplateElement;
279
+ const clone = tmpl.content.cloneNode(true) as DocumentFragment;
280
+ const row = clone.querySelector('div')!;
281
+ const len = row.querySelector('[data-co-inp-len]') as HTMLInputElement;
282
+ const wid = row.querySelector('[data-co-inp-wid]') as HTMLInputElement;
283
+ const qty = row.querySelector('[data-co-inp-qty]') as HTMLInputElement;
284
+ const widWrap = row.querySelector('[data-co-wid-wrap]') as HTMLElement;
285
+ widWrap?.classList.toggle('co-hidden', state.mode !== 'panel');
286
+ len.value = state.cuts[index].length.toString();
287
+ wid.value = state.cuts[index].width.toString();
288
+ qty.value = state.cuts[index].quantity.toString();
289
+ len.addEventListener('input', () => { state.cuts[index].length = coNum(len); state.calc(); });
290
+ wid.addEventListener('input', () => { state.cuts[index].width = coNum(wid); state.calc(); });
291
+ qty.addEventListener('input', () => { state.cuts[index].quantity = coNum(qty); state.calc(); });
292
+ row.querySelector('[data-co-btn-rm]')?.addEventListener('click', () => {
293
+ state.cuts.splice(index, 1); coRenderRows(root, state); state.calc();
294
+ });
295
+ return row;
296
+ }
297
+
298
+ function coRenderRows(root: Element, state: CoState): void {
299
+ const container = root.querySelector('[data-co-list]') as HTMLElement | null;
300
+ if (!container) return;
301
+ container.innerHTML = '';
302
+ state.cuts.forEach((_, i) => container.appendChild(coMakeRow(root, state, i)));
303
+ }
304
+
305
+ function coSetMode(root: Element, state: CoState, mode: string): void {
306
+ state.mode = mode;
307
+ root.querySelectorAll('[data-co-mode-btn]').forEach((btn) => {
308
+ btn.classList.toggle('co-mode-active', (btn as HTMLElement).dataset.coModeBtn === mode);
309
+ });
310
+ const widWrap = root.querySelector('[data-co-stock-wid-wrap]') as HTMLElement | null;
311
+ if (widWrap) widWrap.classList.toggle('co-hidden', mode !== 'panel');
312
+ coRenderRows(root, state);
313
+ state.calc();
314
+ }
315
+
316
+ function coAttach(root: Element, state: CoState): void {
317
+ root.querySelectorAll('[data-co-mode-btn]').forEach((btn) => {
318
+ btn.addEventListener('click', () => {
319
+ coSetMode(root, state, (btn as HTMLElement).dataset.coModeBtn ?? 'linear');
320
+ });
321
+ });
322
+ root.querySelector('[data-co-btn-add]')?.addEventListener('click', () => {
323
+ state.cuts.push({ id: crypto.randomUUID(), length: 0, width: 0, quantity: 1 });
324
+ coRenderRows(root, state);
325
+ });
326
+ root.querySelectorAll('[data-co-cfg-inp]').forEach((inp) => {
327
+ inp.addEventListener('input', state.calc);
328
+ });
329
+ }
330
+
331
+ function coInit(root: Element): void {
332
+ const state: CoState = {
333
+ mode: 'linear',
334
+ cuts: [
335
+ { id: '1', length: 800, width: 400, quantity: 2 },
336
+ { id: '2', length: 450, width: 300, quantity: 3 },
337
+ ],
338
+ calc: () => {},
339
+ };
340
+ state.calc = coDebounce(() => coCalc(root, state), 300);
341
+ coAttach(root, state);
342
+ coSetMode(root, state, 'linear');
343
+ coCalc(root, state);
344
+ }
345
+
346
+ document.querySelectorAll('[data-co-root]').forEach(coInit);
347
+ </script>
348
+
349
+ <style>
350
+ .co-root {
351
+ max-width: 1200px;
352
+ margin: 0 auto;
353
+ user-select: none;
354
+ }
355
+
356
+ .co-hidden {
357
+ display: none;
358
+ }
359
+
360
+ .co-grid {
361
+ display: grid;
362
+ grid-template-columns: 1fr 2fr;
363
+ gap: 2rem;
364
+ }
365
+
366
+ .co-sidebar {
367
+ display: flex;
368
+ flex-direction: column;
369
+ gap: 1.5rem;
370
+ }
371
+
372
+ .co-main {
373
+ display: flex;
374
+ flex-direction: column;
375
+ gap: 1.5rem;
376
+ }
377
+
378
+ .co-card {
379
+ background: #fff;
380
+ border-radius: 0.75rem;
381
+ padding: 1.5rem;
382
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
383
+ border: 1px solid #f1f5f9;
384
+ }
385
+
386
+ :global(.theme-dark) .co-card {
387
+ background: #1e293b;
388
+ border-color: #334155;
389
+ }
390
+
391
+ .co-card-title {
392
+ font-size: 1rem;
393
+ font-weight: 700;
394
+ color: #1e293b;
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 0.5rem;
398
+ margin: 0 0 1rem;
399
+ }
400
+
401
+ :global(.theme-dark) .co-card-title {
402
+ color: #fff;
403
+ }
404
+
405
+ .co-card-title svg {
406
+ width: 1.25rem;
407
+ height: 1.25rem;
408
+ color: #94a3b8;
409
+ }
410
+
411
+ .co-card-title-row {
412
+ display: flex;
413
+ align-items: center;
414
+ justify-content: space-between;
415
+ margin-bottom: 1rem;
416
+ }
417
+
418
+ .co-card-title-row .co-card-title {
419
+ margin-bottom: 0;
420
+ }
421
+
422
+ .co-mode-toggle {
423
+ display: flex;
424
+ gap: 0;
425
+ background: #f1f5f9;
426
+ padding: 0.375rem;
427
+ border-radius: 0.625rem;
428
+ margin-bottom: 1.5rem;
429
+ }
430
+
431
+ :global(.theme-dark) .co-mode-toggle {
432
+ background: rgba(30, 41, 59, 0.8);
433
+ }
434
+
435
+ .co-mode-btn {
436
+ flex: 1;
437
+ padding: 0.375rem 0;
438
+ font-size: 0.8125rem;
439
+ font-weight: 700;
440
+ border-radius: 0.375rem;
441
+ border: none;
442
+ background: transparent;
443
+ color: #6b7280;
444
+ cursor: pointer;
445
+ transition: all 0.15s ease;
446
+ }
447
+
448
+ .co-mode-btn.co-mode-active {
449
+ background: #f97316;
450
+ color: #fff;
451
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
452
+ }
453
+
454
+ :global(.theme-dark) .co-mode-btn {
455
+ color: #94a3b8;
456
+ }
457
+
458
+ :global(.theme-dark) .co-mode-btn.co-mode-active {
459
+ color: #fff;
460
+ }
461
+
462
+ .co-fields {
463
+ display: flex;
464
+ flex-direction: column;
465
+ gap: 1rem;
466
+ }
467
+
468
+ .co-field {
469
+ display: flex;
470
+ flex-direction: column;
471
+ gap: 0.25rem;
472
+ }
473
+
474
+ .co-field-label {
475
+ font-size: 0.7rem;
476
+ font-weight: 600;
477
+ color: #94a3b8;
478
+ text-transform: uppercase;
479
+ letter-spacing: 0.05em;
480
+ }
481
+
482
+ .co-input {
483
+ width: 100%;
484
+ background: #f8fafc;
485
+ border: 1px solid #e2e8f0;
486
+ border-radius: 0.5rem;
487
+ padding: 0.75rem 1rem;
488
+ font-weight: 700;
489
+ color: #1e293b;
490
+ outline: none;
491
+ transition: border-color 0.15s ease;
492
+ box-sizing: border-box;
493
+ }
494
+
495
+ .co-input:focus {
496
+ border-color: #f97316;
497
+ box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
498
+ }
499
+
500
+ :global(.theme-dark) .co-input {
501
+ background: #0f172a;
502
+ border-color: #334155;
503
+ color: #fff;
504
+ }
505
+
506
+ .co-btn-add {
507
+ padding: 0.5rem;
508
+ background: #fff7ed;
509
+ color: #ea580c;
510
+ border: none;
511
+ border-radius: 0.5rem;
512
+ cursor: pointer;
513
+ transition: background 0.15s ease;
514
+ display: flex;
515
+ align-items: center;
516
+ justify-content: center;
517
+ }
518
+
519
+ .co-btn-add:hover {
520
+ background: #fed7aa;
521
+ }
522
+
523
+ .co-btn-add svg {
524
+ width: 1.25rem;
525
+ height: 1.25rem;
526
+ }
527
+
528
+ :global(.theme-dark) .co-btn-add {
529
+ background: rgba(249, 115, 22, 0.15);
530
+ color: #fb923c;
531
+ }
532
+
533
+ .co-cut-list {
534
+ display: flex;
535
+ flex-direction: column;
536
+ gap: 0.75rem;
537
+ max-height: 400px;
538
+ overflow-y: auto;
539
+ padding-right: 0.25rem;
540
+ }
541
+
542
+ .co-cut-list::-webkit-scrollbar {
543
+ width: 4px;
544
+ }
545
+
546
+ .co-cut-list::-webkit-scrollbar-track {
547
+ background: transparent;
548
+ }
549
+
550
+ .co-cut-list::-webkit-scrollbar-thumb {
551
+ background: rgba(148, 163, 184, 0.4);
552
+ border-radius: 20px;
553
+ }
554
+
555
+ .co-row {
556
+ display: flex;
557
+ align-items: center;
558
+ gap: 0.5rem;
559
+ }
560
+
561
+ .co-row-len-wrap,
562
+ .co-row-wid-wrap {
563
+ position: relative;
564
+ flex: 1;
565
+ display: flex;
566
+ align-items: center;
567
+ }
568
+
569
+ .co-row-qty-wrap {
570
+ position: relative;
571
+ width: 5rem;
572
+ }
573
+
574
+ .co-row-input {
575
+ width: 100%;
576
+ background: #f8fafc;
577
+ border: 1px solid #e2e8f0;
578
+ border-radius: 0.5rem;
579
+ padding: 0.5rem 0.75rem;
580
+ font-size: 0.875rem;
581
+ font-weight: 600;
582
+ color: #1e293b;
583
+ outline: none;
584
+ box-sizing: border-box;
585
+ }
586
+
587
+ .co-row-input:focus {
588
+ border-color: #f97316;
589
+ box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
590
+ }
591
+
592
+ .co-row-input-blue:focus {
593
+ border-color: #3b82f6;
594
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
595
+ }
596
+
597
+ .co-row-input-qty {
598
+ text-align: center;
599
+ }
600
+
601
+ :global(.theme-dark) .co-row-input {
602
+ background: #0f172a;
603
+ border-color: #334155;
604
+ color: #fff;
605
+ }
606
+
607
+ .co-row-unit {
608
+ position: absolute;
609
+ right: 0.5rem;
610
+ font-size: 0.65rem;
611
+ font-weight: 700;
612
+ color: #94a3b8;
613
+ pointer-events: none;
614
+ }
615
+
616
+ .co-btn-rm {
617
+ padding: 0.4rem;
618
+ background: transparent;
619
+ border: none;
620
+ color: #94a3b8;
621
+ cursor: pointer;
622
+ border-radius: 0.375rem;
623
+ transition: color 0.15s ease;
624
+ flex-shrink: 0;
625
+ display: flex;
626
+ align-items: center;
627
+ }
628
+
629
+ .co-btn-rm:hover {
630
+ color: #ef4444;
631
+ }
632
+
633
+ .co-btn-rm svg {
634
+ width: 1rem;
635
+ height: 1rem;
636
+ }
637
+
638
+ .co-stats-grid {
639
+ display: grid;
640
+ grid-template-columns: repeat(4, 1fr);
641
+ gap: 1rem;
642
+ }
643
+
644
+ .co-stat-card {
645
+ background: #fff;
646
+ padding: 1rem;
647
+ border-radius: 0.75rem;
648
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
649
+ border: 1px solid #f1f5f9;
650
+ }
651
+
652
+ :global(.theme-dark) .co-stat-card {
653
+ background: #1e293b;
654
+ border-color: #334155;
655
+ }
656
+
657
+ .co-stat-label {
658
+ font-size: 0.65rem;
659
+ font-weight: 700;
660
+ color: #94a3b8;
661
+ text-transform: uppercase;
662
+ letter-spacing: 0.05em;
663
+ }
664
+
665
+ .co-stat-value {
666
+ font-size: 1.5rem;
667
+ font-weight: 900;
668
+ color: #1e293b;
669
+ margin-top: 0.25rem;
670
+ line-height: 1;
671
+ }
672
+
673
+ :global(.theme-dark) .co-stat-value {
674
+ color: #fff;
675
+ }
676
+
677
+ .co-stat-red {
678
+ color: #ef4444;
679
+ }
680
+
681
+ :global(.theme-dark) .co-stat-red {
682
+ color: #f87171;
683
+ }
684
+
685
+ .co-stat-green {
686
+ color: #22c55e;
687
+ }
688
+
689
+ :global(.theme-dark) .co-stat-green {
690
+ color: #4ade80;
691
+ }
692
+
693
+ .co-viz-card {
694
+ min-height: 400px;
695
+ }
696
+
697
+ .co-viz {
698
+ display: flex;
699
+ flex-direction: column;
700
+ gap: 2rem;
701
+ min-height: 16rem;
702
+ }
703
+
704
+ :global(.co-empty-state) {
705
+ display: flex;
706
+ align-items: center;
707
+ justify-content: center;
708
+ height: 16rem;
709
+ color: #94a3b8;
710
+ font-size: 0.875rem;
711
+ font-style: italic;
712
+ text-align: center;
713
+ }
714
+
715
+ :global(.co-bar-wrap) {
716
+ background: #f8fafc;
717
+ border-radius: 0.5rem;
718
+ padding: 0.75rem;
719
+ border: 1px solid #f1f5f9;
720
+ }
721
+
722
+ :global(.theme-dark .co-bar-wrap) {
723
+ background: rgba(15, 23, 42, 0.5);
724
+ border-color: #1e293b;
725
+ }
726
+
727
+ :global(.co-bar-hdr) {
728
+ display: flex;
729
+ justify-content: space-between;
730
+ font-size: 0.7rem;
731
+ font-weight: 700;
732
+ color: #94a3b8;
733
+ margin-bottom: 0.5rem;
734
+ }
735
+
736
+ :global(.co-bar) {
737
+ height: 3rem;
738
+ background: #e2e8f0;
739
+ border-radius: 0.25rem;
740
+ display: flex;
741
+ overflow: hidden;
742
+ position: relative;
743
+ }
744
+
745
+ :global(.theme-dark .co-bar) {
746
+ background: #334155;
747
+ }
748
+
749
+ :global(.co-seg) {
750
+ height: 100%;
751
+ background: linear-gradient(to bottom, #fb923c, #ea580c);
752
+ border-right: 1px solid rgba(255, 255, 255, 0.2);
753
+ display: flex;
754
+ align-items: center;
755
+ justify-content: center;
756
+ color: #fff;
757
+ font-size: 0.75rem;
758
+ font-weight: 700;
759
+ overflow: hidden;
760
+ cursor: default;
761
+ }
762
+
763
+ :global(.co-panel-wrap) {
764
+ background: #f8fafc;
765
+ border-radius: 0.5rem;
766
+ padding: 0.75rem;
767
+ border: 1px solid #f1f5f9;
768
+ }
769
+
770
+ :global(.theme-dark .co-panel-wrap) {
771
+ background: rgba(15, 23, 42, 0.5);
772
+ border-color: #1e293b;
773
+ }
774
+
775
+ :global(.co-sheet) {
776
+ position: relative;
777
+ background: #e2e8f0;
778
+ width: 100%;
779
+ border: 1px solid #cbd5e1;
780
+ }
781
+
782
+ :global(.theme-dark .co-sheet) {
783
+ background: #334155;
784
+ border-color: #475569;
785
+ }
786
+
787
+ :global(.co-rect) {
788
+ position: absolute;
789
+ background: linear-gradient(135deg, #3b82f6, #1d4ed8);
790
+ border: 1px solid rgba(255, 255, 255, 0.4);
791
+ display: flex;
792
+ align-items: center;
793
+ justify-content: center;
794
+ color: #fff;
795
+ font-size: 0.625rem;
796
+ font-weight: 700;
797
+ overflow: hidden;
798
+ cursor: default;
799
+ transition: filter 0.15s ease;
800
+ }
801
+
802
+ :global(.co-rect:hover) {
803
+ filter: brightness(1.1);
804
+ }
805
+
806
+ @media (max-width: 1024px) {
807
+ .co-grid {
808
+ grid-template-columns: 1fr;
809
+ }
810
+
811
+ .co-stats-grid {
812
+ grid-template-columns: repeat(2, 1fr);
813
+ }
814
+ }
815
+
816
+ @media (max-width: 640px) {
817
+ .co-stats-grid {
818
+ grid-template-columns: repeat(2, 1fr);
819
+ }
820
+
821
+ .co-card {
822
+ padding: 1rem;
823
+ }
824
+ }
825
+ </style>