(() => { 'use strict'; // -------- helpers ---------- const log = (...a) => console.log('[Classic]', ...a); const fail = (msg) => { console.error('[Classic ERROR]', msg); const d = document.getElementById('classic-display'); if (d) d.innerHTML = `
Classic failed: ${String(msg)}
`; }; const normHex = (h) => (String(h || '')).trim().toLowerCase(); // -------- persistent color selection (now supports image textures) ---------- const PALETTE_KEY = 'classic:colors:v2'; const TOPPER_COLOR_KEY = 'classic:topperColor:v2'; const MAX_SLOTS = 20; const SLOT_COUNT_KEY = 'classic:slotCount:v1'; const defaultColors = () => [ { hex: '#d92e3a', image: null }, { hex: '#ffffff', image: null }, { hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null }, { hex: '#fcd34d', image: null } ]; const defaultTopper = () => ({ hex: '#a18b67', image: 'images/chrome-gold.webp' }); function getClassicColors() { let arr = defaultColors(); try { const savedJSON = localStorage.getItem(PALETTE_KEY); if (!savedJSON) return arr; const saved = JSON.parse(savedJSON); if (Array.isArray(saved) && saved.length > 0) { if (typeof saved[0] === 'string') { arr = saved.slice(0, MAX_SLOTS).map(hex => ({ hex: normHex(hex), image: null })); } else if (typeof saved[0] === 'object' && saved[0] !== null) { arr = saved.slice(0, MAX_SLOTS); } } while (arr.length < 5) arr.push({ hex: '#ffffff', image: null }); if (arr.length > MAX_SLOTS) arr = arr.slice(0, MAX_SLOTS); } catch (e) { console.error('Failed to parse classic colors:', e); } return arr; } function setClassicColors(arr) { const clean = (arr || []).slice(0, MAX_SLOTS).map(c => ({ hex: normHex(c.hex), image: c.image || null })); while (clean.length < 5) clean.push({ hex: '#ffffff', image: null }); try { localStorage.setItem(PALETTE_KEY, JSON.stringify(clean)); } catch {} return clean; } function getTopperColor() { try { const saved = JSON.parse(localStorage.getItem(TOPPER_COLOR_KEY)); return (saved && saved.hex) ? saved : defaultTopper(); } catch { return defaultTopper(); } } function setTopperColor(colorObj) { const clean = { hex: normHex(colorObj.hex), image: colorObj.image || null }; try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {} } function buildClassicPalette() { const colors = getClassicColors(); const palette = { 0: { colour: '#FFFFFF', name: 'No Colour', image: null } }; colors.forEach((c, i) => { palette[i + 1] = { colour: c.hex, image: c.image }; }); return palette; } function flattenPalette() { const out = []; if (Array.isArray(window.PALETTE)) { window.PALETTE.forEach(group => { (group.colors || []).forEach(c => { if (!c?.hex) return; out.push({ hex: normHex(c.hex), name: c.name || c.hex, family: group.family || '', image: c.image || null }); }); }); } const seen = new Set(); return out.filter(c => (seen.has(c.hex) ? false : (seen.add(c.hex), true))); } // -------- tiny grid engine (Mithril) ---------- function GridCalculator() { if (typeof window.m === 'undefined') throw new Error('Mithril (m) not loaded'); let pxUnit = 10; let clusters = 10; let reverse = false; let topperEnabled = false; let topperType = 'round'; let topperOffsetX_Px = 0; let topperOffsetY_Px = 0; let topperSizeMultiplier = 1; let shineEnabled = true; const patterns = {}; const api = { patterns, initialPattern: 'Arch 4', controller: (el) => makeController(el), setClusters(n) { clusters = Math.max(1, (Number(n)|0) || 10); }, setReverse(on){ reverse = !!on; }, setTopperEnabled(on) { topperEnabled = !!on; }, setTopperType(type) { topperType = type || 'round'; }, setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; }, setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; }, setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; }, setShineEnabled(on) { shineEnabled = !!on; } }; const svg = (tag, attrs, children) => m(tag, attrs, children); function extend(p){ const parentName = p.deriveFrom; if (!parentName) return; const base = patterns[parentName]; if (!base) return; if (base.deriveFrom) extend(base); Object.keys(base).forEach(k => { if (!(k in p)) p[k] = base[k]; }); p.parent = base; } function BBox(){ this.min={x:Infinity,y:Infinity}; this.max={x:-Infinity,y:-Infinity}; } BBox.prototype.add = function(x,y){ if(isNaN(x)||isNaN(y)) return this; this.min.x=Math.min(this.min.x,x); this.min.y=Math.min(this.min.y,y); this.max.x=Math.max(this.max.x,x); this.max.y=Math.max(this.max.y,y); return this; }; BBox.prototype.w=function(){return this.max.x-this.min.x;}; BBox.prototype.h=function(){return this.max.y-this.min.y;}; const balloonSize = (cell)=> (cell.shape.size ?? 1); const cellScale = (cell)=> balloonSize(cell) * pxUnit; function cellView(cell, id, explicitFill, model){ const shape = cell.shape; const scale = cellScale(cell); const transform = [(shape.base.transform||''), `scale(${scale})`].join(' '); const commonAttrs = { 'vector-effect': 'non-scaling-stroke', stroke: '#111827', 'stroke-width': 2, 'paint-order': 'stroke fill', class: 'balloon', fill: explicitFill || '#cccccc' }; if (cell.isTopper) { commonAttrs['data-is-topper'] = true; } else { commonAttrs['data-color-code'] = cell.colorCode || 0; commonAttrs['data-quad-number'] = cell.y + 1; } let shapeEl; if (shape.base.type === 'path') shapeEl = svg('path', { ...commonAttrs, d: shape.base.d }); else shapeEl = svg('ellipse', { ...commonAttrs, cx:0, cy:0, rx:0.5, ry:0.5 }); const kids = [shapeEl]; const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round')); if (applyShine) { kids.push(svg('ellipse', { class: 'shine', cx: -0.15, cy: -0.15, rx: 0.22, ry: 0.13, fill: '#ffffff', opacity: 0.45, transform: 'rotate(-25)', 'pointer-events': 'none' })); } return svg('g', { id, transform }, kids); } function gridPos(x,y,z,inflate,pattern,model){ const base = patterns[model.patternName].parent || patterns[model.patternName]; const rel = (pattern.baseBalloonSize && base.baseBalloonSize) ? pattern.baseBalloonSize/base.baseBalloonSize : 1; let p = { x: pattern.gridX(model.pattern.cellsPerRow > 1 ? y : x, x), y: pattern.gridY(y,x) }; if (pattern.transform) p = pattern.transform(p,x,y,model); return { x: p.x * rel * pxUnit, y: p.y * rel * pxUnit }; } // === Spiral coloring helpers (shared by 4- and 5-balloon clusters) === function distinctPaletteSlots(palette) { // Collapse visually identical slots so 3-color spirals work even if you filled 5 slots. const seen = new Set(), out = []; for (let s = 1; s <= 5; s++) { const c = palette[s]; if (!c) continue; const key = (c.image || '') + '|' + String(c.colour || '').toLowerCase(); if (!seen.has(key)) { seen.add(key); out.push(s); } } return out.length ? out : [1,2,3,4,5]; } function newGrid(pattern, cells, container, model){ const kids = [], layers = [], bbox = new BBox(); const balloonsPerCluster = pattern.balloonsPerCluster || 4; const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse)); const rowColorPatterns = {}; const stackedSlots = (() => { const slots = distinctPaletteSlots(model.palette); const limit = Math.max(1, Math.min(slots.length, balloonsPerCluster)); return slots.slice(0, limit); })(); const colorBlock4 = [[1, 2, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]]; const colorBlock5 = [ [5, 2, 3, 4, 1], [2, 3, 4, 5, 1], [2, 4, 5, 1, 3], [4, 5, 1, 2, 3], [4, 1, 2, 3, 5], ]; for (let cell of cells) { let c, fill; if (cell.isTopper) { const topRowYIndex = 0, topClusterY = pattern.gridY(topRowYIndex, 0) * pxUnit; const regularBalloonRadius = (pattern.balloonShapes['front'] || pattern.balloonShapes['penta'] || pattern.balloonShapes['middle']).size * pxUnit * 0.5; const highestPoint = topClusterY - regularBalloonRadius; const topperRadius = cell.shape.size * pxUnit * cell.shape.base.radius; const topperY = highestPoint - topperRadius - (pxUnit * 0.5) + topperOffsetY_Px; c = { x: topperOffsetX_Px, y: topperY }; fill = model.topperColor.image ? `url(#classic-pattern-topper)` : model.topperColor.hex; } else { c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model); const rowIndex = cell.y; if (!rowColorPatterns[rowIndex]) { const totalRows = model.rowCount * (pattern.cellsPerRow || 1); const isRightHalf = false; // mirror mode removed const baseRow = rowIndex; const qEff = baseRow + 1; let pat; if (pattern.colorMode === 'stacked') { const slot = stackedSlots[(rowIndex) % stackedSlots.length] || stackedSlots[0] || 1; pat = new Array(balloonsPerCluster).fill(slot); } else if (balloonsPerCluster === 5) { const base = (qEff - 1) % 5; pat = colorBlock5[base].slice(); } else { const base = Math.floor((qEff - 1) / 2); pat = colorBlock4[base % 4].slice(); if (qEff % 2 === 0) { pat = [pat[0], pat[2], pat[1], pat[3]]; } } // Swap left/right emphasis every 5 clusters to break repetition (per template override) if (balloonsPerCluster === 5) { const SWAP_EVERY = 5; const blockIndex = Math.floor(rowIndex / SWAP_EVERY); if (blockIndex % 2 === 1) { [pat[0], pat[4]] = [pat[4], pat[0]]; } } if (pat.length > 1) { let shouldReverse; shouldReverse = reversed; if (shouldReverse) pat.reverse(); } rowColorPatterns[rowIndex] = pat; } const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster]; cell.colorCode = colorCode; const colorInfo = model.palette[colorCode]; fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent'; } const scale = cellScale(cell), shapeRadius = cell.shape.base.radius || 0.5, size = shapeRadius * scale; bbox.add(c.x - size, c.y - size); bbox.add(c.x + size, c.y + size); const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model); v.attrs.transform = `translate(${c.x},${c.y}) ${v.attrs.transform || ''}`; const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0)); (layers[zi] ||= []).push(v); }; layers.forEach(layer => layer && layer.forEach(v => kids.push(v))); const margin = 20; const vb = [ bbox.min.x - margin, bbox.min.y - margin, Math.max(1,bbox.w()) + margin*2, Math.max(1,bbox.h()) + margin*2 ].join(' '); const patternsDefs = []; const SVG_PATTERN_ZOOM = 2.5; const offset = (1 - SVG_PATTERN_ZOOM) / 2; Object.entries(model.palette).forEach(([slot, colorInfo]) => { if (colorInfo.image) { patternsDefs.push(svg('pattern', {id: `classic-pattern-slot-${slot}`, patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, [ svg('image', { href: colorInfo.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] )); } }); if (model.topperColor.image) { patternsDefs.push(svg('pattern', {id: 'classic-pattern-topper', patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, [ svg('image', { href: model.topperColor.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] )); } const svgDefs = svg('defs', {}, patternsDefs); const mainGroup = svg('g', null, kids); m.render(container, svg('svg', { xmlns: 'http://www.w3.org/2000/svg', width:'100%', height:'100%', viewBox: vb, preserveAspectRatio:'xMidYMid meet', style: 'isolation:isolate' }, [svgDefs, mainGroup])); } function makeController(displayEl){ const models = []; function buildModel(name){ const pattern = patterns[name]; if (patterns['Column 4']) patterns['Column 4']._reverse = reverse; if (patterns['Arch 4']) patterns['Arch 4']._reverse = reverse; if (patterns['Column 5']) patterns['Column 5']._reverse = reverse; if (patterns['Arch 5']) patterns['Arch 5']._reverse = reverse; const model = { patternName: name, pattern, cells: [], rowCount: clusters, palette: buildClassicPalette(), topperColor: getTopperColor(), topperType, shineEnabled }; const rows = pattern.cellsPerRow * model.rowCount, cols = pattern.cellsPerColumn; for (let y=0; y [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u]; let v0 = verts[0], v1 = verts[1]; let p0 = lerp(v0, v1, t); let d = `M ${p0[0].toFixed(4)} ${p0[1].toFixed(4)}`; for (let i = 0; i < verts.length; i++) { const v = verts[(i + 1) % verts.length], vNext = verts[(i + 2) % verts.length]; const p = lerp(v, vNext, t); d += ` Q ${v[0].toFixed(4)} ${v[1].toFixed(4)} ${p[0].toFixed(4)} ${p[1].toFixed(4)}`; } return d + ' Z'; } // --- Column 4: This is the existing logic from classic.js, which matches your template file --- patterns['Column 4'] = { baseBalloonSize: 25, _reverse: false, balloonsPerCluster: 4, balloonShapes: { 'front':{zIndex:4, base:{radius:0.5}, size:3}, 'front-inner':{zIndex:3, base:{radius:0.5}, size:3}, 'back-inner':{zIndex:2, base:{radius:0.5}, size:3}, 'back':{zIndex:1, base:{radius:0.5}, size:3}, 'topper-round':{base:{type:'ellipse', radius:0.5}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5}, size:8}, 'topper-heart':{base:{type:'path', d:'M0,0.35 C-0.5,0, -0.14,-0.35, 0,-0.14 C0.14,-0.35, 0.5,0, 0,0.35 Z', radius:0.5}, size:20} }, tile: { size:{x:5,y:1} }, cellsPerRow: 1, cellsPerColumn: 5, gridX(row, col){ return col + [0, -0.12, -0.24, -0.36, -0.48][col % 5]; }, gridY(row, col){ return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2)); }, createCell(x, y) { const odd = !!(y % 2); const A = ['front-inner','back','', 'front','back-inner'], B = ['back-inner', 'front','', 'back', 'front-inner']; const arr = this._reverse ? (odd ? B : A) : (odd ? A : B); const shapeName = arr[x % 5]; const shape = this.balloonShapes[shapeName]; return shape ? { shape:{...shape} } : null; } }; // --- Arch 4: This is the existing logic from classic.js, which matches your template file --- patterns['Arch 4'] = { deriveFrom: 'Column 4', transform(point, col, row, model){ const len = this.gridY(model.rowCount*this.tile.size.y, 0) - this.gridY(0, 0); const r = (len / Math.PI) + point.x; const y = point.y - this.gridY(0, 0); const a = Math.PI * (y / len); return { x: -r*Math.cos(a), y: -r*Math.sin(a) }; } }; // --- Column 5 (template geometry) --- patterns['Column 5'] = { baseBalloonSize: 25, _reverse: false, balloonsPerCluster: 5, tile: { size: { x: 5, y: 1 } }, cellsPerRow: 1, cellsPerColumn: 5, balloonShapes: { "front": { zIndex:5, base:{radius:0.5}, size:3.0 }, "front2": { zIndex:4, base:{radius:0.5}, size:3.0 }, "middle": { zIndex:3, base:{radius:0.5}, size:3.0 }, "middle2": { zIndex:2, base:{radius:0.5}, size:3.0 }, "back": { zIndex:1, base:{radius:0.5}, size:3.0 }, "back2": { zIndex:0, base:{radius:0.5}, size:3.0 }, 'topper-round':{base:{type:'ellipse', radius:0.5}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5}, size:8}, 'topper-heart':{base:{type:'path', d:'M0,0.35 C-0.5,0, -0.14,-0.35, 0,-0.14 C0.14,-0.35, 0.5,0, 0,0.35 Z', radius:0.5}, size:20} }, gridX(row, col) { var mid = 0.6; return (0.9) * (col + (0 === col % 5 && -0.5) + (1 === col % 5 && -mid) + (3 === col % 5 && mid) + (4 === col % 5 && 0.5) - 0.5); }, gridY(row, col){ return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2)); }, createCell(x, y) { var yOdd = !!(y % 2); const shapePattern = yOdd ? ['middle', 'back', 'front', 'back', 'middle'] : ['middle2', 'front2', 'back2', 'front2', 'middle2']; var shapeName = shapePattern[x % 5]; var shape = this.balloonShapes[shapeName]; return shape ? { shape: {...shape} } : null; } }; // Arch 5 derives from Column 5 patterns['Arch 5'] = { deriveFrom: 'Column 5', transform(point, col, row, model){ const len = this.gridY(model.rowCount * this.tile.size.y, 0) - this.gridY(0, 0); const r = (len / Math.PI) + point.x; const y = point.y - this.gridY(0, 0); const a = Math.PI * (y / len); return { x: -r * Math.cos(a), y: -r * Math.sin(a) }; } }; // --- END: MODIFIED SECTION --- // --- Stacked variants (same geometry, single-color clusters alternating rows) --- patterns['Arch 4 Stacked'] = { deriveFrom: 'Arch 4', colorMode: 'stacked' }; patterns['Arch 5 Stacked'] = { deriveFrom: 'Arch 5', colorMode: 'stacked' }; patterns['Column 4 Stacked'] = { deriveFrom: 'Column 4', colorMode: 'stacked' }; patterns['Column 5 Stacked'] = { deriveFrom: 'Column 5', colorMode: 'stacked' }; Object.keys(patterns).forEach(n => extend(patterns[n])); return api; } const patternSlotCount = (name) => ((name || '').includes('5') ? 5 : 4); function getStoredSlotCount() { try { const saved = parseInt(localStorage.getItem(SLOT_COUNT_KEY), 10); if (Number.isFinite(saved) && saved > 0) return Math.min(saved, MAX_SLOTS); } catch {} return 5; } function setStoredSlotCount(n) { const v = Math.max(1, Math.min(MAX_SLOTS, n|0)); try { localStorage.setItem(SLOT_COUNT_KEY, String(v)); } catch {} return v; } function initClassicColorPicker(onColorChange) { const slotsContainer = document.getElementById('classic-slots'), topperSwatch = document.getElementById('classic-topper-color-swatch'), swatchGrid = document.getElementById('classic-swatch-grid'), activeLabel = document.getElementById('classic-active-label'), randomizeBtn = document.getElementById('classic-randomize-colors'), addSlotBtn = document.getElementById('classic-add-slot'); if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) return; topperSwatch.classList.add('tab-btn'); let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount(); function visibleSlotCount() { const patSelect = document.getElementById('classic-pattern'); const name = patSelect?.value || 'Arch 4'; const baseCount = patternSlotCount(name); const isStacked = (name || '').toLowerCase().includes('stacked'); if (!isStacked) return baseCount; const lengthInp = document.getElementById('classic-length-ft'); const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); const maxSlots = Math.min(MAX_SLOTS, clusters); return Math.min(Math.max(baseCount, slotCount), maxSlots); } function renderSlots() { slotsContainer.innerHTML = ''; const count = visibleSlotCount(); for (let i = 1; i <= count; i++) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'slot-btn tab-btn'; btn.dataset.slot = String(i); btn.textContent = `#${i}`; btn.addEventListener('click', () => { activeTarget = String(i); updateUI(); }); slotsContainer.appendChild(btn); } } function enforceSlotVisibility() { const count = visibleSlotCount(); if (parseInt(activeTarget, 10) > count) activeTarget = '1'; renderSlots(); } function updateUI() { enforceSlotVisibility(); const buttons = Array.from(slotsContainer.querySelectorAll('.slot-btn')); [...buttons, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); }); buttons.forEach((slot, i) => { const color = classicColors[i]; if (!color) return; // Safeguard against errors slot.style.backgroundImage = color.image ? `url("${color.image}")` : 'none'; slot.style.backgroundColor = color.hex; slot.style.backgroundSize = '200%'; slot.style.backgroundPosition = 'center'; }); const topperColor = getTopperColor(); topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none'; topperSwatch.style.backgroundColor = topperColor.hex; topperSwatch.style.backgroundSize = '200%'; topperSwatch.style.backgroundPosition = 'center'; const patSelect = document.getElementById('classic-pattern'); const isStacked = (patSelect?.value || '').toLowerCase().includes('stacked'); if (addSlotBtn) { const lengthInp = document.getElementById('classic-length-ft'); const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); const maxSlots = Math.min(MAX_SLOTS, clusters); addSlotBtn.classList.toggle('hidden', !isStacked); addSlotBtn.disabled = !isStacked || slotCount >= maxSlots; } activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`; } const allPaletteColors = flattenPalette(); swatchGrid.innerHTML = ''; (window.PALETTE || []).forEach(group => { const title = document.createElement('div'); title.className = 'family-title'; title.textContent = group.family; swatchGrid.appendChild(title); const row = document.createElement('div'); row.className = 'swatch-row'; (group.colors || []).forEach(colorItem => { const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'swatch'; sw.title = colorItem.name; sw.setAttribute('aria-label', colorItem.name); sw.style.backgroundImage = colorItem.image ? `url("${colorItem.image}")` : 'none'; sw.style.backgroundColor = colorItem.hex; sw.style.backgroundSize = '500%'; sw.style.backgroundPosition = 'center'; sw.addEventListener('click', () => { const selectedColor = { hex: colorItem.hex, image: colorItem.image }; if (activeTarget === 'T') setTopperColor(selectedColor); else { const index = parseInt(activeTarget, 10) - 1; if (index >= 0 && index < MAX_SLOTS) { classicColors[index] = selectedColor; setClassicColors(classicColors); } } updateUI(); onColorChange(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); }); row.appendChild(sw); }); swatchGrid.appendChild(row); }); topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); }); randomizeBtn?.addEventListener('click', () => { const pool = allPaletteColors.slice(); const picks = []; const colorCount = visibleSlotCount(); for (let i = 0; i < colorCount && pool.length; i++) { picks.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); } classicColors = setClassicColors(picks.map(c => ({ hex: c.hex, image: c.image }))); updateUI(); onColorChange(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); }); addSlotBtn?.addEventListener('click', () => { const patSelect = document.getElementById('classic-pattern'); const name = patSelect?.value || ''; const isStacked = name.toLowerCase().includes('stacked'); if (!isStacked) return; const lengthInp = document.getElementById('classic-length-ft'); const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); const maxSlots = Math.min(MAX_SLOTS, clusters); if (slotCount >= maxSlots) return; slotCount = setStoredSlotCount(slotCount + 1); while (classicColors.length < slotCount) { const fallback = allPaletteColors[Math.floor(Math.random() * allPaletteColors.length)] || { hex: '#ffffff', image: null }; classicColors.push({ hex: fallback.hex, image: fallback.image }); } setClassicColors(classicColors); updateUI(); onColorChange(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); }); updateUI(); } function initClassic() { try { if (typeof window.m === 'undefined') return fail('Mithril not loaded'); const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'); const patternShapeBtns = Array.from(document.querySelectorAll('[data-pattern-shape]')); const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]')); const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]')); const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper')); const topperTypeButtons = Array.from(document.querySelectorAll('.topper-type-btn')); const slotsContainer = document.getElementById('classic-slots'); let topperOffsetX = 0, topperOffsetY = 0; let lastPresetKey = null; // 'custom' means user-tweaked; otherwise `${pattern}:${type}` const topperPresets = { 'Column 4:heart': { enabled: true, offsetX: 3, offsetY: -10.5, size: 1.05 }, 'Column 4:star': { enabled: true, offsetX: 3, offsetY: -7.5, size: 1.65 }, 'Column 4:round': { enabled: true, offsetX: 3, offsetY: -2, size: 1.25 }, 'Column 5:heart': { enabled: true, offsetX: 2, offsetY: -10, size: 1.15 }, 'Column 5:star': { enabled: true, offsetX: 2.5, offsetY: -7.5, size: 1.75 }, 'Column 5:round': { enabled: true, offsetX: 2.5, offsetY: -2, size: 1.3 } }; if (!display) return fail('#classic-display not found'); const GC = GridCalculator(), ctrl = GC.controller(display); const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round'; const setTopperType = (type) => { topperTypeButtons.forEach(btn => { const active = btn.dataset.type === type; btn.setAttribute('aria-pressed', String(active)); btn.classList.toggle('tab-active', active); btn.classList.toggle('tab-idle', !active); }); }; function applyTopperPreset(patternName, type) { const key = `${patternName}:${type}`; const preset = topperPresets[key]; if (!preset) return; if (lastPresetKey === key || lastPresetKey === 'custom') return; topperOffsetX = preset.offsetX; topperOffsetY = preset.offsetY; if (topperSizeInp) topperSizeInp.value = preset.size; if (topperEnabledCb) topperEnabledCb.checked = preset.enabled; setTopperType(type); lastPresetKey = key; } let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral'; const computePatternName = () => { const base = patternShape === 'column' ? 'Column' : 'Arch'; const count = patternCount === 5 ? '5' : '4'; const layout = patternLayout === 'stacked' ? ' Stacked' : ''; return `${base} ${count}${layout}`; }; const syncPatternStateFromSelect = () => { const val = (patSel?.value || '').toLowerCase(); patternShape = val.includes('column') ? 'column' : 'arch'; patternCount = val.includes('5') ? 5 : 4; patternLayout = val.includes('stacked') ? 'stacked' : 'spiral'; }; const applyPatternButtons = () => { const setActive = (btns, attr, val) => btns.forEach(b => { const active = b.dataset[attr] === val; b.classList.toggle('tab-active', active); b.classList.toggle('tab-idle', !active); b.setAttribute('aria-pressed', String(active)); }); setActive(patternShapeBtns, 'patternShape', patternShape); setActive(patternCountBtns, 'patternCount', String(patternCount)); setActive(patternLayoutBtns, 'patternLayout', patternLayout); }; syncPatternStateFromSelect(); applyPatternButtons(); function updateClassicDesign() { if (!lengthInp || !patSel) return; patSel.value = computePatternName(); const patternName = patSel.value || 'Arch 4'; const isColumn = patternName.toLowerCase().includes('column'); const hasTopper = patternName.includes('4') || patternName.includes('5'); const showToggle = isColumn && hasTopper; if (patternName.toLowerCase().includes('column')) { const baseName = patternName.includes('5') ? 'Column 5' : 'Column 4'; applyTopperPreset(baseName, getTopperType()); } if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle); const showTopper = showToggle && topperEnabledCb?.checked; topperControls.classList.toggle('hidden', !showTopper); GC.setTopperEnabled(showTopper); GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2)); GC.setReverse(!!reverseCb?.checked); GC.setTopperType(getTopperType()); GC.setTopperOffsetX(topperOffsetX); GC.setTopperOffsetY(topperOffsetY); GC.setTopperSize(topperSizeInp?.value); GC.setShineEnabled(!!shineEnabledCb?.checked); if (document.body) { if (showTopper) document.body.dataset.topperOverlay = '1'; else delete document.body.dataset.topperOverlay; } window.__updateFloatingNudge?.(); if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`; ctrl.selectPattern(patternName); } const setLengthForPattern = () => { if (!lengthInp || !patSel) return; const isArch = (computePatternName()).toLowerCase().includes('arch'); lengthInp.value = isArch ? 20 : 5; }; window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner.api = GC; window.ClassicDesigner.redraw = updateClassicDesign; window.ClassicDesigner.getColors = getClassicColors; window.ClassicDesigner.getTopperColor = getTopperColor; document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50)); patSel?.addEventListener('change', () => { lastPresetKey = null; syncPatternStateFromSelect(); applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }); patternShapeBtns.forEach(btn => btn.addEventListener('click', () => { patternShape = btn.dataset.patternShape; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); })); patternCountBtns.forEach(btn => btn.addEventListener('click', () => { patternCount = Number(btn.dataset.patternCount) === 5 ? 5 : 4; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); })); patternLayoutBtns.forEach(btn => btn.addEventListener('click', () => { patternLayout = btn.dataset.patternLayout === 'stacked' ? 'stacked' : 'spiral'; lastPresetKey = null; applyPatternButtons(); updateClassicDesign(); })); topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => { const dx = Number(btn.dataset.dx || 0); const dy = Number(btn.dataset.dy || 0); topperOffsetX += dx; topperOffsetY += dy; lastPresetKey = 'custom'; GC.setTopperOffsetX(topperOffsetX); GC.setTopperOffsetY(topperOffsetY); updateClassicDesign(); })); topperTypeButtons.forEach(btn => btn.addEventListener('click', () => { setTopperType(btn.dataset.type); lastPresetKey = null; updateClassicDesign(); })); [lengthInp, reverseCb, topperEnabledCb, topperSizeInp] .forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInp || el === topperEnabledCb) lastPresetKey = 'custom'; updateClassicDesign(); }); }); topperEnabledCb?.addEventListener('change', updateClassicDesign); shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); }); initClassicColorPicker(updateClassicDesign); try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {} setLengthForPattern(); updateClassicDesign(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); log('Classic ready'); } catch (e) { fail(e.message || e); } } window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null }; document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } }); })();