/
acecomfort
/
ace
Обзор
Документация
Войти
/
acecomfort
/
ace
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
ACEPRO.html
797 строк
48 KB
acecomfort
Create: ACEPRO.html
21 июл 2026, 21:52
Верифицирован
21 июл 2026, 21:52
87d9820
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"> <title>ACE Blinds Pro — Конструктор жалюзи</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f0f2f5; display: flex; height: 100vh; overflow: hidden; color: #1e293b; } .sidebar { width: 260px; min-width: 260px; background: #0f172a; color: white; display: flex; flex-direction: column; height: 100vh; overflow-y: auto; box-shadow: 2px 0 12px rgba(0,0,0,0.2); z-index: 20; } .sidebar-header { padding: 20px 16px; border-bottom: 1px solid #1e293b; } .sidebar-header h2 { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; background: linear-gradient(135deg, #60a5fa, #34d399); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .sidebar-nav { flex: 1; padding: 8px 0; } .sidebar-nav .nav-btn { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 20px; border: none; background: transparent; color: #cbd5e1; font-size: 14px; cursor: pointer; transition: 0.2s; text-align: left; font-weight: 500; } .sidebar-nav .nav-btn:hover { background: rgba(255,255,255,0.05); } .sidebar-nav .nav-btn.active { background: #1e3a5f; color: #60a5fa; border-left: 3px solid #3b82f6; } .sidebar-footer { padding: 12px 16px; border-top: 1px solid #1e293b; display: flex; flex-direction: column; gap: 6px; } .sidebar-footer button { padding: 8px; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #e2e8f0; cursor: pointer; font-size: 12px; } .btn-primary { background: #3b82f6; border: none; color: white; font-weight: 600; } .main-content { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; background: #f1f5f9; } .panel { display: none; flex-direction: column; gap: 16px; animation: fade 0.25s; } .panel.active { display: flex; } @keyframes fade { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .card { background: white; border-radius: 16px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .form-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 10px; } .form-group { flex: 1; min-width: 100px; } .form-group label { display: block; font-size: 10px; font-weight: 700; color: #64748b; text-transform: uppercase; margin-bottom: 2px; } input, select, textarea { width: 100%; padding: 8px 10px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 13px; background: #f8fafc; } button { cursor: pointer; } .vis-wrapper { position: relative; width: 100%; } .vis-wrapper canvas { width: 100%; height: auto; background: #f8fafc; border-radius: 12px; border: 1px solid #e2e8f0; aspect-ratio: 4/3; touch-action: none; } .btn { padding: 8px 16px; border-radius: 8px; font-weight: 600; font-size: 13px; border: none; display: inline-flex; align-items: center; gap: 6px; } .btn-sm { padding: 5px 10px; font-size: 11px; } .btn-outline { background: transparent; border: 2px solid #3b82f6; color: #3b82f6; } .legend { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; } .legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; } .legend-color { width: 16px; height: 16px; border-radius: 4px; } .project-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom:1px solid #e2e8f0; } .drag-marker { fill: #3b82f6; stroke: white; stroke-width: 2; cursor: ns-resize; } </style> </head> <body> <div class="sidebar"> <div class="sidebar-header"><h2>🏢 ACE Blinds Pro</h2></div> <div class="sidebar-nav"> <button class="nav-btn active" data-panel="arch"><span>🌊</span> Арка</button> <button class="nav-btn" data-panel="multi"><span>📐</span> Мультифактур</button> <button class="nav-btn" data-panel="roll"><span>📜</span> Рулонные</button> <button class="nav-btn" data-panel="vertical"><span>↕️</span> Вертикальные</button> <button class="nav-btn" data-panel="horizontal"><span>↔️</span> Горизонтальные</button> <button class="nav-btn" data-panel="plisse"><span>🌀</span> Плиссе</button> <button class="nav-btn" data-panel="roman"><span>🏛️</span> Римские</button> <button class="nav-btn" data-panel="daynight"><span>🌗</span> День-Ночь</button> <button class="nav-btn" data-panel="custom"><span>🎨</span> Свой порядок</button> <button class="nav-btn" data-panel="clients"><span>👥</span> Клиенты и заказы</button> <button class="nav-btn" data-panel="projects"><span>📁</span> Проекты</button> </div> <div class="sidebar-footer"> <button onclick="saveProject()" class="btn-primary">💾 Сохранить проект</button> <button onclick="loadProject()">📂 Загрузить</button> <button onclick="printSpec()">🖨️ Печать спецификации</button> <button onclick="resetAll()">🗑️ Сброс</button> </div> </div> <div class="main-content" id="mainContent"></div> <script> (function() { // ========== ЦВЕТА ========== const FULL_COLORS = [ { name: 'Белый', hex: '#ffffff' },{ name: 'Молочный', hex: '#fff5e6' },{ name: 'Кремовый', hex: '#fffdd0' }, { name: 'Бежевый', hex: '#f5deb3' },{ name: 'Песочный', hex: '#c2b280' },{ name: 'Светло-серый', hex: '#d3d3d3' }, { name: 'Серый', hex: '#808080' },{ name: 'Графит', hex: '#36454f' },{ name: 'Чёрный', hex: '#000000' }, { name: 'Венге', hex: '#3b2f2f' },{ name: 'Дуб', hex: '#c19a6b' },{ name: 'Орех', hex: '#8b6914' }, { name: 'Красный', hex: '#ff0000' },{ name: 'Бордовый', hex: '#800020' },{ name: 'Синий', hex: '#0066ff' }, { name: 'Голубой', hex: '#87ceeb' },{ name: 'Зелёный', hex: '#008000' },{ name: 'Жёлтый', hex: '#ffd700' }, { name: 'Оранжевый', hex: '#ffa500' },{ name: 'Коричневый', hex: '#8b4513' } ]; function getColorHex(name) { const c = FULL_COLORS.find(x => x.name === name); return c ? c.hex : '#808080'; } function darken(hex, amt=20) { const num = parseInt(hex.slice(1),16); const r=Math.max(0,(num>>16)-amt), g=Math.max(0,((num>>8)&0xff)-amt), b=Math.max(0,(num&0xff)-amt); return '#'+(r<<16|g<<8|b).toString(16).padStart(6,'0'); } function lighten(hex, amt=20) { const num = parseInt(hex.slice(1),16); const r=Math.min(255,(num>>16)+amt), g=Math.min(255,((num>>8)&0xff)+amt), b=Math.min(255,(num&0xff)+amt); return '#'+(r<<16|g<<8|b).toString(16).padStart(6,'0'); } // ========== СОСТОЯНИЕ (все размеры в см) ========== let state = { arch: { runners: 24, width: 200, height: 250, maxLen: 280, minLen: 180, type: 'sinus', waveAmp: 0, waveFreq: 0, gap: 0.2, orientation: 'top', hardware: 'standard', asymLeft: 200, asymRight: 250, asymPeak: 50, sections: [], pattern: 'sequential', customOrder: '', manualMode: false, manualLengths: [] }, multi: { width: 200, height: 250, gap: 0.2, rows: [], sections: [], manualMode: false }, roll: { width: 150, height: 200, color: '#ffffff', fabric: 'standard', position: 'closed', hardware: false }, vertical: { width: 200, height: 250, count: 20, color: '#d3d3d3', rotate: 0, position: 'closed', hardware: false }, horizontal: { width: 200, height: 250, count: 30, color: '#c0c0c0', tilt: 0, position: 'closed', hardware: false }, plisse: { width: 150, height: 200, folds: 30, color: '#f5deb3', position: 'closed', hardware: false }, roman: { width: 180, height: 220, folds: 6, color: '#ffffff', position: 'closed', hardware: false }, daynight: { width: 200, height: 250, color1: '#ffffff', color2: '#d3d3d3', open: 50, stripe: 5, position: 'closed', hardware: false }, custom: { width: 200, height: 250, count: 24, pattern: [] } }; let clients = JSON.parse(localStorage.getItem('ace_clients') || '[]'); let projects = JSON.parse(localStorage.getItem('ace_projects') || '[]'); let currentClientId = null; let currentOrderId = null; let currentPanel = 'arch'; let lamellasGlobal = []; // Для drag let dragInfo = null; // Инициализация начальных данных state.arch.sections = [{ color: 'Белый', count: 12 }, { color: 'Бежевый', count: 12 }]; state.multi.sections = [{ color: 'Белый', count: 8 }, { color: 'Синий', count: 8 }]; state.multi.rows = [ { count: 20, length: 250, offset: 0, waveAmp: 0, waveFreq: 0, archType: 'none', colorOverride: null }, { count: 20, length: 220, offset: 0, waveAmp: 0, waveFreq: 0, archType: 'none', colorOverride: null } ]; // ========== ПОСТРОЕНИЕ ИНТЕРФЕЙСА ========== function buildPanels() { const main = document.getElementById('mainContent'); main.innerHTML = ` <div id="panel-arch" class="panel active">${buildArchPanel()}</div> <div id="panel-multi" class="panel">${buildMultiPanel()}</div> <div id="panel-roll" class="panel">${buildSimplePanel('roll','Рулонные шторы',['width','height','color','fabric','position','hardware'])}</div> <div id="panel-vertical" class="panel">${buildSimplePanel('vertical','Вертикальные жалюзи',['width','height','count','color','rotate','position','hardware'])}</div> <div id="panel-horizontal" class="panel">${buildSimplePanel('horizontal','Горизонтальные жалюзи',['width','height','count','color','tilt','position','hardware'])}</div> <div id="panel-plisse" class="panel">${buildSimplePanel('plisse','Плиссе',['width','height','folds','color','position','hardware'])}</div> <div id="panel-roman" class="panel">${buildSimplePanel('roman','Римские шторы',['width','height','folds','color','position','hardware'])}</div> <div id="panel-daynight" class="panel">${buildSimplePanel('daynight','День-Ночь',['width','height','color1','color2','open','stripe','position','hardware'])}</div> <div id="panel-custom" class="panel">${buildCustomPanel()}</div> <div id="panel-clients" class="panel">${buildClientsPanel()}</div> <div id="panel-projects" class="panel">${buildProjectsPanel()}</div> <div id="specPrintArea" style="display:none;"></div> `; attachEvents(); refreshArchSectionsUI(); refreshMultiUI(); renderCurrentPanel(); } function buildArchPanel() { return ` <div class="grid-2"> <div class="card"><h3>📐 Геометрия арки (см)</h3> <div class="form-row"><div class="form-group"><label>Кол-во ламелей</label><input type="number" id="archRunners" value="${state.arch.runners}" min="4"></div><div class="form-group"><label>Ширина</label><input type="number" id="archWidth" value="${state.arch.width}" step="1"></div><div class="form-group"><label>Высота</label><input type="number" id="archHeight" value="${state.arch.height}" step="1"></div></div> <div class="form-row"><div class="form-group"><label>Макс. длина</label><input type="number" id="archMaxLen" value="${state.arch.maxLen}" step="1"></div><div class="form-group"><label>Мин. длина</label><input type="number" id="archMinLen" value="${state.arch.minLen}" step="1"></div><div class="form-group"><label>Тип арки</label><select id="archType">${['sinus','circular','elliptic','parabolic','asymmetric'].map(t=>`<option value="${t}" ${state.arch.type===t?'selected':''}>${t}</option>`).join('')}</select></div></div> <div id="asymParams" style="display:none"><div class="form-row"><div class="form-group"><label>Левая высота</label><input type="number" id="asymLeft" value="${state.arch.asymLeft}"></div><div class="form-group"><label>Правая высота</label><input type="number" id="asymRight" value="${state.arch.asymRight}"></div><div class="form-group"><label>Пик смещения</label><input type="range" id="asymPeak" min="0" max="100" value="${state.arch.asymPeak}"><span id="asymPeakVal">${state.arch.asymPeak}%</span></div></div></div> <div class="form-row"><div class="form-group"><label>Амплитуда волны (см)</label><input type="range" id="archWaveAmp" min="0" max="20" value="${state.arch.waveAmp}" step="0.5"></div><div class="form-group"><label>Частота волны</label><input type="range" id="archWaveFreq" min="0" max="5" value="${state.arch.waveFreq}" step="0.5"></div><div class="form-group"><label>Зазор (см)</label><input type="number" id="archGap" value="${state.arch.gap}" step="0.1"></div></div> <div class="form-row"><div class="form-group"><label>Ориентация</label><select id="archOrientation"><option value="top" ${state.arch.orientation==='top'?'selected':''}>Арка сверху</option><option value="bottom" ${state.arch.orientation==='bottom'?'selected':''}>Арка снизу</option></select></div><div class="form-group"><label>Фурнитура</label><select id="archHardware"><option value="standard" ${state.arch.hardware==='standard'?'selected':''}>Стандарт</option><option value="cord" ${state.arch.hardware==='cord'?'selected':''}>Шнур</option><option value="motor" ${state.arch.hardware==='motor'?'selected':''}>Мотор</option></select></div></div> <div><label><input type="checkbox" id="archManualMode" ${state.arch.manualMode?'checked':''}> ✏️ Ручная коррекция</label></div> </div> <div class="card"><h3>🎨 Цвета</h3> <div><strong>Режим:</strong> <select id="archPattern"><option value="sequential" ${state.arch.pattern==='sequential'?'selected':''}>По порядку</option><option value="alternating">Чередование</option><option value="symmetry">Симметрия</option><option value="gradient">Градиент</option><option value="custom">Свой</option></select></div> <div id="customOrderArch" style="display:none"><textarea id="customOrderTextArch" placeholder="Цвета через запятую">${state.arch.customOrder}</textarea></div> <table><thead><tr><th>Цвет</th><th>Кол-во</th><th></th></tr></thead><tbody id="archSectionsBody"></tbody></table> <button class="btn btn-sm" onclick="addArchSection()">➕ Добавить цвет</button> </div> </div> <div class="card"><h3>Визуализация</h3><div class="vis-wrapper"><canvas id="archCanvas"></canvas></div><div id="archLegend" class="legend"></div></div> `; } function buildMultiPanel() { return ` <div class="grid-2"> <div class="card"><h3>📐 Параметры (см)</h3> <div class="form-row"><div class="form-group"><label>Ширина</label><input type="number" id="multiWidth" value="${state.multi.width}"></div><div class="form-group"><label>Высота</label><input type="number" id="multiHeight" value="${state.multi.height}"></div><div class="form-group"><label>Зазор</label><input type="number" id="multiGap" value="${state.multi.gap}" step="0.1"></div></div> <div id="multiRowsContainer"></div> <button class="btn btn-sm" onclick="addMultiRow()">➕ Добавить ряд</button> <div><label><input type="checkbox" id="multiManualMode" ${state.multi.manualMode?'checked':''}> ✏️ Ручная коррекция рядов</label></div> </div> <div class="card"><h3>🎨 Цвета</h3><table><thead><tr><th>Цвет</th><th>Кол-во</th></tr></thead><tbody id="multiSectionsBody"></tbody></table><button class="btn btn-sm" onclick="addMultiSection()">➕ Добавить цвет</button></div> </div> <div class="card"><h3>Визуализация (мультифактур — наложение)</h3><div class="vis-wrapper"><canvas id="multiCanvas"></canvas></div><div id="multiLegend" class="legend"></div></div> `; } function buildSimplePanel(type, title, fields) { let html = `<div class="card"><h3>${title}</h3><div class="form-row">`; fields.forEach(f => { switch(f) { case 'width': html += `<div class="form-group"><label>Ширина (см)</label><input type="number" id="${type}Width" value="${state[type].width}" step="0.1"></div>`; break; case 'height': html += `<div class="form-group"><label>Высота (см)</label><input type="number" id="${type}Height" value="${state[type].height}" step="0.1"></div>`; break; case 'count': html += `<div class="form-group"><label>Кол-во</label><input type="number" id="${type}Count" value="${state[type].count}"></div>`; break; case 'folds': html += `<div class="form-group"><label>Складки</label><input type="number" id="${type}Folds" value="${state[type].folds}"></div>`; break; case 'color': html += `<div class="form-group"><label>Цвет</label><input type="color" id="${type}Color" value="${state[type].color}"></div>`; break; case 'color1': html += `<div class="form-group"><label>Цвет 1</label><input type="color" id="${type}Color1" value="${state[type].color1}"></div>`; break; case 'color2': html += `<div class="form-group"><label>Цвет 2</label><input type="color" id="${type}Color2" value="${state[type].color2}"></div>`; break; case 'fabric': html += `<div class="form-group"><label>Ткань</label><select id="${type}Fabric"><option value="standard" ${state[type].fabric==='standard'?'selected':''}>Стандарт</option><option value="blackout" ${state[type].fabric==='blackout'?'selected':''}>Блэкаут</option></select></div>`; break; case 'position': html += `<div class="form-group"><label>Положение</label><select id="${type}Position"><option value="closed" ${state[type].position==='closed'?'selected':''}>Закрыто</option><option value="open" ${state[type].position==='open'?'selected':''}>Открыто</option><option value="tilt" ${state[type].position==='tilt'?'selected':''}>Наклон</option></select></div>`; break; case 'hardware': html += `<div class="form-group"><label>Фурнитура</label><input type="checkbox" id="${type}Hardware" ${state[type].hardware?'checked':''}></div>`; break; case 'rotate': html += `<div class="form-group"><label>Поворот °</label><input type="range" id="${type}Rotate" min="0" max="90" value="${state[type].rotate}"></div>`; break; case 'tilt': html += `<div class="form-group"><label>Наклон °</label><input type="range" id="${type}Tilt" min="0" max="45" value="${state[type].tilt}"></div>`; break; case 'open': html += `<div class="form-group"><label>Открытие %</label><input type="range" id="${type}Open" min="0" max="100" value="${state[type].open}"></div>`; break; case 'stripe': html += `<div class="form-group"><label>Полоса (см)</label><input type="number" id="${type}Stripe" value="${state[type].stripe}" step="0.1"></div>`; break; } }); html += `</div></div><div class="card"><div class="vis-wrapper"><canvas id="${type}Canvas"></canvas></div></div>`; return html; } function buildCustomPanel() { return `<div class="card"><h3>Свой порядок</h3><div class="form-row"><div class="form-group"><label>Ширина (см)</label><input type="number" id="customWidth" value="${state.custom.width}"></div><div class="form-group"><label>Высота (см)</label><input type="number" id="customHeight" value="${state.custom.height}"></div><div class="form-group"><label>Кол-во</label><input type="number" id="customCount" value="${state.custom.count}"></div></div><div id="customPatternContainer"></div><button class="btn btn-sm" onclick="addCustomPatternItem()">➕ Добавить цвет</button><div class="vis-wrapper"><canvas id="customCanvas"></canvas></div></div>`; } function buildClientsPanel() { return `<div class="card"><h3>👥 Клиенты и заказы</h3><div class="grid-2"><div><h4>Клиенты</h4><div class="client-list" id="clientList"></div><button class="btn btn-sm" onclick="addClient()">➕ Новый клиент</button></div><div><h4>Заказы</h4><div class="order-list" id="orderList"></div><button class="btn btn-sm" onclick="addOrder()">➕ Новый заказ</button><button class="btn btn-sm" onclick="loadOrderToConstructor()">📋 Загрузить в конструктор</button></div></div></div>`; } function buildProjectsPanel() { return `<div class="card"><h3>📁 Мои проекты</h3><div id="projectList"></div><button class="btn btn-sm" onclick="createProject()">➕ Сохранить текущий как проект</button></div>`; } // ========== ОБНОВЛЕНИЕ UI ========== function refreshArchSectionsUI() { const tbody = document.getElementById('archSectionsBody'); if (!tbody) return; tbody.innerHTML = state.arch.sections.map((s, i) => `<tr> <td><select class="archColorSelect" onchange="updateArchSection(${i},'color',this.value)">${FULL_COLORS.map(c => `<option value="${c.name}" ${c.name===s.color?'selected':''}>${c.name}</option>`).join('')}</select></td> <td><input type="number" value="${s.count}" min="1" style="width:60px" onchange="updateArchSection(${i},'count',this.value)"></td> <td><button class="btn btn-sm" onclick="deleteArchSection(${i})">✕</button></td> </tr>`).join(''); } window.addArchSection = () => { state.arch.sections.push({ color: 'Белый', count: 8 }); refreshArchSectionsUI(); renderCurrentPanel(); }; window.updateArchSection = (i, field, val) => { if (state.arch.sections[i]) { if (field==='color') state.arch.sections[i].color = val; else state.arch.sections[i].count = parseInt(val)||1; } renderCurrentPanel(); }; window.deleteArchSection = (i) => { state.arch.sections.splice(i,1); refreshArchSectionsUI(); renderCurrentPanel(); }; function refreshMultiUI() { const rowsContainer = document.getElementById('multiRowsContainer'); if (rowsContainer) { rowsContainer.innerHTML = state.multi.rows.map((r, i) => `<div class="card" style="margin-bottom:8px"> <strong>Ряд ${i+1}</strong> <button class="btn btn-sm" onclick="deleteMultiRow(${i})">🗑️</button> <div class="form-row"> <div class="form-group"><label>Кол-во</label><input type="number" value="${r.count}" onchange="updateMultiRow(${i},'count',this.value)"></div> <div class="form-group"><label>Длина (см)</label><input type="number" value="${r.length}" onchange="updateMultiRow(${i},'length',this.value)"></div> <div class="form-group"><label>Тип арки</label><select onchange="updateMultiRow(${i},'archType',this.value)"> <option value="none" ${r.archType==='none'?'selected':''}>Нет</option> <option value="sinus" ${r.archType==='sinus'?'selected':''}>Синус</option> <option value="circular" ${r.archType==='circular'?'selected':''}>Круг</option> <option value="elliptic" ${r.archType==='elliptic'?'selected':''}>Эллипс</option> </select></div> <div class="form-group"><label>Амплитуда волны (см)</label><input type="number" value="${r.waveAmp||0}" onchange="updateMultiRow(${i},'waveAmp',this.value)"></div> <div class="form-group"><label>Частота волны</label><input type="number" value="${r.waveFreq||0}" onchange="updateMultiRow(${i},'waveFreq',this.value)" step="0.5"></div> <div class="form-group"><label>Цвет ряда</label><select onchange="updateMultiRow(${i},'colorOverride',this.value)"><option value="">(общий)</option>${FULL_COLORS.map(c => `<option value="${c.name}" ${r.colorOverride===c.name?'selected':''}>${c.name}</option>`).join('')}</select></div> </div> </div>`).join(''); } const sectionsBody = document.getElementById('multiSectionsBody'); if (sectionsBody) { sectionsBody.innerHTML = state.multi.sections.map((s, i) => `<tr> <td><select onchange="updateMultiSection(${i},'color',this.value)">${FULL_COLORS.map(c => `<option value="${c.name}" ${c.name===s.color?'selected':''}>${c.name}</option>`).join('')}</select></td> <td><input type="number" value="${s.count}" style="width:60px" onchange="updateMultiSection(${i},'count',this.value)"></td> <td><button class="btn btn-sm" onclick="deleteMultiSection(${i})">✕</button></td> </tr>`).join(''); } } window.addMultiRow = () => { state.multi.rows.push({ count: 15, length: 200, offset: 0, waveAmp: 0, waveFreq: 0, archType: 'none', colorOverride: null }); refreshMultiUI(); renderCurrentPanel(); }; window.deleteMultiRow = (i) => { state.multi.rows.splice(i,1); refreshMultiUI(); renderCurrentPanel(); }; window.updateMultiRow = (i, field, val) => { if (state.multi.rows[i]) state.multi.rows[i][field] = (field==='count'||field==='length'||field==='waveAmp'||field==='waveFreq') ? parseFloat(val)||0 : val; renderCurrentPanel(); }; window.addMultiSection = () => { state.multi.sections.push({ color: 'Серый', count: 5 }); refreshMultiUI(); renderCurrentPanel(); }; window.updateMultiSection = (i, field, val) => { if (state.multi.sections[i]) state.multi.sections[i][field] = field==='count'? parseInt(val)||1 : val; renderCurrentPanel(); }; window.deleteMultiSection = (i) => { state.multi.sections.splice(i,1); refreshMultiUI(); renderCurrentPanel(); }; // ========== ПЕРЕКЛЮЧЕНИЕ ПАНЕЛЕЙ ========== function attachEvents() { document.querySelectorAll('.nav-btn').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); switchPanel(this.dataset.panel); }); }); document.querySelectorAll('input, select, textarea').forEach(el => { el.addEventListener('input', syncStateFromUI); el.addEventListener('change', syncStateFromUI); }); // Ручной режим const archManual = document.getElementById('archManualMode'); if (archManual) archManual.addEventListener('change', (e) => { state.arch.manualMode = e.target.checked; renderArch(); }); const multiManual = document.getElementById('multiManualMode'); if (multiManual) multiManual.addEventListener('change', (e) => { state.multi.manualMode = e.target.checked; renderMulti(); }); } function syncStateFromUI() { const id = this.id; if (!id) return; const val = this.type==='checkbox' ? this.checked : this.value; if (id.startsWith('arch')) updateArchState(id, val); else if (id.startsWith('multi')) updateMultiState(id, val); else if (id.startsWith('roll')) state.roll[id.replace('roll','').toLowerCase()] = val; else if (id.startsWith('vertical')) state.vertical[id.replace('vertical','').toLowerCase()] = val; else if (id.startsWith('horizontal')) state.horizontal[id.replace('horizontal','').toLowerCase()] = val; else if (id.startsWith('plisse')) state.plisse[id.replace('plisse','').toLowerCase()] = val; else if (id.startsWith('roman')) state.roman[id.replace('roman','').toLowerCase()] = val; else if (id.startsWith('daynight')) state.daynight[id.replace('daynight','').toLowerCase()] = val; else if (id.startsWith('custom')) state.custom[id.replace('custom','').toLowerCase()] = val; renderCurrentPanel(); } function updateArchState(id, val) { const map = { archRunners:'runners', archWidth:'width', archHeight:'height', archMaxLen:'maxLen', archMinLen:'minLen', archType:'type', archWaveAmp:'waveAmp', archWaveFreq:'waveFreq', archGap:'gap', archOrientation:'orientation', archHardware:'hardware', asymLeft:'asymLeft', asymRight:'asymRight', asymPeak:'asymPeak', archPattern:'pattern', customOrderTextArch:'customOrder' }; const prop = map[id]; if (prop) state.arch[prop] = isNaN(val) ? val : parseFloat(val); } function updateMultiState(id, val) { /* упрощенно */ } function switchPanel(panelId) { currentPanel = panelId; document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); const panel = document.getElementById(`panel-${panelId}`); if (panel) panel.classList.add('active'); renderCurrentPanel(); } function renderCurrentPanel() { switch (currentPanel) { case 'arch': renderArch(); break; case 'multi': renderMulti(); break; case 'roll': renderSimple('roll'); break; case 'vertical': renderSimple('vertical'); break; case 'horizontal': renderSimple('horizontal'); break; case 'plisse': renderSimple('plisse'); break; case 'roman': renderSimple('roman'); break; case 'daynight': renderSimple('daynight'); break; case 'custom': renderCustom(); break; case 'clients': renderClients(); break; case 'projects': renderProjects(); break; } } // ========== РЕНДЕРИНГ АРКИ С DRAG ========== function renderArch() { const canvas = document.getElementById('archCanvas'); if (!canvas) return; const w = canvas.parentElement.clientWidth || 600; const h = w * 0.75; canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.clearRect(0,0,w,h); const total = state.arch.runners; const width = state.arch.width; const height = state.arch.height; const gap = state.arch.gap; const orientation = state.arch.orientation; let lengths; if (state.arch.manualMode && state.arch.manualLengths.length === total) { lengths = state.arch.manualLengths; } else { lengths = calculateArchLengths(total, state.arch); if (!state.arch.manualMode) state.arch.manualLengths = [...lengths]; } const colors = generateArchColors(total); lamellasGlobal = lengths.map((len, i) => ({ length: len, color: colors[i], colorHex: getColorHex(colors[i]) })); const margin = 30; const scale = Math.min((w-2*margin)/width, (h-2*margin)/height); const ox = (w - width*scale)/2; const oy = (h - height*scale)/2; ctx.fillStyle = '#e2e8f0'; ctx.fillRect(0,0,w,h); ctx.fillStyle = '#ffffff'; ctx.fillRect(ox, oy, width*scale, height*scale); ctx.fillStyle = '#334155'; ctx.fillRect(ox, oy-4, width*scale, 8); const lamWidth = (width*scale - (total-1)*gap*scale) / total; const lamPositions = []; for (let i = 0; i < total; i++) { const x = ox + i*(lamWidth + gap*scale); const barH = lengths[i] * scale; const y = orientation === 'bottom' ? oy : oy + height*scale - barH; ctx.fillStyle = getColorHex(colors[i]); ctx.fillRect(x, y, lamWidth, barH); ctx.strokeStyle = '#ffffff33'; ctx.strokeRect(x, y, lamWidth, barH); lamPositions.push({ x, y, lamWidth, barH, index: i, length: lengths[i] }); } // Ручные маркеры if (state.arch.manualMode) { lamPositions.forEach(lp => { const mx = lp.x + lp.lamWidth/2; const my = orientation === 'bottom' ? lp.y + lp.barH : lp.y; ctx.beginPath(); ctx.arc(mx, my, 6, 0, 2*Math.PI); ctx.fillStyle = '#3b82f6'; ctx.fill(); ctx.strokeStyle = 'white'; ctx.stroke(); }); } // Drag events canvas.onmousedown = (e) => { if (!state.arch.manualMode) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; for (let lp of lamPositions) { const cx = lp.x + lp.lamWidth/2; const cy = orientation === 'bottom' ? lp.y + lp.barH : lp.y; if (Math.hypot(mx-cx, my-cy) < 8) { dragInfo = { type: 'arch', index: lp.index, startY: my, startLength: lp.length, scale, orientation, oy, height }; e.preventDefault(); break; } } }; canvas.onmousemove = (e) => { if (!dragInfo || dragInfo.type !== 'arch') return; const rect = canvas.getBoundingClientRect(); const my = e.clientY - rect.top; const delta = (dragInfo.startY - my) / dragInfo.scale; let newLen = dragInfo.startLength + delta; newLen = Math.max(10, Math.min(state.arch.maxLen, newLen)); state.arch.manualLengths[dragInfo.index] = Math.round(newLen*10)/10; renderArch(); }; canvas.onmouseup = () => { dragInfo = null; }; const legend = document.getElementById('archLegend'); if (legend) { const unique = [...new Set(colors)]; legend.innerHTML = unique.map(c => `<div class="legend-item"><div class="legend-color" style="background:${getColorHex(c)}"></div>${c}</div>`).join(''); } } function calculateArchLengths(total, arch) { const maxLen = arch.maxLen, minLen = arch.minLen; const lengths = []; for (let i = 0; i < total; i++) { const frac = total > 1 ? i/(total-1) : 0.5; let factor; switch (arch.type) { case 'sinus': factor = Math.sin(Math.PI * frac); break; case 'circular': factor = 1 - Math.sqrt(1 - Math.pow(2*frac-1,2)); break; case 'elliptic': factor = Math.sqrt(1 - Math.pow(2*frac-1,2)); break; case 'parabolic': factor = 1 - Math.pow(2*frac-1,2); break; case 'asymmetric': const peak = arch.asymPeak/100; factor = frac <= peak ? 1 - Math.pow(frac/peak,2) : 1 - Math.pow((1-frac)/(1-peak),2); break; default: factor = 0; } let len = maxLen - (maxLen - minLen) * factor; len += arch.waveAmp * Math.sin(2*Math.PI*frac*arch.waveFreq); lengths.push(Math.max(10, Math.round(len*10)/10)); } return lengths; } function generateArchColors(total) { const secs = state.arch.sections.filter(s => s.count > 0); let base = []; secs.forEach(s => { for (let i=0;i<s.count;i++) base.push(s.color); }); if (base.length === 0) base = ['Серый']; const pattern = state.arch.pattern; let result = []; if (pattern === 'sequential') for (let i=0;i<total;i++) result.push(base[i%base.length]); else if (pattern === 'alternating') { const uniq = [...new Set(base)]; for (let i=0;i<total;i++) result.push(uniq[i%uniq.length]); } else if (pattern === 'symmetry') { const half = Math.ceil(total/2); for (let i=0;i<half;i++) result.push(base[i%base.length]); result = result.concat([...result].reverse().slice(total%2)); } else if (pattern === 'gradient') { const uniq = [...new Set(base)]; for (let i=0;i<total;i++) { const idx = Math.floor(i/total*uniq.length); result.push(uniq[Math.min(idx,uniq.length-1)]); } } else if (pattern === 'custom') { const custom = state.arch.customOrder.split(',').map(s=>s.trim()).filter(Boolean); for (let i=0;i<total;i++) result.push(custom[i%custom.length] || base[i%base.length]); } return result.slice(0, total); } // ========== РЕНДЕРИНГ МУЛЬТИ С DRAG ========== function renderMulti() { const canvas = document.getElementById('multiCanvas'); if (!canvas) return; const w = canvas.parentElement.clientWidth || 600; const h = w * 0.75; canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.clearRect(0,0,w,h); const width = state.multi.width; const height = state.multi.height; const gap = state.multi.gap; const rows = state.multi.rows; if (!rows.length) { ctx.fillText('Добавьте ряды', w/2, h/2); return; } const margin = 30; const scale = Math.min((w-2*margin)/width, (h-2*margin)/height); const ox = (w - width*scale)/2; const oy = (h - height*scale)/2; ctx.fillStyle = '#e2e8f0'; ctx.fillRect(0,0,w,h); ctx.fillStyle = '#ffffff'; ctx.fillRect(ox, oy, width*scale, height*scale); const sortedRows = [...rows].sort((a,b) => b.length - a.length); const allLamellas = []; const rowPositions = []; sortedRows.forEach((row, rowIdx) => { const count = row.count; const rowLen = row.length; const lamWidth = (width*scale - (count-1)*gap*scale) / count; // Применяем арку/волну к длинам ламелей внутри ряда const lengths = []; for (let i = 0; i < count; i++) { let len = rowLen; if (row.archType && row.archType !== 'none') { const frac = count>1 ? i/(count-1) : 0.5; let factor; switch (row.archType) { case 'sinus': factor = Math.sin(Math.PI * frac); break; case 'circular': factor = 1 - Math.sqrt(1 - Math.pow(2*frac-1,2)); break; case 'elliptic': factor = Math.sqrt(1 - Math.pow(2*frac-1,2)); break; default: factor = 0; } len = rowLen - (rowLen * 0.3) * factor; } if (row.waveAmp) len += row.waveAmp * Math.sin(2*Math.PI*i*row.waveFreq/count); lengths.push(Math.max(5, len)); } const rowHeightPx = Math.max(...lengths) * scale; rowPositions.push({ y: oy, heightPx: rowHeightPx, row, originalIdx: rows.indexOf(row) }); for (let i = 0; i < count; i++) { const x = ox + i*(lamWidth + gap*scale); let colorName = row.colorOverride || (state.multi.sections[i % state.multi.sections.length]?.color) || 'Серый'; allLamellas.push({ x, lamWidth, rowHeight: lengths[i]*scale, color: colorName, rowIdx }); } }); allLamellas.forEach(l => { ctx.fillStyle = getColorHex(l.color); ctx.globalAlpha = 0.9; ctx.fillRect(l.x, oy, l.lamWidth, l.rowHeight); ctx.strokeStyle = '#ffffff33'; ctx.strokeRect(l.x, oy, l.lamWidth, l.rowHeight); }); ctx.globalAlpha = 1; // Ручные маркеры рядов if (state.multi.manualMode) { rowPositions.forEach(rp => { const y = rp.y; const h = rp.heightPx; ctx.fillStyle = '#3b82f680'; ctx.fillRect(ox, y-4, width*scale, 8); ctx.fillStyle = '#3b82f6'; ctx.fillRect(ox + width*scale/2 - 20, y-8, 40, 8); }); } canvas.onmousedown = (e) => { if (!state.multi.manualMode) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; for (let rp of rowPositions) { if (Math.abs(my - rp.y) < 10 && mx > ox && mx < ox+width*scale) { dragInfo = { type: 'multi', rowIdx: rp.originalIdx, startY: my, startLength: rp.row.length, scale, oy }; e.preventDefault(); break; } } }; canvas.onmousemove = (e) => { if (!dragInfo || dragInfo.type !== 'multi') return; const rect = canvas.getBoundingClientRect(); const my = e.clientY - rect.top; const delta = (dragInfo.startY - my) / dragInfo.scale; let newLen = dragInfo.startLength + delta; newLen = Math.max(10, Math.min(state.multi.height, newLen)); state.multi.rows[dragInfo.rowIdx].length = Math.round(newLen*10)/10; renderMulti(); }; canvas.onmouseup = () => { dragInfo = null; }; lamellasGlobal = allLamellas.map(l => ({ length: l.rowHeight/scale, color: l.color, colorHex: getColorHex(l.color) })); const legend = document.getElementById('multiLegend'); if (legend) { const uniqueColors = [...new Set(allLamellas.map(l => l.color))]; legend.innerHTML = uniqueColors.map(c => `<div class="legend-item"><div class="legend-color" style="background:${getColorHex(c)}"></div>${c}</div>`).join(''); } } // ========== ПРОСТЫЕ ТИПЫ (без drag) ========== function renderSimple(type) { const canvas = document.getElementById(`${type}Canvas`); if (!canvas) return; const w = canvas.parentElement.clientWidth || 600; const h = w * 0.75; canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.clearRect(0,0,w,h); const s = state[type]; const margin = 20; const scale = Math.min((w-2*margin)/s.width, (h-2*margin)/s.height); const ox = (w - s.width*scale)/2; const oy = (h - s.height*scale)/2; ctx.fillStyle = '#e2e8f0'; ctx.fillRect(0,0,w,h); ctx.fillStyle = '#ffffff'; ctx.fillRect(ox, oy, s.width*scale, s.height*scale); ctx.fillStyle = '#334155'; ctx.fillRect(ox, oy-4, s.width*scale, 8); if (type === 'roll') { const fabricColor = s.fabric === 'blackout' ? darken(s.color,40) : s.color; ctx.fillStyle = fabricColor; if (s.position === 'open') ctx.fillRect(ox, oy, s.width*scale, s.height*scale*0.2); else ctx.fillRect(ox, oy, s.width*scale, s.height*scale); if (s.hardware) { ctx.strokeStyle = '#475569'; ctx.beginPath(); ctx.moveTo(ox+s.width*scale-10, oy+s.height*scale); ctx.lineTo(ox+s.width*scale+20, oy+s.height*scale+30); ctx.stroke(); } } else if (type === 'vertical') { const count = s.count; const lamW = (s.width*scale) / count * 0.8; for (let i=0; i<count; i++) { const x = ox + i*(s.width*scale/count) + (s.width*scale/count - lamW)/2; ctx.fillStyle = s.color; if (s.position === 'open') ctx.fillRect(x, oy, lamW, s.height*scale*0.3); else if (s.position === 'tilt') { ctx.save(); ctx.translate(x+lamW/2, oy+s.height*scale/2); ctx.rotate(s.rotate*Math.PI/180); ctx.fillRect(-lamW/2, -s.height*scale/2, lamW, s.height*scale); ctx.restore(); } else ctx.fillRect(x, oy, lamW, s.height*scale); } if (s.hardware) { ctx.fillStyle = '#94a3b8'; ctx.fillRect(ox, oy-6, s.width*scale, 4); } } else if (type === 'horizontal') { const count = s.count; const lamH = (s.height*scale) / count * 0.8; for (let i=0; i<count; i++) { const y = oy + i*(s.height*scale/count) + (s.height*scale/count - lamH)/2; ctx.fillStyle = s.color; if (s.position === 'open') continue; else if (s.position === 'tilt') { ctx.save(); ctx.translate(ox+s.width*scale/2, y+lamH/2); ctx.rotate(s.tilt*Math.PI/180); ctx.fillRect(-s.width*scale/2, -lamH/2, s.width*scale, lamH); ctx.restore(); } else ctx.fillRect(ox, y, s.width*scale, lamH); } if (s.hardware) { ctx.fillStyle = '#94a3b8'; ctx.fillRect(ox, oy-6, s.width*scale, 4); } } else if (type === 'plisse') { const folds = s.folds; const foldH = s.height*scale / folds; for (let i=0; i<folds; i++) { const y = oy + i*foldH; ctx.fillStyle = s.color; if (s.position === 'open' && i > folds*0.3) continue; ctx.fillRect(ox, y, s.width*scale, foldH*0.7); } if (s.hardware) { ctx.strokeStyle = '#475569'; ctx.beginPath(); ctx.moveTo(ox+s.width*scale-5, oy+s.height*scale); ctx.lineTo(ox+s.width*scale+15, oy+s.height*scale+20); ctx.stroke(); } } else if (type === 'roman') { const folds = s.folds; const foldH = s.height*scale / folds; for (let i=0; i<folds; i++) { const y = oy + i*foldH; ctx.fillStyle = s.color; if (s.position === 'open' && i > folds*0.5) continue; ctx.fillRect(ox, y, s.width*scale, foldH); ctx.fillStyle = '#00000022'; ctx.fillRect(ox, y+foldH-3, s.width*scale, 3); } if (s.hardware) { ctx.strokeStyle = '#475569'; ctx.beginPath(); ctx.moveTo(ox+s.width*scale-5, oy+s.height*scale); ctx.lineTo(ox+s.width*scale+15, oy+s.height*scale+20); ctx.stroke(); } } else if (type === 'daynight') { const stripeW = (s.width*scale) / Math.floor(s.width / s.stripe); for (let i=0; i<Math.floor(s.width/s.stripe); i++) { const x = ox + i*stripeW; ctx.fillStyle = i%2===0 ? s.color1 : s.color2; const hh = s.height*scale * (1 - s.open/100); ctx.fillRect(x, oy + (s.height*scale - hh)/2, stripeW, hh); } if (s.hardware) { ctx.fillStyle = '#94a3b8'; ctx.fillRect(ox, oy-6, s.width*scale, 4); } } } function renderCustom() { const canvas = document.getElementById('customCanvas'); if (!canvas) return; const w = canvas.parentElement.clientWidth || 600; const h = w * 0.75; canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.clearRect(0,0,w,h); const width = state.custom.width, height = state.custom.height, count = state.custom.count; const margin = 20; const scale = Math.min((w-2*margin)/width, (h-2*margin)/height); const ox = (w - width*scale)/2, oy = (h - height*scale)/2; ctx.fillStyle = '#e2e8f0'; ctx.fillRect(0,0,w,h); ctx.fillStyle = '#ffffff'; ctx.fillRect(ox, oy, width*scale, height*scale); const pattern = state.custom.pattern; if (pattern.length === 0) { ctx.fillText('Добавьте цвета', w/2, h/2); return; } const seq = []; pattern.forEach(p => { for (let i=0;i<p.count;i++) seq.push(p.color); }); const lamW = (width*scale - (count-1)*0.2*scale) / count; for (let i=0; i<count; i++) { const x = ox + i*(lamW + 0.2*scale); ctx.fillStyle = getColorHex(seq[i%seq.length]); ctx.fillRect(x, oy, lamW, height*scale); } } window.addCustomPatternItem = () => { state.custom.pattern.push({ color: 'Белый', count: 1 }); renderCustom(); }; // ========== КЛИЕНТЫ И ЗАКАЗЫ ========== function renderClients() { const clientList = document.getElementById('clientList'); if (clientList) clientList.innerHTML = clients.map(c => `<div class="client-item ${c.id===currentClientId?'active':''}" onclick="selectClient('${c.id}')">${c.name}</div>`).join(''); renderOrders(); } function renderOrders() { const orderList = document.getElementById('orderList'); if (!orderList) return; const client = clients.find(c => c.id === currentClientId); if (client) { orderList.innerHTML = client.orders.map((o,i) => `<div class="order-item ${i===currentOrderId?'active':''}" onclick="selectOrder(${i})">Заказ ${i+1} (${new Date(o.date).toLocaleDateString()})</div>`).join(''); } else orderList.innerHTML = ''; } window.selectClient = (id) => { currentClientId = id; currentOrderId = null; renderClients(); }; window.selectOrder = (idx) => { currentOrderId = idx; renderClients(); }; window.addClient = () => { const name = prompt('Имя клиента:'); if (name) { clients.push({ id: Date.now().toString(), name, orders: [] }); saveClients(); renderClients(); } }; window.addOrder = () => { if (!currentClientId) { alert('Выберите клиента'); return; } const client = clients.find(c => c.id === currentClientId); if (!client) return; client.orders.push({ date: Date.now(), state: JSON.parse(JSON.stringify(state)) }); saveClients(); renderClients(); }; window.loadOrderToConstructor = () => { if (currentClientId === null || currentOrderId === null) { alert('Выберите клиента и заказ'); return; } const client = clients.find(c => c.id === currentClientId); if (client && client.orders[currentOrderId]) { state = JSON.parse(JSON.stringify(client.orders[currentOrderId].state)); buildPanels(); switchPanel('arch'); alert('Заказ загружен'); } }; function saveClients() { localStorage.setItem('ace_clients', JSON.stringify(clients)); } // ========== ПРОЕКТЫ ========== function renderProjects() { const list = document.getElementById('projectList'); if (!list) return; list.innerHTML = projects.map((p, i) => `<div class="project-item"> <span><strong>${p.name}</strong> (${new Date(p.date).toLocaleDateString()}) ${p.clientId ? '👤 '+ (clients.find(c=>c.id===p.clientId)?.name || '') : ''}</span> <div> <button class="btn btn-sm" onclick="loadProjectById(${i})">📂 Загрузить</button> <button class="btn btn-sm" onclick="deleteProject(${i})">🗑️</button> <select onchange="assignProjectClient(${i}, this.value)" style="width:auto; margin-left:4px;"> <option value="">Без клиента</option> ${clients.map(c => `<option value="${c.id}" ${p.clientId===c.id?'selected':''}>${c.name}</option>`).join('')} </select> </div> </div>`).join(''); } window.createProject = () => { const name = prompt('Название проекта:'); if (!name) return; projects.push({ id: Date.now().toString(), name, date: Date.now(), state: JSON.parse(JSON.stringify(state)), clientId: currentClientId || null }); saveProjects(); renderProjects(); }; window.loadProjectById = (idx) => { if (projects[idx]) { state = JSON.parse(JSON.stringify(projects[idx].state)); if (projects[idx].clientId) { currentClientId = projects[idx].clientId; currentOrderId = null; } buildPanels(); switchPanel('arch'); alert('Проект загружен'); } }; window.deleteProject = (idx) => { projects.splice(idx,1); saveProjects(); renderProjects(); }; window.assignProjectClient = (idx, clientId) => { projects[idx].clientId = clientId || null; saveProjects(); renderProjects(); }; function saveProjects() { localStorage.setItem('ace_projects', JSON.stringify(projects)); } // ========== СПЕЦИФИКАЦИЯ С КЛИЕНТОМ ========== function generateSpecification() { if (lamellasGlobal.length === 0) return '<p>Нет данных</p>'; let html = ''; const client = clients.find(c => c.id === currentClientId); if (client) { html += `<div style="margin-bottom:12px;"><strong>Клиент:</strong> ${client.name}`; if (currentOrderId !== null && client.orders[currentOrderId]) { html += ` | <strong>Заказ:</strong> ${new Date(client.orders[currentOrderId].date).toLocaleDateString()}`; } html += `</div>`; } const groups = {}; lamellasGlobal.forEach(l => { const key = `${l.color}_${l.length}`; if (!groups[key]) groups[key] = { color: l.color, length: l.length, count: 0, hex: l.colorHex }; groups[key].count++; }); html += `<table border="1" cellpadding="4" style="border-collapse:collapse;width:100%"><tr><th>Цвет</th><th>Длина (см)</th><th>Кол-во</th><th>Метраж (м)</th></tr>`; let totalM = 0; Object.values(groups).forEach(g => { const m = (g.length * g.count) / 100; totalM += m; html += `<tr><td><span style="display:inline-block;width:20px;height:20px;background:${g.hex};margin-right:8px;"></span>${g.color}</td><td>${g.length}</td><td>${g.count}</td><td>${m.toFixed(2)}</td></tr>`; }); html += `<tr><td colspan="3"><strong>Общий метраж</strong></td><td><strong>${totalM.toFixed(2)} м</strong></td></tr></table>`; return html; } window.printSpec = () => { const specContent = generateSpecification(); const printWindow = window.open('', '_blank'); printWindow.document.write(`<html><head><title>Спецификация</title></head><body>${specContent}</body></html>`); printWindow.document.close(); printWindow.print(); }; window.saveProject = () => { localStorage.setItem('ace_blinds_state', JSON.stringify(state)); alert('Проект сохранён'); }; window.loadProject = () => { const saved = localStorage.getItem('ace_blinds_state'); if (saved) { state = JSON.parse(saved); buildPanels(); switchPanel('arch'); alert('Проект загружен'); } else alert('Нет сохранения'); }; window.resetAll = () => { if (confirm('Сбросить всё?')) { localStorage.clear(); location.reload(); } }; buildPanels(); switchPanel('arch'); })(); </script> </body> </html>