(() => { 'use strict'; // -------- helpers ---------- const log = (...a) => console.log('[Classic]', ...a); const debug = (...a) => console.debug('[Classic/manual]', ...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(); const clamp01 = (v) => Math.max(0, Math.min(1, v)); function hexToRgb(hex) { const h = normHex(hex).replace('#', ''); if (h.length === 3) { return { r: parseInt(h[0] + h[0], 16) || 0, g: parseInt(h[1] + h[1], 16) || 0, b: parseInt(h[2] + h[2], 16) || 0 }; } if (h.length === 6) { return { r: parseInt(h.slice(0,2), 16) || 0, g: parseInt(h.slice(2,4), 16) || 0, b: parseInt(h.slice(4,6), 16) || 0 }; } return { r: 0, g: 0, b: 0 }; } function luminance(hex) { const { r, g, b } = hexToRgb(hex); const norm = [r, g, b].map(v => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * norm[0] + 0.7152 * norm[1] + 0.0722 * norm[2]; } let manualModeState = false; let classicZoom = 1; const clampZoom = (z) => Math.min(2.2, Math.max(0.5, z)); let currentPatternName = ''; let currentRowCount = 0; let manualUndoStack = []; let manualRedoStack = []; function classicShineStyle(colorInfo) { const hex = normHex(colorInfo?.hex || colorInfo?.colour || ''); if (hex.startsWith('#')) { const lum = luminance(hex); // For bright hues (yellows, pastels) avoid darkening which can skew green; use a light, soft highlight instead. if (lum > 0.7) { // Slightly stronger highlight for bright hues while staying neutral return { fill: 'rgba(255,255,255,0.4)', opacity: 1, stroke: null }; } // Deep shades keep a stronger white highlight. if (lum < 0.2) { return { fill: 'rgba(255,255,255,0.55)', opacity: 1, stroke: null }; } } return { fill: '#ffffff', opacity: 0.45, stroke: null }; } function textStyleForColor(colorInfo) { if (!colorInfo) return { color: '#0f172a', shadow: 'none' }; if (colorInfo.image) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.55)' }; const hex = normHex(colorInfo.hex); if (hex.startsWith('#')) { const lum = luminance(hex); if (lum < 0.5) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.6)' }; return { color: '#0f172a', shadow: '0 1px 2px rgba(255,255,255,0.7)' }; } return { color: '#0f172a', shadow: 'none' }; } function outlineColorFor(colorInfo) { // Mirrors the topper chip text color choice for consistent contrast. const chipStyle = textStyleForColor(colorInfo || { hex: '#ffffff' }); return chipStyle.color || '#0f172a'; } // -------- persistent color selection (now supports image textures) ---------- const PALETTE_KEY = 'classic:colors:v2'; const TOPPER_COLOR_KEY = 'classic:topperColor:v2'; const CLASSIC_STATE_KEY = 'classic:state:v1'; const MANUAL_MODE_KEY = 'classic:manualMode:v1'; const MANUAL_OVERRIDES_KEY = 'classic:manualOverrides:v1'; const MANUAL_EXPANDED_KEY = 'classic:manualExpanded:v1'; const NUMBER_IMAGE_MAP = { '0': 'output_webp/0.webp', '1': 'output_webp/1.webp', '2': 'output_webp/2.webp', '3': 'output_webp/3.webp', '4': 'output_webp/4.webp', '5': 'output_webp/5.webp', '6': 'output_webp/6.webp', '7': 'output_webp/7.webp', '8': 'output_webp/8.webp', '9': 'output_webp/9.webp' }; 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: '#E32636', image: 'images/chrome-gold.webp' }); // Classic gold const numberSpriteSet = new Set(Object.values(NUMBER_IMAGE_MAP)); const sanitizeTopperColor = (colorObj = {}) => { const base = defaultTopper(); const hex = normHex(colorObj.hex || base.hex); const img = colorObj.image || null; const image = (img && !numberSpriteSet.has(img)) ? img : null; return { hex, image }; }; 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)); if (saved && saved.hex) return sanitizeTopperColor(saved); } catch {} return sanitizeTopperColor(defaultTopper()); } function setTopperColor(colorObj) { const clean = sanitizeTopperColor(colorObj); try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {} } function loadManualMode() { try { const saved = JSON.parse(localStorage.getItem(MANUAL_MODE_KEY)); if (typeof saved === 'boolean') return saved; } catch {} return false; // default to pattern-driven mode; manual is opt-in } function saveManualMode(on) { try { localStorage.setItem(MANUAL_MODE_KEY, JSON.stringify(!!on)); } catch {} } function loadManualExpanded() { try { const saved = JSON.parse(localStorage.getItem(MANUAL_EXPANDED_KEY)); if (typeof saved === 'boolean') return saved; } catch {} return true; } function saveManualExpanded(on) { try { localStorage.setItem(MANUAL_EXPANDED_KEY, JSON.stringify(!!on)); } catch {} } function loadManualOverrides() { try { const saved = JSON.parse(localStorage.getItem(MANUAL_OVERRIDES_KEY)); if (saved && typeof saved === 'object') return saved; } catch {} return {}; } function saveManualOverrides(map) { try { localStorage.setItem(MANUAL_OVERRIDES_KEY, JSON.stringify(map || {})); } catch {} } function manualKey(patternName, rowCount) { return `${patternName || ''}::${rowCount || 0}`; } const manualOverrides = loadManualOverrides(); function manualOverrideCount(patternName, rowCount) { const key = manualKey(patternName, rowCount); const entry = manualOverrides[key]; return entry ? Object.keys(entry).length : 0; } function getManualOverride(patternName, rowCount, x, y) { const key = manualKey(patternName, rowCount); const entry = manualOverrides[key]; if (!entry) return undefined; return entry[`${x},${y}`]; } function setManualOverride(patternName, rowCount, x, y, value) { const key = manualKey(patternName, rowCount); if (!manualOverrides[key]) manualOverrides[key] = {}; manualOverrides[key][`${x},${y}`] = value; saveManualOverrides(manualOverrides); } function clearManualOverride(patternName, rowCount, x, y) { const key = manualKey(patternName, rowCount); const entry = manualOverrides[key]; if (entry) { delete entry[`${x},${y}`]; saveManualOverrides(manualOverrides); } } function manualUsedColorsFor(patternName, rowCount) { const key = manualKey(patternName, rowCount); const overrides = manualOverrides[key] || {}; const palette = buildClassicPalette(); const out = []; const seen = new Set(); Object.values(overrides).forEach(val => { let hex = null, image = null; if (val && typeof val === 'object') { hex = normHex(val.hex || val.colour || ''); image = val.image || null; } else if (typeof val === 'number') { const info = palette[val] || null; hex = normHex(info?.colour || info?.hex || ''); image = info?.image || null; } if (!hex && !image) return; const keyStr = `${image || ''}|${hex || ''}`; if (seen.has(keyStr)) return; seen.add(keyStr); out.push({ hex, image, label: hex || (image ? 'Texture' : 'Color') }); }); return out; } // Manual palette (used in Manual mode project palette) let projectPaletteBox = null; let renderProjectPalette = () => {}; let manualActiveColorGlobal = (window.shared?.getActiveColor?.()) || { hex: '#ffffff', image: null }; function getTopperTypeSafe() { try { return (window.ClassicDesigner?.lastTopperType) || null; } catch { return null; } } function loadClassicState() { try { const saved = JSON.parse(localStorage.getItem(CLASSIC_STATE_KEY)); if (saved && typeof saved === 'object') return saved; } catch {} return null; } function saveClassicState(state) { try { localStorage.setItem(CLASSIC_STATE_KEY, JSON.stringify(state || {})); } 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 => { const key = `${c.image || ''}|${c.hex}`; if (seen.has(key)) return false; seen.add(key); return 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; let borderEnabled = false; let manualMode = loadManualMode(); let explodedScale = 1.18; let explodedGapPx = pxUnit * 1.6; let explodedStaggerPx = pxUnit * 0.6; let manualFocusStart = 0; let manualFocusSize = 8; let manualFocusEnabled = false; let manualFloatingQuad = null; 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; }, setBorderEnabled(on) { borderEnabled = !!on; }, setManualMode(on) { manualMode = !!on; saveManualMode(manualMode); }, setExplodedSettings({ scale, gapPx, staggerPx } = {}) { if (Number.isFinite(scale)) explodedScale = scale; if (Number.isFinite(gapPx)) explodedGapPx = gapPx; if (Number.isFinite(staggerPx)) explodedStaggerPx = staggerPx; }, setManualFocus({ start, size, enabled }) { if (Number.isFinite(start)) manualFocusStart = Math.max(0, start|0); if (Number.isFinite(size)) manualFocusSize = Math.max(1, size|0); if (typeof enabled === 'boolean') manualFocusEnabled = enabled; }, setManualFloatingQuad(val) { manualFloatingQuad = (val === null ? null : (val|0)); } }; 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, colorInfo, opts = {}){ const shape = cell.shape; const base = shape.base || {}; const scale = cellScale(cell); const expandedOn = model.manualMode && (model.explodedGapPx || 0) > 0; // Keep arch geometry consistent when expanded; only scale the alpha (wireframe) ring slightly to improve hit targets. const manualScale = expandedOn && model.patternName?.toLowerCase().includes('arch') ? 1 : (expandedOn ? 1.15 : 1); const transform = [(base.transform||''), `scale(${scale * manualScale})`].join(' '); const isUnpainted = !colorInfo || explicitFill === 'none'; const wireframe = !!opts.wireframe || (model.manualMode && isUnpainted); const wantsOutlineOnly = model.manualMode && isUnpainted; const wireStroke = '#94a3b8'; const commonAttrs = { 'vector-effect': 'non-scaling-stroke', stroke: wireframe ? wireStroke : (borderEnabled ? '#111827' : (wantsOutlineOnly ? wireStroke : 'none')), 'stroke-width': wireframe ? 1.1 : (borderEnabled ? 0.8 : (wantsOutlineOnly ? 1.1 : 0)), 'paint-order': 'stroke fill', class: 'balloon', fill: isUnpainted ? 'none' : (explicitFill || '#cccccc'), 'pointer-events': 'all' }; if (cell.isTopper) { commonAttrs['data-is-topper'] = true; } else { commonAttrs['data-color-code'] = cell.colorCode || 0; commonAttrs['data-quad-number'] = cell.y + 1; } const kids = []; const fillRule = base.fillRule || base['fill-rule'] || null; const isNumTopper = cell.isTopper && (model.topperType || '').startsWith('num-'); if (base.image) { const w = base.width || 1, h = base.height || 1; if (isNumTopper) { const maskId = base.maskId || 'classic-num-mask'; const fillVal = (colorInfo && colorInfo.image) ? 'url(#classic-pattern-topper)' : (colorInfo?.hex || '#ffffff'); const lum = luminance(colorInfo?.hex || colorInfo?.colour || '#ffffff'); const outlineFilterId = lum >= 0.55 ? 'classic-num-outline-dark' : 'classic-num-outline-light'; kids.push(svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none', filter: `url(#${outlineFilterId})`, mask: `url(#${maskId})` })); kids.push(svg('rect', { x: -w/2, y: -h/2, width: w, height: h, fill: fillVal, mask: `url(#${maskId})`, style: 'pointer-events:none' })); } else { kids.push(svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' })); } } else if (Array.isArray(base.paths)) { base.paths.forEach(p => { kids.push(svg('path', { ...commonAttrs, d: p.d, 'fill-rule': p.fillRule || fillRule || 'nonzero' })); }); } else if (base.type === 'path' || base.d) { kids.push(svg('path', { ...commonAttrs, d: base.d, 'fill-rule': fillRule || 'nonzero' })); } else { kids.push(svg('ellipse', { ...commonAttrs, cx:0, cy:0, rx:0.5, ry:0.5 })); } const allowShine = base.allowShine !== false; const applyShine = !wireframe && model.shineEnabled && (!cell.isTopper || (allowShine && !isNumTopper)); if (applyShine) { const shine = classicShineStyle(colorInfo); const shineAttrs = { class: 'shine', cx: -0.15, cy: -0.15, rx: 0.22, ry: 0.13, fill: shine.fill, opacity: shine.opacity, transform: 'rotate(-25)', 'pointer-events': 'none' }; kids.push(svg('ellipse', { ...shineAttrs })); } 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); let xPx = p.x * rel * pxUnit; let yPx = p.y * rel * pxUnit; if (model.manualMode && (model.explodedGapPx || 0) > 0) { const rowIndex = y; const gap = model.explodedGapPx || 0; const isArch = (model.patternName || '').toLowerCase().includes('arch'); if (isArch) { // Move outward along the radial vector and add a tangential nudge for even spread; push ends a bit more. const dist = Math.hypot(xPx, yPx) || 1; const maxRow = Math.max(1, (pattern.cellsPerRow * model.rowCount) - 1); const t = Math.max(0, Math.min(1, y / maxRow)); // 0 first row, 1 last row const radialPush = gap * (1.6 + Math.abs(t - 0.5) * 1.6); // ends > crown const tangentialPush = (t - 0.5) * (gap * 0.8); // small along-arc spread const nx = xPx / dist; const ny = yPx / dist; const tx = -ny; const ty = nx; xPx += nx * radialPush + tx * tangentialPush; yPx += ny * radialPush + ty * tangentialPush; } else { yPx += rowIndex * gap; // columns: separate along the vertical path } } return { x: xPx, y: yPx }; } // === 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(), focusBox = new BBox(), resetDots = new Map(); let floatingAnchor = null; let overrideCount = manualOverrideCount(model.patternName, model.rowCount); const balloonsPerCluster = pattern.balloonsPerCluster || 4; const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse)); const rowColorPatterns = {}; const wireframeMode = false; // per-balloon wireframe handled in cellView for unpainted balloons 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, 4, 3], [4, 1, 3, 2], [3, 4, 2, 1], [2, 3, 1, 4], ]; 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], ]; const isManual = !!model.manualMode; const manualImagePatterns = new Map(); const getManualPatternId = (img) => { if (!img) return null; if (manualImagePatterns.has(img)) return manualImagePatterns.get(img); const id = `classic-manual-${manualImagePatterns.size + 1}`; manualImagePatterns.set(img, id); return id; }; const expandedOn = model.manualMode && (model.explodedGapPx || 0) > 0; for (let cell of cells) { let c, fill, colorInfo; 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; colorInfo = model.topperColor; } else { c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model); if (expandedOn) { const lift = (cell.shape.zIndex || 0) * 6.2; c.y -= lift; } const manualOverride = isManual ? getManualOverride(model.patternName, model.rowCount, cell.x, cell.y) : undefined; const manualSlot = (typeof manualOverride === 'number') ? manualOverride : undefined; 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 patternSlot = rowColorPatterns[rowIndex][cell.balloonIndexInCluster]; const manualColorInfo = (manualOverride && typeof manualOverride === 'object') ? manualOverride : null; const colorCode = (manualSlot !== undefined) ? manualSlot : patternSlot; cell.colorCode = colorCode; colorInfo = manualColorInfo || model.palette[colorCode]; if (manualColorInfo) { const pid = manualColorInfo.image ? getManualPatternId(manualColorInfo.image) : null; fill = pid ? `url(#${pid})` : (manualColorInfo.hex || manualColorInfo.colour || 'transparent'); if (manualColorInfo.hex && !manualColorInfo.colour) { colorInfo = { ...manualColorInfo, colour: manualColorInfo.hex }; } } else if (isManual) { // In manual mode, leave unpainted balloons transparent with an outline only. fill = 'none'; colorInfo = null; } else { fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent'; } } if (wireframeMode) { fill = 'none'; colorInfo = colorInfo || {}; } const scale = cellScale(cell), shapeRadius = cell.shape.base.radius || 0.5, size = shapeRadius * scale; const inFocus = model.manualFocusSize && model.manualFocusStart >= 0 ? (cell.y >= model.manualFocusStart && cell.y < model.manualFocusStart + model.manualFocusSize) : false; const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model, colorInfo, { wireframe: wireframeMode }); if (!cell.isTopper) { v.attrs = v.attrs || {}; v.attrs['data-quad-number'] = cell.y + 1; } const depthLift = expandedOn ? ((cell.shape.zIndex || 0) * 1.8) : 0; const floatingOut = model.manualMode && model.manualFloatingQuad === cell.y; if (floatingOut) { if (!resetDots.has(cell.y)) resetDots.set(cell.y, { x: c.x, y: c.y }); const isArch = (model.patternName || '').toLowerCase().includes('arch'); let slideX = 80; let slideY = 0; const idx = typeof cell.balloonIndexInCluster === 'number' ? cell.balloonIndexInCluster : 0; const spread = idx - 1.5; if (isArch) { // Radial slide outward; preserve layout. const dist = Math.hypot(c.x, c.y) || 1; const offset = (model.manualMode && (model.explodedGapPx || 0) > 0) ? 120 : 80; const nx = c.x / dist, ny = c.y / dist; slideX = nx * offset; slideY = ny * offset; // Slight tangent spread (~5px) to separate balloons without reshaping the quad. const txDirX = -ny; const txDirY = nx; const fan = spread * ((model.manualMode && (model.explodedGapPx || 0) > 0) ? 16 : 10); slideX += txDirX * fan; slideY += txDirY * fan; } let tx = c.x + slideX; let ty = c.y + slideY; // Keep shape intact; only fan columns slightly. if (isArch) { // no fan/scale for arches; preserve layout } else { tx += spread * 4; ty += spread * 4; } const fanScale = 1; // Nudge the top pair down slightly in columns so they remain easily clickable. if (!isArch && typeof cell.balloonIndexInCluster === 'number' && cell.balloonIndexInCluster <= 1) { ty += 6; } // CRITICAL: Re-add NaN checks to prevent coordinates from breaking SVG if (!Number.isFinite(tx) || !Number.isFinite(ty)) { console.error('[Classic] Floating Quad: Non-finite coordinates detected. Falling back to original position.', {row: cell.y, cx: c.x, cy: c.y, slideX, slideY, tx, ty}); tx = c.x; ty = c.y; } // Correctly construct transform: Translate first, then Base Transform, then Scale to pixel size. // We use cellScale(cell) directly (1.0x) to match the true wireframe size perfectly, // removing any "pop" or expansion that caused the size mismatch overlay effect. // Reuse the original balloon transform so the floated cluster matches the stacked layout exactly. // Only prepend the slide offset (plus any depth lift) to move it aside. const baseTransform = v.attrs.transform || ''; const yPos = ty + depthLift; const scaleStr = fanScale !== 1 ? ` scale(${fanScale})` : ''; const tiltDeg = isArch ? 0 : 0; // remove tilt for columns const rotationStr = tiltDeg ? ` rotate(${tiltDeg})` : ''; v.attrs.transform = `translate(${tx},${yPos})${rotationStr} ${baseTransform}${scaleStr}`; // Standard z-index for top visibility, removed drop-shadow; no animation to avoid layout flicker. v.attrs.style = `${v.attrs.style || ''}; z-index: 1000;`; // Boost shine visibility on floating balloons to compensate for smaller scale if (Array.isArray(v.children)) { const shineNode = v.children.find(c => c.attrs && c.attrs.class === 'shine'); if (shineNode && shineNode.attrs) { shineNode.attrs.opacity = 0.65; } } // Suppress outlines on the floated preview to avoid the dark halo. v.attrs.stroke = 'none'; v.attrs['stroke-width'] = 0; bbox.add(tx - size, yPos - size); bbox.add(tx + size, yPos + size); if (inFocus) { focusBox.add(tx - size, yPos - size); focusBox.add(tx + size, yPos + size); } } else { const yPos = c.y + depthLift; v.attrs.transform = `translate(${c.x},${yPos}) ${v.attrs.transform || ''}`; v.attrs.style = `${v.attrs.style || ''};`; bbox.add(c.x - size, yPos - size); bbox.add(c.x + size, yPos + size); if (inFocus) { focusBox.add(c.x - size, yPos - size); focusBox.add(c.x + size, yPos + size); } } // Keep stacking order stable even when the quad is floated. const baseZi = cell.isTopper ? 102 : (100 + (cell.shape.zIndex || 0)); const zi = floatingOut ? (1000 + baseZi) : baseZi; (layers[zi] ||= []).push(v); }; layers.forEach(layer => layer && layer.forEach(v => kids.push(v))); // Add reset dots for floated quads (one per floating row) at their original position. if (resetDots.size) { resetDots.forEach(({ x, y }) => { kids.push(svg('g', { transform: `translate(${x},${y})`, style: 'cursor:pointer' , onclick: 'window.ClassicDesigner?.resetFloatingQuad?.()' }, [ svg('circle', { cx: 0, cy: 0, r: 10, fill: 'rgba(37,99,235,0.12)', stroke: '#2563eb', 'stroke-width': 2 }), svg('circle', { cx: 0, cy: 0, r: 3.5, fill: '#2563eb' }) ])); }); } // Keep a modest margin when a quad is floated so the design doesn’t shrink too much. const margin = (model.manualMode && model.manualFloatingQuad !== null) ? 40 : 20; const focusValid = isFinite(focusBox.min.x) && isFinite(focusBox.min.y) && focusBox.w() > 0 && focusBox.h() > 0; const focusOn = model.manualMode && model.manualFocusEnabled && model.manualFocusSize && focusValid; // Keep full arch/column in view while still tracking focus extents for highlighting const box = bbox; const baseW = Math.max(1, box.w()) + margin * 2; const baseH = Math.max(1, box.h()) + margin * 2; let minX = box.min.x - margin; let minY = box.min.y - margin; let vbW = baseW; let vbH = baseH; const isColumnPattern = (model.patternName || '').toLowerCase().includes('column'); const targetClusters = 14; // ≈7ft at 2 clusters/ft // When not in manual, pad short columns to a consistent scale; in manual keep true size so expanded spacing stays in view. if (!model.manualMode && isColumnPattern && model.rowCount < targetClusters) { const scaleFactor = targetClusters / Math.max(1, model.rowCount); vbW = baseW * scaleFactor; vbH = baseH * scaleFactor; const cx = (box.min.x + box.max.x) / 2; const cy = (box.min.y + box.max.y) / 2; minX = cx - vbW / 2; minY = cy - vbH / 2; } if (model.manualMode) { // Keep the full column centered in manual mode; avoid upward bias that was hiding the top. const lift = 0; minY -= lift; } const vb = [ minX, minY, vbW, vbH ].join(' '); const patternsDefs = []; const maskDefs = []; const SVG_PATTERN_ZOOM = 2.5; const offset = (1 - SVG_PATTERN_ZOOM) / 2; const OUTLINE_DARK = '#0f172a'; // matches chip text for light colors const OUTLINE_LIGHT = '#f8fafc'; // matches chip text for dark colors 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' }) ] )); } }); manualImagePatterns.forEach((id, href) => { patternsDefs.push(svg('pattern', {id, patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, [ svg('image', { href, 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' }) ] )); } Object.entries(numberTopperShapes).forEach(([key, shape]) => { const base = shape.base || {}; if (!base.image) return; const w = base.width || 1, h = base.height || 1; const maskId = base.maskId || `classic-num-mask-${key.replace('topper-num-', '')}`; maskDefs.push(svg('mask', { id: maskId, maskUnits: 'userSpaceOnUse', x: -w/2, y: -h/2, width: w, height: h }, [ svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet' }) ])); }); const svgDefs = svg('defs', {}, [ // Tint: use source alpha to clip topper color, then reapply ink. svg('filter', { id: 'classic-num-tint', 'color-interpolation-filters': 'sRGB', x: '-10%', y: '-10%', width: '120%', height: '120%' }, [ svg('feColorMatrix', { in: 'SourceGraphic', type: 'matrix', values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0', result: 'alpha' }), svg('feFlood', { 'flood-color': model.topperColor?.hex || '#ffffff', result: 'tint' }), svg('feComposite', { in: 'tint', in2: 'alpha', operator: 'in', result: 'fill' }), svg('feComposite', { in: 'SourceGraphic', in2: 'alpha', operator: 'in', result: 'ink' }), svg('feMerge', {}, [ svg('feMergeNode', { in: 'fill' }), svg('feMergeNode', { in: 'ink' }) ]) ]), svg('filter', { id: 'classic-num-outline-dark', 'color-interpolation-filters': 'sRGB', x: '-30%', y: '-30%', width: '160%', height: '160%' }, [ // Heavy outer stroke svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 9, result: 'spreadOuter' }), svg('feComposite', { in: 'spreadOuter', in2: 'SourceAlpha', operator: 'out', result: 'strokeOuter' }), svg('feFlood', { 'flood-color': OUTLINE_DARK, 'flood-opacity': 1, result: 'strokeOuterColor' }), svg('feComposite', { in: 'strokeOuterColor', in2: 'strokeOuter', operator: 'in', result: 'coloredStrokeOuter' }), // Inner reinforcement to avoid gaps svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 4.8, result: 'spreadInner' }), svg('feComposite', { in: 'spreadInner', in2: 'SourceAlpha', operator: 'out', result: 'strokeInner' }), svg('feFlood', { 'flood-color': OUTLINE_DARK, 'flood-opacity': 1, result: 'strokeInnerColor' }), svg('feComposite', { in: 'strokeInnerColor', in2: 'strokeInner', operator: 'in', result: 'coloredStrokeInner' }), svg('feMerge', {}, [ svg('feMergeNode', { in: 'coloredStrokeOuter' }), svg('feMergeNode', { in: 'coloredStrokeInner' }), svg('feMergeNode', { in: 'SourceGraphic' }) ]) ]), svg('filter', { id: 'classic-num-outline-light', 'color-interpolation-filters': 'sRGB', x: '-30%', y: '-30%', width: '160%', height: '160%' }, [ // Heavy outer stroke svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 9, result: 'spreadOuter' }), svg('feComposite', { in: 'spreadOuter', in2: 'SourceAlpha', operator: 'out', result: 'strokeOuter' }), svg('feFlood', { 'flood-color': OUTLINE_LIGHT, 'flood-opacity': 1, result: 'strokeOuterColor' }), svg('feComposite', { in: 'strokeOuterColor', in2: 'strokeOuter', operator: 'in', result: 'coloredStrokeOuter' }), // Inner reinforcement to avoid gaps svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 4.8, result: 'spreadInner' }), svg('feComposite', { in: 'spreadInner', in2: 'SourceAlpha', operator: 'out', result: 'strokeInner' }), svg('feFlood', { 'flood-color': OUTLINE_LIGHT, 'flood-opacity': 1, result: 'strokeInnerColor' }), svg('feComposite', { in: 'strokeInnerColor', in2: 'strokeInner', operator: 'in', result: 'coloredStrokeInner' }), svg('feMerge', {}, [ svg('feMergeNode', { in: 'coloredStrokeOuter' }), svg('feMergeNode', { in: 'coloredStrokeInner' }), svg('feMergeNode', { in: 'SourceGraphic' }) ]) ]), ...maskDefs, ...patternsDefs ]); const mainGroup = svg('g', null, kids); const zoomPercent = classicZoom * 100; m.render(container, svg('svg', { xmlns: 'http://www.w3.org/2000/svg', width:'100%', height:'100%', viewBox: vb, preserveAspectRatio:'xMidYMid meet', style: `isolation:isolate; width:${zoomPercent}%; height:${zoomPercent}%; min-width:${zoomPercent}%; min-height:${zoomPercent}%; transform-origin:center center;` }, [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, manualMode, manualFocusEnabled, manualFloatingQuad, explodedScale, explodedGapPx, explodedStaggerPx, manualFocusStart, manualFocusSize }; 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'; } function roundedRectPath(cx, cy, w, h, r = 0.08) { const x0 = cx - w / 2, x1 = cx + w / 2; const y0 = cy - h / 2, y1 = cy + h / 2; const rad = Math.min(r, w / 2, h / 2); return [ `M ${x0 + rad} ${y0}`, `H ${x1 - rad}`, `Q ${x1} ${y0} ${x1} ${y0 + rad}`, `V ${y1 - rad}`, `Q ${x1} ${y1} ${x1 - rad} ${y1}`, `H ${x0 + rad}`, `Q ${x0} ${y1} ${x0} ${y1 - rad}`, `V ${y0 + rad}`, `Q ${x0} ${y0} ${x0 + rad} ${y0}`, 'Z' ].join(' '); } function buildNumberTopperShapes() { // Reuse the exported SVG glyphs for numbers and recolor them via an alpha mask. // ViewBox sizes vary a lot, so we normalize the aspect ratio with a shared base height. const viewBoxes = { '0': { w: 28.874041, h: 38.883382 }, '1': { w: 20.387968, h: 39.577327 }, '2': { w: 27.866452, h: 39.567209 }, '3': { w: 27.528916, h: 39.153201 }, '4': { w: 39.999867, h: 39.999867 }, '5': { w: 39.999867, h: 39.999867 }, '6': { w: 27.952782, h: 39.269062 }, '7': { w: 39.999867, h: 39.999867 }, '8': { w: 39.999867, h: 39.999867 }, '9': { w: 39.999867, h: 39.999867 } }; const shapes = {}; const topperSize = 8.2; // closer to round topper scale const baseHeight = 1.05; Object.entries(NUMBER_IMAGE_MAP).forEach(([num, href]) => { const vb = viewBoxes[num] || { w: 40, h: 40 }; const aspect = vb.w / Math.max(1, vb.h); const height = baseHeight; const width = height * aspect; const radius = Math.max(width, height) / 2; const maskId = `classic-num-mask-${num}`; shapes[`topper-num-${num}`] = { base: { image: href, width, height, preserveAspectRatio: 'xMidYMid meet', maskId, radius, allowShine: false // keep number toppers matte; shine causes halo }, size: topperSize }; }); return shapes; } const numberTopperShapes = buildNumberTopperShapes(); // --- 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, allowShine:true}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5, allowShine:false}, 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, allowShine:false}, size:20}, ...numberTopperShapes }, 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, allowShine:true}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5, allowShine:false}, 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, allowShine:false}, size:20}, ...numberTopperShapes }, 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'), activeChip = document.getElementById('classic-active-chip'), floatingChip = document.getElementById('classic-active-chip-floating'), activeDot = document.getElementById('classic-active-dot'), floatingDot = document.getElementById('classic-active-dot-floating'); const projectBlock = document.getElementById('classic-project-block'); const replaceBlock = document.getElementById('classic-replace-block'); const replaceFromSel = document.getElementById('classic-replace-from'); const replaceToSel = document.getElementById('classic-replace-to'); const replaceBtn = document.getElementById('classic-replace-btn'); const replaceMsg = document.getElementById('classic-replace-msg'); const replaceFromChip = document.getElementById('classic-replace-from-chip'); const replaceToChip = document.getElementById('classic-replace-to-chip'); const replaceCountLabel = document.getElementById('classic-replace-count'); const topperBlock = document.getElementById('classic-topper-color-block'); if (!slotsContainer || !topperSwatch || !swatchGrid) return; topperSwatch.classList.add('tab-btn'); let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount(); const publishActiveTarget = () => { window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner.activeSlot = activeTarget; }; const isManual = () => !!(window.ClassicDesigner?.manualMode); const manualColor = () => manualActiveColorGlobal || (manualActiveColorGlobal = (window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null })); const openManualPicker = () => { if (!isManual()) return false; if (typeof window.openColorPicker !== 'function') return false; const all = flattenPalette().map(c => ({ label: c.name || c.hex, hex: c.hex, meta: c, metaText: c.family || '' })); window.openColorPicker({ title: 'Pick a color', subtitle: 'Apply to active paint', items: all, onSelect: (item) => { const meta = item.meta || {}; manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: meta.hex || item.hex, image: meta.image || null }); updateUI(); onColorChange(); } }); return true; }; 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(); openPalettePicker(); }); slotsContainer.appendChild(btn); } } function enforceSlotVisibility() { const count = visibleSlotCount(); if (parseInt(activeTarget, 10) > count) activeTarget = '1'; renderSlots(); } const allPaletteColors = flattenPalette(); const labelForColor = (color) => { const hex = normHex(color?.hex || color?.colour || ''); const image = color?.image || ''; const byImage = image ? allPaletteColors.find(c => c.image === image) : null; const byHex = hex ? allPaletteColors.find(c => c.hex === hex) : null; return color?.name || byImage?.name || byHex?.name || hex || (image ? 'Texture' : 'Current'); }; const colorKeyFromVal = (val) => { const palette = buildClassicPalette(); let hex = null, image = null; if (val && typeof val === 'object') { hex = normHex(val.hex || val.colour || ''); image = val.image || null; } else if (typeof val === 'number') { const info = palette[val] || null; hex = normHex(info?.colour || info?.hex || ''); image = info?.image || null; } const cleanedHex = (hex === 'transparent' || hex === 'none') ? '' : (hex || ''); const key = (image || cleanedHex) ? `${image || ''}|${cleanedHex}` : ''; return { hex: cleanedHex, image: image || null, key }; }; const manualUsage = () => { if (!manualModeState) return []; const palette = buildClassicPalette(); const map = new Map(); const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]')); cells.forEach(g => { const match = g.id.match(/balloon_(\d+)_(\d+)/); if (!match) return; const x = parseInt(match[1], 10); const y = parseInt(match[2], 10); const override = getManualOverride(currentPatternName, currentRowCount, x, y); const code = parseInt(g.getAttribute('data-color-code') || '0', 10); const base = palette[code] || { hex: '#ffffff', image: null }; const fill = override || base; const { hex, image, key: k } = colorKeyFromVal(fill); if (!k) return; const existing = map.get(k) || { hex, image, count: 0 }; existing.count += 1; map.set(k, existing); }); return Array.from(map.values()); }; const setReplaceChip = (chip, color) => { if (!chip) return; if (color?.image) { chip.style.backgroundImage = `url("${color.image}")`; chip.style.backgroundSize = 'cover'; chip.style.backgroundColor = color.hex || '#fff'; } else { chip.style.backgroundImage = 'none'; chip.style.backgroundColor = color?.hex || '#f1f5f9'; } }; const populateReplaceTo = () => { if (!replaceToSel) return; replaceToSel.innerHTML = ''; allPaletteColors.forEach((c, idx) => { const opt = document.createElement('option'); opt.value = String(idx); opt.textContent = c.name || c.hex || (c.image ? 'Texture' : 'Color'); replaceToSel.appendChild(opt); }); }; const updateReplaceChips = () => { if (!replaceFromSel || !replaceToSel) return 0; if (!manualModeState) { replaceFromSel.innerHTML = ''; setReplaceChip(replaceFromChip, { hex: '#f8fafc' }); setReplaceChip(replaceToChip, { hex: '#f8fafc' }); if (replaceCountLabel) replaceCountLabel.textContent = ''; if (replaceMsg) replaceMsg.textContent = 'Manual paint only.'; return 0; } const usage = manualUsage(); replaceFromSel.innerHTML = ''; usage.forEach(u => { const opt = document.createElement('option'); opt.value = `${u.image || ''}|${u.hex || ''}`; const labelHex = u.hex || (u.image ? 'Texture' : 'Color'); opt.textContent = `${labelHex} (${u.count})`; replaceFromSel.appendChild(opt); }); if (!replaceFromSel.value && usage.length) replaceFromSel.value = `${usage[0].image || ''}|${usage[0].hex || ''}`; if (!replaceToSel.value && replaceToSel.options.length) replaceToSel.value = replaceToSel.options[0].value; const toIdx = parseInt(replaceToSel.value || '-1', 10); const toMeta = Number.isInteger(toIdx) && toIdx >= 0 ? allPaletteColors[toIdx] : null; const fromVal = replaceFromSel.value || ''; const fromParts = fromVal.split('|'); const fromColor = { image: fromParts[0] || null, hex: fromParts[1] || '' }; setReplaceChip(replaceFromChip, fromColor); setReplaceChip(replaceToChip, toMeta ? { hex: toMeta.hex || '#f1f5f9', image: toMeta.image || null } : { hex: '#f1f5f9' }); // count matches let count = 0; if (fromVal) { const usage = manualUsage(); usage.forEach(u => { if (`${u.image || ''}|${u.hex || ''}` === fromVal) count += u.count; }); } if (replaceCountLabel) replaceCountLabel.textContent = count ? `${count} match${count === 1 ? '' : 'es'}` : '0 matches'; if (replaceMsg) replaceMsg.textContent = usage.length ? '' : 'Paint something first to replace.'; return count; }; const openReplacePicker = (mode = 'from') => { if (!window.openColorPicker) return; if (mode === 'from') { const usage = manualUsage(); const items = usage.map(u => ({ label: u.hex || (u.image ? 'Texture' : 'Color'), metaText: `${u.count} in design`, value: `${u.image || ''}|${u.hex || ''}`, hex: u.hex || '#ffffff', meta: { image: u.image, hex: u.hex || '#ffffff' }, image: u.image })); window.openColorPicker({ title: 'Replace: From color', subtitle: 'Pick a color already on canvas', items, onSelect: (item) => { if (!replaceFromSel) return; replaceFromSel.value = item.value; updateReplaceChips(); } }); } else { const items = allPaletteColors.map((c, idx) => ({ label: c.name || c.hex || (c.image ? 'Texture' : 'Color'), metaText: c.family || '', idx })); window.openColorPicker({ title: 'Replace: To color', subtitle: 'Choose a library color', items, onSelect: (item) => { if (!replaceToSel) return; replaceToSel.value = String(item.idx); updateReplaceChips(); } }); } }; renderProjectPalette = function renderProjectPaletteFn() { if (!projectPaletteBox) return; projectPaletteBox.innerHTML = ''; if (!manualModeState) { projectPaletteBox.innerHTML = '
Enter Manual paint to see colors used.
'; return; } const used = manualUsedColorsFor(currentPatternName, currentRowCount); if (!used.length) { projectPaletteBox.innerHTML = '
Paint to build a project palette.
'; return; } const row = document.createElement('div'); row.className = 'swatch-row'; used.forEach(item => { const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'swatch'; if (item.image) { sw.style.backgroundImage = `url("${item.image}")`; sw.style.backgroundSize = '500%'; sw.style.backgroundPosition = 'center'; sw.style.backgroundColor = item.hex || '#fff'; } else { sw.style.backgroundColor = item.hex || '#fff'; } sw.title = item.label || item.hex || 'Color'; sw.addEventListener('click', () => { manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: item.hex || '#ffffff', image: item.image || null }) || { hex: item.hex || '#ffffff', image: item.image || null }; updateClassicDesign(); }); row.appendChild(sw); }); projectPaletteBox.appendChild(row); } 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(el => el.classList.toggle('slot-active', activeTarget === el.dataset.slot)); publishActiveTarget(); 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 txt = textStyleForColor(color); slot.style.color = txt.color; slot.style.textShadow = txt.shadow; }); const topperColor = getTopperColor(); const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round'; topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none'; topperSwatch.style.backgroundBlendMode = 'normal'; topperSwatch.style.backgroundColor = topperColor.hex; topperSwatch.style.backgroundSize = '200%'; topperSwatch.style.backgroundPosition = 'center'; const topperTxt = textStyleForColor({ hex: topperColor.hex, image: topperColor.image }); topperSwatch.style.color = topperTxt.color; topperSwatch.style.textShadow = topperTxt.shadow; const patName = (document.getElementById('classic-pattern')?.value || '').toLowerCase(); const topperEnabled = document.getElementById('classic-topper-enabled')?.checked; const showTopperColor = patName.includes('column') && (patName.includes('4') || patName.includes('5')) && topperEnabled; if (topperBlock) topperBlock.classList.toggle('hidden', !showTopperColor); 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; } const manualModeOn = isManual(); const sharedActive = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; if (activeChip) { const idx = parseInt(activeTarget, 10) - 1; const color = manualModeOn ? sharedActive : (classicColors[idx] || { hex: '#ffffff', image: null }); activeChip.setAttribute('title', manualModeOn ? 'Active color' : `Slot #${activeTarget}`); const bgImg = color.image ? `url(\"${color.image}\")` : 'none'; const bgCol = color.hex || '#ffffff'; activeChip.style.backgroundImage = bgImg; activeChip.style.backgroundColor = bgCol; activeChip.style.backgroundSize = color.image ? '200%' : 'cover'; activeChip.style.backgroundPosition = 'center'; const txt = textStyleForColor({ hex: color.hex || '#ffffff', image: color.image }); activeChip.style.color = txt.color; activeChip.style.textShadow = txt.shadow; if (activeLabel) { activeLabel.textContent = labelForColor(color); activeLabel.style.color = txt.color; activeLabel.style.textShadow = txt.shadow; } if (activeDot) { activeDot.style.backgroundImage = bgImg; activeDot.style.backgroundColor = bgCol; } if (floatingChip) { floatingChip.style.backgroundImage = bgImg; floatingChip.style.backgroundColor = bgCol; if (floatingDot) { floatingDot.style.backgroundImage = bgImg; floatingDot.style.backgroundColor = bgCol; } floatingChip.style.display = manualModeOn ? '' : 'none'; } } if (slotsContainer) { const row = slotsContainer.parentElement; if (row) row.style.display = manualModeOn ? 'none' : ''; if (addSlotBtn) addSlotBtn.style.display = manualModeOn ? 'none' : ''; } if (activeChip) { activeChip.style.display = manualModeOn ? '' : 'none'; } if (projectBlock) projectBlock.classList.toggle('hidden', !manualModeOn); if (replaceBlock) replaceBlock.classList.toggle('hidden', !manualModeOn); } swatchGrid.innerHTML = ''; swatchGrid.style.display = 'none'; // hide inline list; use modal picker instead const openPalettePicker = () => { if (typeof window.openColorPicker !== 'function') return; const items = allPaletteColors.map(c => ({ label: c.name || c.hex, hex: c.hex, meta: c, metaText: c.family || '' })); const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round'; const subtitle = isManual() ? 'Apply to active paint' : (activeTarget === 'T' ? 'Set topper color' : `Set slot #${activeTarget}`); window.openColorPicker({ title: 'Choose a color', subtitle, items, onSelect: (item) => { const meta = item.meta || {}; const selectedColor = { hex: meta.hex || item.hex, image: meta.image || null }; if (activeTarget === 'T') { setTopperColor(selectedColor); } else if (isManual()) { manualActiveColorGlobal = window.shared?.setActiveColor?.(selectedColor) || 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(); } }); }; topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); openPalettePicker(); }); activeChip?.addEventListener('click', () => { openPalettePicker(); }); floatingChip?.addEventListener('click', () => { openPalettePicker(); }); randomizeBtn?.addEventListener('click', () => { if (isManual() && window.ClassicDesigner?.randomizeManualFromPalette) { const applied = window.ClassicDesigner.randomizeManualFromPalette(); if (applied) return; } 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(); }); replaceFromChip?.addEventListener('click', () => openReplacePicker('from')); replaceToChip?.addEventListener('click', () => openReplacePicker('to')); replaceFromSel?.addEventListener('change', updateReplaceChips); replaceToSel?.addEventListener('change', updateReplaceChips); replaceBtn?.addEventListener('click', () => { if (!manualModeState) { if (replaceMsg) replaceMsg.textContent = 'Manual paint only.'; return; } const fromKey = replaceFromSel?.value || ''; const toIdx = parseInt(replaceToSel?.value || '-1', 10); if (!fromKey || Number.isNaN(toIdx) || toIdx < 0 || toIdx >= allPaletteColors.length) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; } const toMeta = allPaletteColors[toIdx]; const key = manualKey(currentPatternName, currentRowCount); const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null; if (!manualOverrides[key]) manualOverrides[key] = {}; const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]')); const palette = buildClassicPalette(); let count = 0; cells.forEach(g => { const match = g.id.match(/balloon_(\d+)_(\d+)/); if (!match) return; const x = parseInt(match[1], 10); const y = parseInt(match[2], 10); const override = getManualOverride(currentPatternName, currentRowCount, x, y); const code = parseInt(g.getAttribute('data-color-code') || '0', 10); const base = palette[code] || { hex: '#ffffff', image: null }; const fill = override || base; if (colorKeyFromVal(fill).key === fromKey) { manualOverrides[key][`${x},${y}`] = { hex: normHex(toMeta.hex || toMeta.colour || '#ffffff'), image: toMeta.image || null }; count++; } }); if (!count) { if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.'; return; } saveManualOverrides(manualOverrides); manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prevSnapshot }); manualRedoStack.length = 0; if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`; onColorChange(); updateReplaceChips(); }); populateReplaceTo(); updateUI(); updateReplaceChips(); return () => { updateUI(); updateReplaceChips(); }; } function initClassic() { try { if (typeof window.m === 'undefined') return fail('Mithril not loaded'); projectPaletteBox = null; 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'), borderEnabledCb = document.getElementById('classic-border-enabled'), manualModeBtn = document.getElementById('classic-manual-btn'), expandedToggleRow = document.getElementById('classic-expanded-row'), expandedToggle = document.getElementById('classic-expanded-toggle'), focusRow = document.getElementById('classic-focus-row'), focusPrev = document.getElementById('classic-focus-prev'), focusNext = document.getElementById('classic-focus-next'), focusLabel = document.getElementById('classic-focus-label'), floatingBar = document.getElementById('classic-mobile-bar'), floatingChip = document.getElementById('classic-active-chip-floating'), floatingUndo = document.getElementById('classic-undo-manual'), floatingRedo = document.getElementById('classic-redo-manual'), floatingPick = document.getElementById('classic-pick-manual'), floatingErase = document.getElementById('classic-erase-manual'), floatingClear = document.getElementById('classic-clear-manual'), floatingExport = document.getElementById('classic-export-manual'), quadReset = document.getElementById('classic-quad-reset'), focusZoomOut = document.getElementById('classic-focus-zoomout'), manualHub = document.getElementById('classic-manual-hub'), manualRange = document.getElementById('classic-manual-range'), manualRangeLabel = document.getElementById('classic-manual-range-label'), manualPrevBtn = document.getElementById('classic-manual-prev'), manualNextBtn = document.getElementById('classic-manual-next'), manualFullBtn = document.getElementById('classic-manual-full'), manualFocusBtn = document.getElementById('classic-manual-focus'), manualDetailDisplay = document.getElementById('classic-manual-detail-display'); const nudgeOpenBtn = document.getElementById('classic-nudge-open'); const fullscreenBtn = document.getElementById('app-fullscreen-toggle'); const toolbar = document.getElementById('classic-canvas-toolbar'); const toolbarPrev = document.getElementById('classic-toolbar-prev'); const toolbarNext = document.getElementById('classic-toolbar-next'); const toolbarZoomOut = document.getElementById('classic-toolbar-zoomout'); const toolbarReset = document.getElementById('classic-toolbar-reset'); const focusLabelCanvas = document.getElementById('classic-focus-label-canvas'); const reverseLabel = reverseCb?.closest('label'); const reverseHint = reverseLabel?.parentElement?.querySelector('.hint'); const quadModal = document.getElementById('classic-quad-modal'); const quadModalClose = document.getElementById('classic-quad-modal-close'); const quadModalDisplay = document.getElementById('classic-quad-modal-display'); 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 topperSizeInput = document.getElementById('classic-topper-size'); const slotsContainer = document.getElementById('classic-slots'); projectPaletteBox = document.getElementById('classic-project-palette'); const manualPaletteBtn = document.getElementById('classic-manual-palette'); let topperOffsetX = 0, topperOffsetY = 0; let lastPresetKey = null; // 'custom' means user-tweaked; otherwise `${pattern}:${type}` window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner.lastTopperType = window.ClassicDesigner.lastTopperType || 'round'; window.ClassicDesigner.resetFloatingQuad = () => { manualFloatingQuad = null; updateClassicDesign(); }; let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral', lastNonManualLayout = 'spiral'; manualModeState = loadManualMode(); let manualExpandedState = loadManualExpanded(); let manualFocusEnabled = false; // start with full design visible; focus toggles when user targets a cluster manualActiveColorGlobal = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; currentPatternName = ''; currentRowCount = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); let manualFocusStart = 0; const manualFocusSize = 8; manualUndoStack = []; manualRedoStack = []; let manualTool = 'paint'; // paint | pick | erase let manualFloatingQuad = null; let quadModalRow = null; let quadModalStartRect = null; let manualDetailRow = 0; let manualDetailFrame = null; classicZoom = 1; window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner.randomizeManualFromPalette = () => { if (!manualModeState) return false; const used = manualUsedColorsFor(currentPatternName, currentRowCount); const source = (used.length ? used : flattenPalette().map(c => ({ hex: c.hex, image: c.image || null }))).filter(Boolean); if (!source.length) return false; const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]')); if (!cells.length) return false; const key = manualKey(currentPatternName, currentRowCount); const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null; manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prevSnapshot }); manualRedoStack.length = 0; manualOverrides[key] = {}; cells.forEach(g => { const match = g.id.match(/balloon_(\d+)_(\d+)/); if (!match) return; const pick = source[Math.floor(Math.random() * source.length)] || { hex: '#ffffff', image: null }; manualOverrides[key][`${parseInt(match[1], 10)},${parseInt(match[2], 10)}`] = { hex: normHex(pick.hex || pick.colour || '#ffffff'), image: pick.image || null }; }); saveManualOverrides(manualOverrides); updateClassicDesign(); scheduleManualDetail(); return true; }; // Force UI to reflect initial manual state if (manualModeState) patternLayout = 'manual'; 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 4:number': { enabled: true, offsetX: 3, offsetY: -7, size: 1.05 }, '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 }, 'Column 5:number': { enabled: true, offsetX: 2.5, offsetY: -6.5, size: 1.05 } }; if (!display) return fail('#classic-display not found'); const GC = GridCalculator(), ctrl = GC.controller(display); if (topperSizeInput) { const val = Math.max(0.5, Math.min(2, parseFloat(topperSizeInput.value) || 1)); GC.setTopperSize(val); topperSizeInput.addEventListener('input', () => { const next = Math.max(0.5, Math.min(2, parseFloat(topperSizeInput.value) || 1)); GC.setTopperSize(next); updateClassicDesign(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); }); } let refreshClassicPaletteUi = null; 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); }); window.ClassicDesigner.lastTopperType = type; }; function ensureNumberTopperImage(type) { if (!type || !type.startsWith('num-')) return; const cur = getTopperColor(); if (cur?.image && numberSpriteSet.has(cur.image)) { setTopperColor({ hex: cur?.hex || '#ffffff', image: null }); refreshClassicPaletteUi?.(); } } function resetNonNumberTopperColor(type) { if (type && type.startsWith('num-')) return; const fallback = getTopperColor(); if (fallback?.image && numberSpriteSet.has(fallback.image)) { setTopperColor({ hex: fallback.hex || '#ffffff', image: null }); refreshClassicPaletteUi?.(); } } function applyTopperPreset(patternName, type) { const presetType = (type || '').startsWith('num-') ? 'number' : type; const key = `${patternName}:${presetType}`; const preset = topperPresets[key]; if (!preset) return; if (lastPresetKey === key || lastPresetKey === 'custom') return; topperOffsetX = preset.offsetX; topperOffsetY = preset.offsetY; if (topperSizeInput) topperSizeInput.value = preset.size; if (topperEnabledCb) topperEnabledCb.checked = preset.enabled; setTopperType(type); ensureNumberTopperImage(type); resetNonNumberTopperColor(type); lastPresetKey = key; } 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 displayLayout = manualModeState ? 'manual' : patternLayout; 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', displayLayout); patternLayoutBtns.forEach(b => b.disabled = false); if (manualModeBtn) { const active = manualModeState; manualModeBtn.disabled = false; manualModeBtn.classList.toggle('tab-active', active); manualModeBtn.classList.toggle('tab-idle', !active); manualModeBtn.setAttribute('aria-pressed', String(active)); } if (toolbar) toolbar.classList.toggle('hidden', !manualModeState); if (quadModal) quadModal.classList.toggle('hidden', !(manualModeState && quadModalRow !== null)); if (expandedToggleRow) expandedToggleRow.classList.toggle('hidden', !manualModeState); if (expandedToggle) expandedToggle.checked = manualModeState ? manualExpandedState : false; if (focusRow) { const showFocus = manualModeState && (currentPatternName.toLowerCase().includes('arch') || currentPatternName.toLowerCase().includes('column')); focusRow.classList.toggle('hidden', !showFocus); } if (manualHub) manualHub.classList.toggle('hidden', !manualModeState); if (floatingBar) floatingBar.classList.toggle('hidden', !manualModeState); // No expanded toggle in bar (handled by main control) [floatingPick, floatingErase].forEach(btn => { if (!btn) return; const active = manualModeState && manualTool === btn.dataset.tool; btn.setAttribute('aria-pressed', active ? 'true' : 'false'); btn.classList.toggle('active', active); }); }; syncPatternStateFromSelect(); function persistState() { const state = { patternName: patSel?.value || '', length: lengthInp?.value || '', reverse: !!reverseCb?.checked, topperEnabled: !!topperEnabledCb?.checked, topperType: getTopperType(), topperOffsetX, topperOffsetY, topperSize: topperSizeInput?.value || '' }; saveClassicState(state); } function applySavedState() { const saved = loadClassicState(); if (!saved) return; if (patSel && saved.patternName) patSel.value = saved.patternName; if (lengthInp && saved.length) lengthInp.value = saved.length; if (reverseCb) reverseCb.checked = !!saved.reverse; if (topperEnabledCb) topperEnabledCb.checked = !!saved.topperEnabled; if (typeof saved.topperOffsetX === 'number') topperOffsetX = saved.topperOffsetX; if (typeof saved.topperOffsetY === 'number') topperOffsetY = saved.topperOffsetY; if (topperSizeInput && saved.topperSize) topperSizeInput.value = saved.topperSize; if (saved.topperType) setTopperType(saved.topperType); syncPatternStateFromSelect(); lastPresetKey = 'custom'; } applySavedState(); applyPatternButtons(); const clampRow = (row) => { const maxRow = Math.max(1, currentRowCount) - 1; return Math.max(0, Math.min(maxRow, row|0)); }; const clampFocusStart = (start) => { const maxStart = Math.max(0, currentRowCount - manualFocusSize); return Math.max(0, Math.min(maxStart, start|0)); }; function setManualTargetRow(row, { redraw = true } = {}) { if (!manualModeState) return; const targetRow = clampRow(row); manualDetailRow = targetRow; manualFloatingQuad = targetRow; manualFocusEnabled = false; syncManualUi(); if (redraw) updateClassicDesign(); debug('setManualTargetRow', { targetRow, focusStart: manualFocusStart, clusters: currentRowCount }); } function syncManualUi() { if (!manualModeState) return; if (manualRange) { manualRange.max = Math.max(1, currentRowCount); manualRange.value = String(Math.min(currentRowCount, manualDetailRow + 1)); } if (manualRangeLabel) { manualRangeLabel.textContent = `Cluster ${Math.min(currentRowCount, manualDetailRow + 1)} / ${Math.max(currentRowCount, 1)}`; } if (manualFullBtn) manualFullBtn.disabled = !manualModeState; if (manualFocusBtn) manualFocusBtn.disabled = !manualModeState; } const focusSectionForRow = (row) => { manualFocusEnabled = false; manualFloatingQuad = clampRow(row); manualDetailRow = clampRow(row); syncManualUi(); debug('focusSectionForRow', { row, manualFloatingQuad, clusters: currentRowCount }); }; const getQuadNodes = (row) => { const svg = document.querySelector('#classic-display svg'); if (!svg) return []; let nodes = Array.from(svg.querySelectorAll(`g[data-quad-number="${row + 1}"]`)); if (!nodes.length) { const fallback = Array.from(svg.querySelectorAll(`[data-quad-number="${row + 1}"]`)) .map(n => n.closest('g')).filter(Boolean); nodes = Array.from(new Set(fallback)); } if (!nodes.length) { // Fallback: match by id suffix balloon_*_row const byId = Array.from(svg.querySelectorAll('g[id^="balloon_"]')).filter(n => { const m = n.id.match(/_(\d+)$/); return m && parseInt(m[1], 10) === row; }); nodes = Array.from(new Set(byId)); } if (!nodes.length) { // Last resort: any element with the quad data on parent chain const byAny = Array.from(svg.querySelectorAll('[data-quad-number]')) .filter(el => parseInt(el.getAttribute('data-quad-number') || '0', 10) === row + 1) .map(n => n.closest('g')).filter(Boolean); nodes = Array.from(new Set(byAny)); } debug('quad nodes', { row, count: nodes.length }); return nodes; }; const rectUnion = (rects) => { if (!rects.length) return null; const first = rects[0]; const box = { left: first.left, right: first.right, top: first.top, bottom: first.bottom }; rects.slice(1).forEach(r => { box.left = Math.min(box.left, r.left); box.right = Math.max(box.right, r.right); box.top = Math.min(box.top, r.top); box.bottom = Math.max(box.bottom, r.bottom); }); box.width = box.right - box.left; box.height = box.bottom - box.top; return box; }; const bboxUnion = (bboxes) => { if (!bboxes.length) return null; const first = bboxes[0]; const box = { minX: first.x, minY: first.y, maxX: first.x + first.width, maxY: first.y + first.height }; bboxes.slice(1).forEach(b => { box.minX = Math.min(box.minX, b.x); box.minY = Math.min(box.minY, b.y); box.maxX = Math.max(box.maxX, b.x + b.width); box.maxY = Math.max(box.maxY, b.y + b.height); }); return { x: box.minX, y: box.minY, width: box.maxX - box.minX, height: box.maxY - box.minY }; }; function buildQuadModalSvg(row) { if (!quadModalDisplay) return null; const nodes = getQuadNodes(row); if (!nodes.length) return null; const sourceSvg = document.querySelector('#classic-display svg'); const svgNS = 'http://www.w3.org/2000/svg'; // Build a temp svg to measure the cloned quad accurately (transforms included) const tempSvg = document.createElementNS(svgNS, 'svg'); tempSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); tempSvg.style.position = 'absolute'; tempSvg.style.left = '-99999px'; tempSvg.style.top = '-99999px'; const tempDefs = document.createElementNS(svgNS, 'defs'); if (sourceSvg) { const defs = sourceSvg.querySelector('defs'); if (defs) tempDefs.appendChild(defs.cloneNode(true)); } const palette = buildClassicPalette(); Object.entries(palette).forEach(([slot, info]) => { if (!info?.image) return; const pat = document.createElementNS(svgNS, 'pattern'); pat.setAttribute('id', `classic-pattern-slot-${slot}`); pat.setAttribute('patternContentUnits', 'objectBoundingBox'); pat.setAttribute('width', '1'); pat.setAttribute('height', '1'); const img = document.createElementNS(svgNS, 'image'); img.setAttributeNS(null, 'href', info.image); img.setAttribute('x', '-0.75'); img.setAttribute('y', '-0.75'); img.setAttribute('width', '2.5'); img.setAttribute('height', '2.5'); img.setAttribute('preserveAspectRatio', 'xMidYMid slice'); pat.appendChild(img); tempDefs.appendChild(pat); }); tempSvg.appendChild(tempDefs); const tempG = document.createElementNS(svgNS, 'g'); nodes.forEach(node => tempG.appendChild(node.cloneNode(true))); tempG.querySelectorAll('.balloon').forEach(el => { const code = parseInt(el.getAttribute('data-color-code') || '0', 10); const info = palette[code] || null; const fill = info ? (info.image ? `url(#classic-pattern-slot-${code})` : (info.colour || info.hex || '#cccccc')) : '#cccccc'; el.setAttribute('fill', fill); el.setAttribute('stroke', '#111827'); el.setAttribute('stroke-width', '0.6'); el.setAttribute('paint-order', 'stroke fill'); el.setAttribute('pointer-events', 'all'); // keep taps/clicks active in detail view }); tempSvg.appendChild(tempG); document.body.appendChild(tempSvg); let box = null; try { box = tempG.getBBox(); debug('detail bbox (temp measure)', { row, box }); } catch (err) { debug('detail bbox measure failed', { row, err }); } if (!box || box.width < 1 || box.height < 1) { box = { x: 0, y: 0, width: 50, height: 50 }; debug('detail bbox tiny fallback', { row, box }); } const padBase = Math.max(box.width, box.height) * 0.25; const PAD = Math.max(12, padBase); const vbW = Math.max(1, box.width + PAD * 2); const vbH = Math.max(1, box.height + PAD * 2); const svgEl = document.createElementNS(svgNS, 'svg'); svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svgEl.setAttribute('viewBox', `0 0 ${vbW} ${vbH}`); svgEl.setAttribute('width', '100%'); svgEl.setAttribute('height', '100%'); svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); svgEl.style.display = 'block'; svgEl.style.pointerEvents = 'auto'; svgEl.style.pointerEvents = 'auto'; // Reuse the temp defs in the final svg svgEl.appendChild(tempDefs.cloneNode(true)); const finalG = tempG.cloneNode(true); finalG.setAttribute('transform', `translate(${PAD - box.x}, ${PAD - box.y})`); finalG.querySelectorAll('.balloon').forEach(el => { let fillOverride = null; let strokeOverride = null; let strokeWidth = null; if (manualModeState) { const idMatch = (el.id || '').match(/balloon_(\d+)_(\d+)/); if (idMatch) { const bx = parseInt(idMatch[1], 10); const by = parseInt(idMatch[2], 10); const key = manualKey(currentPatternName, currentRowCount); const overrides = manualOverrides[key] || {}; const ov = overrides[`${bx},${by}`]; if (ov && typeof ov === 'object') { // For detail view, ignore textures; use hex/colour only. fillOverride = ov.hex || ov.colour || null; } else if (typeof ov === 'number') { const palette = buildClassicPalette(); const info = palette[ov] || null; if (info) fillOverride = info.colour || info.hex || null; } } } const isEmpty = !fillOverride; if (isEmpty) { el.setAttribute('fill', 'none'); strokeOverride = '#94a3b8'; strokeWidth = '1.2'; } else { el.setAttribute('fill', fillOverride); } if (strokeOverride) el.setAttribute('stroke', strokeOverride); if (strokeWidth) el.setAttribute('stroke-width', strokeWidth); el.setAttribute('paint-order', 'stroke fill'); el.setAttribute('pointer-events', 'all'); }); svgEl.appendChild(finalG); tempSvg.remove(); return svgEl; } function scheduleManualDetail() { /* detail view disabled */ } function openQuadModal(row) { return; } function closeQuadModal() { if (!quadModal || !quadModalDisplay) return; quadModalRow = null; quadModalStartRect = null; quadModalDisplay.style.transition = 'transform 180ms ease, opacity 160ms ease'; quadModalDisplay.style.transform = 'translate(0,-10px) scale(0.92)'; quadModalDisplay.style.opacity = '0'; setTimeout(() => { quadModal.classList.add('hidden'); quadModal.setAttribute('aria-hidden', 'true'); quadModalDisplay.innerHTML = ''; quadModalDisplay.style.transform = ''; quadModalDisplay.style.opacity = ''; quadModalDisplay.style.transition = ''; }, 200); } function updateClassicDesign() { if (!lengthInp || !patSel) return; patSel.value = computePatternName(); const patternName = patSel.value || 'Arch 4'; currentPatternName = patternName; const clusterCount = Math.max(1, Math.round((parseFloat(lengthInp.value) || 0) * 2)); currentRowCount = clusterCount; const manualOn = manualModeState; if (!manualOn) { manualFocusEnabled = false; manualFloatingQuad = null; closeQuadModal(); } else { manualDetailRow = clampRow(manualDetailRow); // Preserve a cleared floating state; only set if not explicitly reset. if (manualFloatingQuad !== null) manualFloatingQuad = clampRow(manualDetailRow); manualFocusEnabled = false; } manualFocusStart = 0; window.ClassicDesigner.manualMode = manualOn; 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; const isNumberTopper = getTopperType().startsWith('num-'); topperControls.classList.toggle('hidden', !showTopper); // Number tint controls removed; always use base SVG appearance for numbers. if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper); const showReverse = patternLayout === 'spiral' && !manualOn; if (reverseLabel) reverseLabel.classList.toggle('hidden', !showReverse); if (reverseHint) reverseHint.classList.toggle('hidden', !showReverse); if (reverseCb) { reverseCb.disabled = manualOn || !showReverse; if (!showReverse) reverseCb.checked = false; } GC.setTopperEnabled(showTopper); GC.setClusters(clusterCount); GC.setManualMode(manualOn); GC.setReverse(!!reverseCb?.checked); GC.setTopperType(getTopperType()); GC.setTopperOffsetX(topperOffsetX); GC.setTopperOffsetY(topperOffsetY); GC.setTopperSize(topperSizeInput?.value); GC.setShineEnabled(!!shineEnabledCb?.checked); GC.setBorderEnabled(!!borderEnabledCb?.checked); const expandedOn = manualOn && manualExpandedState; GC.setExplodedSettings({ scale: expandedOn ? 1.18 : 1, gapPx: expandedOn ? 90 : 0, staggerPx: expandedOn ? 6 : 0 }); if (display) { display.classList.toggle('classic-expanded-canvas', expandedOn); } const totalClusters = clusterCount; manualFocusStart = 0; GC.setManualFocus({ start: manualFocusStart, size: manualFocusSize, enabled: false }); GC.setManualFloatingQuad(manualFloatingQuad); if (quadReset) { quadReset.disabled = manualFloatingQuad === null; } if (toolbarReset) { toolbarReset.disabled = manualFloatingQuad === null; } if (focusLabel) { if (!manualFocusEnabled) { focusLabel.textContent = 'Full design view'; } else { const end = Math.min(totalClusters, manualFocusStart + manualFocusSize); focusLabel.textContent = `Clusters ${manualFocusStart + 1}–${end} of ${totalClusters}`; } } if (focusLabelCanvas) { if (!manualFocusEnabled) { focusLabelCanvas.textContent = 'Full design view'; } else { const end = Math.min(totalClusters, manualFocusStart + manualFocusSize); focusLabelCanvas.textContent = `Clusters ${manualFocusStart + 1}–${end} of ${totalClusters}`; } } if (document.body) { if (showTopper) document.body.dataset.topperOverlay = '1'; else delete document.body.dataset.topperOverlay; } window.__updateFloatingNudge?.(); if(clusterHint) clusterHint.textContent = `≈ ${clusterCount} clusters (rule: 2 clusters/ft)`; refreshClassicPaletteUi?.(); ctrl.selectPattern(patternName); syncManualUi(); renderProjectPalette(); scheduleManualDetail(); persistState(); } function handleManualPaint(evt) { if (!manualModeState) return; const target = evt.target; if (!target) return; if (target.closest && target.closest('[data-is-topper]')) return; const g = target.closest('g[id^="balloon_"]') || (target.id && target); const id = g?.id || ''; const match = id.match(/balloon_(\d+)_(\d+)/); if (!match) { debug('manual paint click ignored (no match)', { id }); return; } const x = parseInt(match[1], 10); const y = parseInt(match[2], 10); if (manualFloatingQuad !== y) { setManualTargetRow(y); return; } debug('manual paint click', { x, y, manualTool, mode: manualModeState, currentPatternName, currentRowCount }); const prev = getManualOverride(currentPatternName, currentRowCount, x, y); const palette = buildClassicPalette(); const colorCode = parseInt(g?.getAttribute('data-color-code') || '0', 10); const currentFill = prev || palette[colorCode] || { hex: '#ffffff', image: null }; if (manualTool === 'pick') { const picked = { hex: normHex(currentFill.hex || currentFill.colour || '#ffffff'), image: currentFill.image || null }; manualActiveColorGlobal = window.shared?.setActiveColor?.(picked) || picked; manualRedoStack.length = 0; manualTool = 'paint'; updateClassicDesign(); return; } manualRedoStack.length = 0; if (manualTool === 'erase') { const ERASE_COLOR = { hex: 'transparent', colour: 'transparent', image: null }; manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: ERASE_COLOR }); setManualOverride(currentPatternName, currentRowCount, x, y, ERASE_COLOR); manualRedoStack.length = 0; updateClassicDesign(); scheduleManualDetail(); return; } const colorToUse = manualActiveColorGlobal || window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; const hexSource = colorToUse.hex || colorToUse.colour || '#ffffff'; const next = { hex: normHex(hexSource), image: colorToUse.image || null }; // If clicking a filled balloon with the same active fill, toggle it off (supports images and solids). const isSameHex = normHex(currentFill.hex || currentFill.colour) === next.hex; const sameImage = currentFill.image && next.image && currentFill.image === next.image; const isSameFill = (sameImage || (!currentFill.image && !next.image && isSameHex)); const finalNext = isSameFill ? { hex: 'transparent', colour: 'transparent', image: null } : next; manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: finalNext }); setManualOverride(currentPatternName, currentRowCount, x, y, finalNext); updateClassicDesign(); scheduleManualDetail(); } function undoLastManual() { const last = manualUndoStack.pop(); if (!last) return; if (last.clear) { const key = manualKey(last.pattern, last.rows); delete manualOverrides[key]; if (last.snapshot) manualOverrides[key] = { ...last.snapshot }; } else if (last.pattern !== currentPatternName || last.rows !== currentRowCount) { setManualOverride(last.pattern, last.rows, last.x, last.y, last.prev || undefined); } else { if (last.prev) setManualOverride(last.pattern, last.rows, last.x, last.y, last.prev); else clearManualOverride(last.pattern, last.rows, last.x, last.y); } manualRedoStack.push(last); updateClassicDesign(); } function redoLastManual() { const last = manualRedoStack.pop(); if (!last) return; if (last.clear) { const key = manualKey(last.pattern, last.rows); const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null; manualUndoStack.push({ clear: true, pattern: last.pattern, rows: last.rows, snapshot: prevSnapshot }); delete manualOverrides[key]; } else { const prev = getManualOverride(last.pattern, last.rows, last.x, last.y); manualUndoStack.push({ ...last, prev }); if (last.next) setManualOverride(last.pattern, last.rows, last.x, last.y, last.next); else clearManualOverride(last.pattern, last.rows, last.x, last.y); } updateClassicDesign(); } 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)); display?.addEventListener('click', handleManualPaint); patSel?.addEventListener('change', () => { lastPresetKey = null; syncPatternStateFromSelect(); manualFocusEnabled = false; manualFloatingQuad = null; closeQuadModal(); 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'; lastNonManualLayout = patternLayout; manualModeState = false; saveManualMode(false); manualExpandedState = false; saveManualExpanded(false); manualFocusEnabled = false; manualFocusStart = 0; manualDetailRow = 0; manualFloatingQuad = null; closeQuadModal(); applyPatternButtons(); updateClassicDesign(); })); manualModeBtn?.addEventListener('click', () => { const togglingOn = !manualModeState; if (togglingOn) lastNonManualLayout = patternLayout === 'manual' ? 'spiral' : patternLayout; manualModeState = togglingOn; patternLayout = togglingOn ? 'manual' : lastNonManualLayout; manualFocusStart = 0; manualFocusEnabled = false; // keep full-view; quad pull-out handles focus manualDetailRow = 0; manualFloatingQuad = null; closeQuadModal(); saveManualMode(togglingOn); applyPatternButtons(); updateClassicDesign(); if (togglingOn) scheduleManualDetail(); debug('manual mode toggle', { on: togglingOn, clusters: currentRowCount }); }); const shiftFocus = (dir) => { const next = clampRow(manualDetailRow + dir); setManualTargetRow(next); }; focusPrev?.addEventListener('click', () => shiftFocus(-1)); focusNext?.addEventListener('click', () => shiftFocus(1)); focusZoomOut?.addEventListener('click', () => { manualFocusStart = 0; manualFloatingQuad = null; manualFocusEnabled = false; updateClassicDesign(); }); toolbarPrev?.addEventListener('click', () => shiftFocus(-1)); toolbarNext?.addEventListener('click', () => shiftFocus(1)); toolbarZoomOut?.addEventListener('click', () => { manualFocusStart = 0; manualFloatingQuad = null; manualFocusEnabled = false; updateClassicDesign(); }); toolbarReset?.addEventListener('click', () => { manualFloatingQuad = null; updateClassicDesign(); }); quadReset?.addEventListener('click', () => { manualFloatingQuad = null; updateClassicDesign(); }); quadModalClose?.addEventListener('click', closeQuadModal); quadModal?.addEventListener('click', (e) => { if (e.target === quadModal || (e.target && e.target.classList && e.target.classList.contains('quad-modal-backdrop'))) { closeQuadModal(); } }); quadModalDisplay?.addEventListener('click', handleManualPaint); manualDetailDisplay?.addEventListener('click', handleManualPaint); manualRange?.addEventListener('input', () => setManualTargetRow(Number(manualRange.value) - 1)); manualPrevBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow - 1)); manualNextBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow + 1)); manualFullBtn?.addEventListener('click', () => { manualFocusEnabled = true; // keep focus so detail/rail stay active manualFloatingQuad = null; updateClassicDesign(); scheduleManualDetail(); debug('manual full view'); }); manualFocusBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow)); manualPaletteBtn?.addEventListener('click', () => { if (!window.openColorPicker) return; const items = flattenPalette().map(c => ({ label: c.name || c.hex, hex: c.hex, meta: c, metaText: c.family || '' })); window.openColorPicker({ title: 'Manual paint color', subtitle: 'Applies to manual paint tool', items, onSelect: (item) => { const meta = item.meta || {}; manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: meta.hex || item.hex, image: meta.image || null }) || { hex: meta.hex || item.hex, image: meta.image || null }; updateClassicDesign(); } }); }); // Keep detail view in sync after initial render when manual mode is pre-enabled if (manualModeState) { scheduleManualDetail(); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeQuadModal(); }); expandedToggle?.addEventListener('change', () => { manualExpandedState = !!expandedToggle.checked; saveManualExpanded(manualExpandedState); updateClassicDesign(); }); floatingUndo?.addEventListener('click', undoLastManual); floatingRedo?.addEventListener('click', redoLastManual); floatingPick?.addEventListener('click', () => { manualTool = 'pick'; applyPatternButtons(); }); floatingErase?.addEventListener('click', () => { manualTool = 'erase'; applyPatternButtons(); }); floatingClear?.addEventListener('click', () => { const key = manualKey(currentPatternName, currentRowCount); const prev = manualOverrides[key] ? { ...manualOverrides[key] } : null; manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prev }); delete manualOverrides[key]; manualRedoStack.length = 0; updateClassicDesign(); }); floatingExport?.addEventListener('click', () => { document.querySelector('[data-export="png"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); floatingUndo?.addEventListener('click', undoLastManual); // Reset floated quad by clicking empty canvas area. display?.addEventListener('click', (e) => { if (!manualModeState) return; const hit = e.target?.closest?.('g[id^="balloon_"], [data-quad-number]'); if (hit) return; if (manualFloatingQuad !== null) { manualFloatingQuad = null; updateClassicDesign(); } }); // Zoom: wheel and pinch on the display const handleZoom = (factor) => { classicZoom = clampZoom(classicZoom * factor); updateClassicDesign(); }; display?.addEventListener('wheel', (e) => { const dy = e.deltaY || 0; if (dy === 0) return; const factor = dy > 0 ? 0.9 : 1.1; handleZoom(factor); e.preventDefault(); }, { passive: false }); let pinchBase = null; const pinchDistance = (touches) => { if (!touches || touches.length < 2) return 0; const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.hypot(dx, dy); }; display?.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { pinchBase = pinchDistance(e.touches); e.preventDefault(); } }, { passive: false }); display?.addEventListener('touchmove', (e) => { if (pinchBase && e.touches.length === 2) { const d = pinchDistance(e.touches); if (d > 0) { const factor = d / pinchBase; handleZoom(factor); pinchBase = d; } e.preventDefault(); } }, { passive: false }); const resetPinch = () => { pinchBase = null; }; display?.addEventListener('touchend', resetPinch); display?.addEventListener('touchcancel', resetPinch); 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); ensureNumberTopperImage(btn.dataset.type); resetNonNumberTopperColor(btn.dataset.type); if (topperEnabledCb) { topperEnabledCb.checked = true; GC.setTopperEnabled(true); } lastPresetKey = null; updateClassicDesign(); })); nudgeOpenBtn?.addEventListener('click', (e) => { e.preventDefault(); window.__showFloatingNudge?.(); }); const updateFullscreenLabel = () => { if (!fullscreenBtn) return; const active = !!document.fullscreenElement; fullscreenBtn.textContent = active ? 'Exit Fullscreen' : 'Fullscreen'; }; fullscreenBtn?.addEventListener('click', async () => { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen({ navigationUI: 'hide' }); } else { await document.exitFullscreen(); } } catch (err) { console.error('Fullscreen toggle failed', err); } finally { updateFullscreenLabel(); } }); document.addEventListener('fullscreenchange', updateFullscreenLabel); [lengthInp, reverseCb, topperEnabledCb, topperSizeInput] .forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInput || 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); }); borderEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setBorderEnabled(on); try { localStorage.setItem('classic:borderEnabled:v1', JSON.stringify(on)); } catch {} updateClassicDesign(); }); refreshClassicPaletteUi = initClassicColorPicker(updateClassicDesign); try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {} try { const saved = localStorage.getItem('classic:borderEnabled:v1'); if (saved !== null && borderEnabledCb) borderEnabledCb.checked = JSON.parse(saved); else if (borderEnabledCb) borderEnabledCb.checked = true; // default to outline on } catch { if (borderEnabledCb) borderEnabledCb.checked = true; } setLengthForPattern(); updateClassicDesign(); refreshClassicPaletteUi?.(); 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(); } }); // Export helper for tab-level routing (function setupClassicExport() { const { imageUrlToDataUrl, XLINK_NS } = window.shared || {}; if (!imageUrlToDataUrl || !XLINK_NS) return; function getImageHref(el) { return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href'); } function setImageHref(el, val) { el.setAttribute('href', val); el.setAttributeNS(XLINK_NS, 'xlink:href', val); } async function buildClassicSvgPayload() { const svgElement = document.querySelector('#classic-display svg'); if (!svgElement) throw new Error('Classic design not found. Please create a design first.'); const clonedSvg = svgElement.cloneNode(true); let bbox = null; try { const temp = clonedSvg.cloneNode(true); temp.style.position = 'absolute'; temp.style.left = '-99999px'; temp.style.top = '-99999px'; temp.style.width = '0'; temp.style.height = '0'; document.body.appendChild(temp); const target = temp.querySelector('g') || temp; bbox = target.getBBox(); temp.remove(); } catch {} const allImages = Array.from(clonedSvg.querySelectorAll('image')); await Promise.all(allImages.map(async img => { const href = getImageHref(img); if (!href || href.startsWith('data:')) return; const dataUrl = await imageUrlToDataUrl(href); if (dataUrl) setImageHref(img, dataUrl); })); const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number); let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0; let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0; let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000); let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000); if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) { const pad = 10; vbX = bbox.x - pad; vbY = bbox.y - pad; vbW = Math.max(1, bbox.width + pad * 2); vbH = Math.max(1, bbox.height + pad * 2); } clonedSvg.setAttribute('width', vbW); clonedSvg.setAttribute('height', vbH); if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS); clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => { if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827'); if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '1'); if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill'); if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke'); }); const svgString = new XMLSerializer().serializeToString(clonedSvg); return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY }; } window.ClassicExport = { buildClassicSvgPayload }; })(); })();