commit 758bf863eec5b5045c7fbc3b6fd21ddd4eb8e2c1 Author: chris Date: Fri Oct 24 09:32:44 2025 -0400 column 5 and arch 5 complete, reverse broken diff --git a/classic.js b/classic.js new file mode 100644 index 0000000..a400d62 --- /dev/null +++ b/classic.js @@ -0,0 +1,571 @@ +(() => { + 'use strict'; + + // -------- helpers ---------- + const log = (...a) => console.log('[Classic]', ...a); + const fail = (msg) => { + console.error('[Classic ERROR]', msg); + const d = document.getElementById('classic-display'); + if (d) d.innerHTML = `
+ Classic failed: ${String(msg)} +
`; + }; + const normHex = (h) => (String(h || '')).trim().toLowerCase(); + + // -------- persistent color selection (now supports image textures) ---------- + const PALETTE_KEY = 'classic:colors:v2'; + const TOPPER_COLOR_KEY = 'classic:topperColor:v2'; + + const defaultColors = () => [ + { hex: '#d92e3a', image: null }, { hex: '#ffffff', image: null }, + { hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null }, + { hex: '#fcd34d', image: null } + ]; + const defaultTopper = () => ({ hex: '#a18b67', image: 'images/chrome-gold.webp' }); + + function getClassicColors() { + let arr = defaultColors(); + try { + const savedJSON = localStorage.getItem(PALETTE_KEY); + if (!savedJSON) return arr; + const saved = JSON.parse(savedJSON); + if (Array.isArray(saved) && saved.length > 0) { + if (typeof saved[0] === 'string') { + arr = saved.slice(0, 5).map(hex => ({ hex: normHex(hex), image: null })); + } else if (typeof saved[0] === 'object' && saved[0] !== null) { + arr = saved.slice(0, 5); + } + while (arr.length < 5) arr.push({ hex: '#ffffff', image: null }); + } + } catch (e) { console.error('Failed to parse classic colors:', e); } + return arr; + } + + function setClassicColors(arr) { + const clean = (arr || []).slice(0, 5).map(c => ({ + hex: normHex(c.hex), image: c.image || null + })); + while (clean.length < 5) clean.push({ hex: '#ffffff', image: null }); + try { localStorage.setItem(PALETTE_KEY, JSON.stringify(clean)); } catch {} + return clean; + } + + function getTopperColor() { + try { + const saved = JSON.parse(localStorage.getItem(TOPPER_COLOR_KEY)); + return (saved && saved.hex) ? saved : defaultTopper(); + } catch { return defaultTopper(); } + } + + function setTopperColor(colorObj) { + const clean = { hex: normHex(colorObj.hex), image: colorObj.image || null }; + try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {} + } + + function buildClassicPalette() { + const colors = getClassicColors(); + const palette = { 0: { colour: '#FFFFFF', name: 'No Colour', image: null } }; + colors.forEach((c, i) => { + palette[i + 1] = { colour: c.hex, image: c.image }; + }); + return palette; + } + + function flattenPalette() { + const out = []; + if (Array.isArray(window.PALETTE)) { + window.PALETTE.forEach(group => { + (group.colors || []).forEach(c => { + if (!c?.hex) return; + out.push({ + hex: normHex(c.hex), name: c.name || c.hex, + family: group.family || '', image: c.image || null + }); + }); + }); + } + const seen = new Set(); + return out.filter(c => (seen.has(c.hex) ? false : (seen.add(c.hex), true))); + } + + // -------- tiny grid engine (Mithril) ---------- + function GridCalculator() { + if (typeof window.m === 'undefined') throw new Error('Mithril (m) not loaded'); + + let pxUnit = 10; + let clusters = 10; + let reverse = false; + let topperEnabled = false; + let topperType = 'round'; + let topperOffsetX_Px = 0; + let topperOffsetY_Px = 0; + let topperSizeMultiplier = 1; + let shineEnabled = true; + + const patterns = {}; + const api = { + patterns, + initialPattern: 'Arch 4', + controller: (el) => makeController(el), + setClusters(n) { clusters = Math.max(1, (Number(n)|0) || 10); }, + setReverse(on){ reverse = !!on; }, + setTopperEnabled(on) { topperEnabled = !!on; }, + setTopperType(type) { topperType = type || 'round'; }, + setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; }, + setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; }, + setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; }, + setShineEnabled(on) { shineEnabled = !!on; } + }; + + const svg = (tag, attrs, children) => m(tag, attrs, children); + + function extend(p){ + const parentName = p.deriveFrom; if (!parentName) return; + const base = patterns[parentName]; if (!base) return; + if (base.deriveFrom) extend(base); + Object.keys(base).forEach(k => { if (!(k in p)) p[k] = base[k]; }); + p.parent = base; + } + + function BBox(){ this.min={x:Infinity,y:Infinity}; this.max={x:-Infinity,y:-Infinity}; } + BBox.prototype.add = function(x,y){ if(isNaN(x)||isNaN(y)) return this; + this.min.x=Math.min(this.min.x,x); this.min.y=Math.min(this.min.y,y); + this.max.x=Math.max(this.max.x,x); this.max.y=Math.max(this.max.y,y); return this; }; + BBox.prototype.w=function(){return this.max.x-this.min.x;}; + BBox.prototype.h=function(){return this.max.y-this.min.y;}; + + const balloonSize = (cell)=> (cell.shape.size ?? 1); + const cellScale = (cell)=> balloonSize(cell) * pxUnit; + + function cellView(cell, id, explicitFill, model){ + const shape = cell.shape; + const scale = cellScale(cell); + const transform = [(shape.base.transform||''), `scale(${scale})`].join(' '); + const commonAttrs = { + 'vector-effect': 'non-scaling-stroke', stroke: '#111827', + 'stroke-width': 2, 'paint-order': 'stroke fill', class: 'balloon', + fill: explicitFill || '#cccccc' + }; + if (cell.isTopper) { + commonAttrs['data-is-topper'] = true; + } else { + commonAttrs['data-color-code'] = cell.colorCode || 0; + commonAttrs['data-quad-number'] = cell.y + 1; + } + + let shapeEl; + if (shape.base.type === 'path') shapeEl = svg('path', { ...commonAttrs, d: shape.base.d }); + else shapeEl = svg('ellipse', { ...commonAttrs, cx:0, cy:0, rx:0.5, ry:0.5 }); + + const kids = [shapeEl]; + const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round')); + if (applyShine) { + kids.push(svg('ellipse', { + class: 'shine', cx: -0.15, cy: -0.15, rx: 0.22, ry: 0.13, + fill: '#ffffff', opacity: 0.45, transform: 'rotate(-25)', 'pointer-events': 'none' + })); + } + return svg('g', { id, transform }, kids); + } + + function gridPos(x,y,z,inflate,pattern,model){ + const base = patterns[model.patternName].parent || patterns[model.patternName]; + const rel = (pattern.baseBalloonSize && base.baseBalloonSize) ? pattern.baseBalloonSize/base.baseBalloonSize : 1; + let p = { x: pattern.gridX(model.pattern.cellsPerRow > 1 ? y : x, x), y: pattern.gridY(y,x) }; + if (pattern.transform) p = pattern.transform(p,x,y,model); + return { x: p.x * rel * pxUnit, y: p.y * rel * pxUnit }; + } + + // === Spiral coloring helpers (shared by 4- and 5-balloon clusters) === +function distinctPaletteSlots(palette) { + // Collapse visually identical slots so 3-color spirals work even if you filled 5 slots. + const seen = new Set(), out = []; + for (let s = 1; s <= 5; s++) { + const c = palette[s]; + if (!c) continue; + const key = (c.image || '') + '|' + String(c.colour || '').toLowerCase(); + if (!seen.has(key)) { seen.add(key); out.push(s); } + } + return out.length ? out : [1,2,3,4,5]; +} + + + + function newGrid(pattern, cells, container, model){ + const kids = [], layers = [], bbox = new BBox(); + const balloonsPerCluster = pattern.balloonsPerCluster || 4; + const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse)); + const rowColorPatterns = {}; + + const colorBlock4 = [[1, 2, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]]; + const colorBlock5 = + [ + [5, 2, 3, 4, 1], + [2, 3, 4, 5, 1], + [2, 4, 5, 1, 3], + [4, 5, 1, 2, 3], + [4, 1, 2, 3, 5], + ]; + + for (let cell of cells) { + let c, fill; + if (cell.isTopper) { + const topRowYIndex = 0, topClusterY = pattern.gridY(topRowYIndex, 0) * pxUnit; + const regularBalloonRadius = (pattern.balloonShapes['front'] || pattern.balloonShapes['penta'] || pattern.balloonShapes['middle']).size * pxUnit * 0.5; + const highestPoint = topClusterY - regularBalloonRadius; + const topperRadius = cell.shape.size * pxUnit * cell.shape.base.radius; + const topperY = highestPoint - topperRadius - (pxUnit * 0.5) + topperOffsetY_Px; + c = { x: topperOffsetX_Px, y: topperY }; + fill = model.topperColor.image ? `url(#classic-pattern-topper)` : model.topperColor.hex; + } else { + c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model); + + const rowIndex = cell.y; + if (!rowColorPatterns[rowIndex]) { + const qEff = rowIndex + 1; + let pat; + + 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]]; + } + } + + if (reversed && pat.length > 1) { + const first = pat.shift(); + pat.reverse(); + pat.unshift(first); + } + + // --- NEW: swap left/right after every 5 clusters --- +const SWAP_EVERY = 5; // clusters per block +const blockIndex = Math.floor(rowIndex / SWAP_EVERY); + +// swap on blocks #2, #4, #6, ... (i.e., rows 6–10, 16–20, ...) +if (blockIndex % 2 === 1) { + if (balloonsPerCluster === 5) { + // [leftMid, leftBack, front, rightBack, rightMid] + [pat[0], pat[4]] = [pat[4], pat[0]]; + // [pat[1], pat[3]] = [pat[3], pat[1]]; + } +} + rowColorPatterns[rowIndex] = pat; + } + + const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster]; + cell.colorCode = colorCode; + const colorInfo = model.palette[colorCode]; + fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent'; + } + + const scale = cellScale(cell), shapeRadius = cell.shape.base.radius || 0.5, size = shapeRadius * scale; + bbox.add(c.x - size, c.y - size); + bbox.add(c.x + size, c.y + size); + const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model); + v.attrs.transform = `translate(${c.x},${c.y}) ${v.attrs.transform || ''}`; + const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0)); + (layers[zi] ||= []).push(v); + }; + + layers.forEach(layer => layer && layer.forEach(v => kids.push(v))); + const margin = 20; + const vb = [ bbox.min.x - margin, bbox.min.y - margin, Math.max(1,bbox.w()) + margin*2, Math.max(1,bbox.h()) + margin*2 ].join(' '); + + const patternsDefs = []; + const SVG_PATTERN_ZOOM = 2.5; + const offset = (1 - SVG_PATTERN_ZOOM) / 2; + + Object.entries(model.palette).forEach(([slot, colorInfo]) => { + if (colorInfo.image) { + patternsDefs.push(svg('pattern', {id: `classic-pattern-slot-${slot}`, patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, + [ svg('image', { href: colorInfo.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] + )); + } + }); + if (model.topperColor.image) { + patternsDefs.push(svg('pattern', {id: 'classic-pattern-topper', patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, + [ svg('image', { href: model.topperColor.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] + )); + } + const svgDefs = svg('defs', {}, patternsDefs); + + const mainGroup = svg('g', null, kids); + m.render(container, svg('svg', { xmlns: 'http://www.w3.org/2000/svg', width:'100%', height:'100%', viewBox: vb, preserveAspectRatio:'xMidYMid meet', style: 'isolation:isolate' }, [svgDefs, mainGroup])); + } + + function makeController(displayEl){ + const models = []; + function buildModel(name){ + const pattern = patterns[name]; + if (patterns['Column 4']) patterns['Column 4']._reverse = reverse; + if (patterns['Arch 4']) patterns['Arch 4']._reverse = reverse; + if (patterns['Column 5']) patterns['Column 5']._reverse = reverse; + if (patterns['Arch 5']) patterns['Arch 5']._reverse = reverse; + + const model = { patternName: name, pattern, cells: [], rowCount: clusters, palette: buildClassicPalette(), topperColor: getTopperColor(), topperType, shineEnabled }; + const rows = pattern.cellsPerRow * model.rowCount, cols = pattern.cellsPerColumn; + for (let y=0; y [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u]; + let v0 = verts[0], v1 = verts[1]; let p0 = lerp(v0, v1, t); + let d = `M ${p0[0].toFixed(4)} ${p0[1].toFixed(4)}`; + for (let i = 0; i < verts.length; i++) { const v = verts[(i + 1) % verts.length], vNext = verts[(i + 2) % verts.length]; const p = lerp(v, vNext, t); d += ` Q ${v[0].toFixed(4)} ${v[1].toFixed(4)} ${p[0].toFixed(4)} ${p[1].toFixed(4)}`; } + return d + ' Z'; + } + + // --- Column 4: This is the existing logic from classic.js, which matches your template file --- + patterns['Column 4'] = { + baseBalloonSize: 25, _reverse: false, balloonsPerCluster: 4, + balloonShapes: { + 'front':{zIndex:4, base:{radius:0.5}, size:3}, 'front-inner':{zIndex:3, base:{radius:0.5}, size:3}, 'back-inner':{zIndex:2, base:{radius:0.5}, size:3}, 'back':{zIndex:1, base:{radius:0.5}, size:3}, + 'topper-round':{base:{type:'ellipse', radius:0.5}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5}, size:8}, 'topper-heart':{base:{type:'path', d:'M0,0.35 C-0.5,0, -0.14,-0.35, 0,-0.14 C0.14,-0.35, 0.5,0, 0,0.35 Z', radius:0.5}, size:20} + }, + tile: { size:{x:5,y:1} }, cellsPerRow: 1, cellsPerColumn: 5, + gridX(row, col){ return col + [0, -0.12, -0.24, -0.36, -0.48][col % 5]; }, + gridY(row, col){ return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2)); }, + createCell(x, y) { + const odd = !!(y % 2); + const A = ['front-inner','back','', 'front','back-inner'], B = ['back-inner', 'front','', 'back', 'front-inner']; + const arr = this._reverse ? (odd ? B : A) : (odd ? A : B); + const shapeName = arr[x % 5]; + const shape = this.balloonShapes[shapeName]; + return shape ? { shape:{...shape} } : null; + } + }; + + // --- Arch 4: This is the existing logic from classic.js, which matches your template file --- + patterns['Arch 4'] = { + deriveFrom: 'Column 4', + transform(point, col, row, model){ + const len = this.gridY(model.rowCount*this.tile.size.y, 0) - this.gridY(0, 0); + const r = (len / Math.PI) + point.x; + const y = point.y - this.gridY(0, 0); + const a = Math.PI * (y / len); + return { x: -r*Math.cos(a), y: -r*Math.sin(a) }; + } + }; + + // --- START: MODIFIED SECTION --- + // This is the new 'Column 5' definition, adapted from your template file. + patterns['Column 5'] = { + baseBalloonSize: 25, + _reverse: false, + balloonsPerCluster: 5, // Kept this from classic.js to ensure 5-color spiral + tile: { size: { x: 5, y: 1 } }, + cellsPerRow: 1, + cellsPerColumn: 5, + + // Balloon shapes from your template, converted to classic.js format + // (type: "qlink" is approx size: 3.0) + 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 } + }, + + // gridX function from your template + // (I've hard-coded `this.exploded` to false, as it's not in classic.js) + gridX(row, col) { + var mid = 0.6; // this.exploded ? 0.2 : 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 function is inherited from Column 4 via `deriveFrom` in your template. + // So, we use the gridY function from this file's 'Column 4'. + gridY(row, col){ + return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2)); + }, + + // createCell function from your template, adapted for classic.js + createCell(x, y) { + var yOdd = !!(y % 2); + + // Re-created logic from template's createCell + const shapePattern = yOdd ? + ['middle', 'back', 'front', 'back', 'middle'] : + ['middle2', 'front2', 'back2', 'front2', 'middle2']; + + var shapeName = shapePattern[x % 5]; + var shape = this.balloonShapes[shapeName]; + + // Return in classic.js format + return shape ? { shape: {...shape} } : null; + } + }; + + // This is the new 'Arch 5' definition. + // It derives from the new 'Column 5' and uses the same arching logic as 'Arch 4'. + patterns['Arch 5'] = { + deriveFrom: 'Column 5', + transform(point, col, row, model){ + // This transform logic is standard and will work with the new Column 5's gridY + 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 --- + + + Object.keys(patterns).forEach(n => extend(patterns[n])); + return api; + } + + function initClassicColorPicker(onColorChange) { + const slots = Array.from(document.querySelectorAll('#classic-slots .slot-btn')), 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'); + if (!slots.length || !topperSwatch || !swatchGrid || !activeLabel) return; + topperSwatch.classList.add('tab-btn'); + let classicColors = getClassicColors(), activeTarget = '1'; + + function updateUI() { + [...slots, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); }); + + slots.forEach((slot, i) => { + const color = classicColors[i]; + if (!color) return; // Safeguard against errors + slot.style.backgroundImage = color.image ? `url("${color.image}")` : 'none'; + slot.style.backgroundColor = color.hex; + slot.style.backgroundSize = '200%'; + slot.style.backgroundPosition = 'center'; + }); + + const topperColor = getTopperColor(); + topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none'; + topperSwatch.style.backgroundColor = topperColor.hex; + topperSwatch.style.backgroundSize = '200%'; + topperSwatch.style.backgroundPosition = 'center'; + + activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`; + } + + const allPaletteColors = flattenPalette(); swatchGrid.innerHTML = ''; + (window.PALETTE || []).forEach(group => { + const title = document.createElement('div'); title.className = 'family-title'; title.textContent = group.family; swatchGrid.appendChild(title); + const row = document.createElement('div'); row.className = 'swatch-row'; + (group.colors || []).forEach(colorItem => { + const sw = document.createElement('div'); sw.className = 'swatch'; sw.title = colorItem.name; + + sw.style.backgroundImage = colorItem.image ? `url("${colorItem.image}")` : 'none'; + sw.style.backgroundColor = colorItem.hex; + sw.style.backgroundSize = '500%'; + sw.style.backgroundPosition = 'center'; + + sw.addEventListener('click', () => { + const selectedColor = { hex: colorItem.hex, image: colorItem.image }; + if (activeTarget === 'T') setTopperColor(selectedColor); + else { + const index = parseInt(activeTarget, 10) - 1; + if (index >= 0 && index < 5) { classicColors[index] = selectedColor; setClassicColors(classicColors); } + } + updateUI(); onColorChange(); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + }); + row.appendChild(sw); + }); + swatchGrid.appendChild(row); + }); + slots.forEach(slot => { slot.addEventListener('click', () => { activeTarget = slot.dataset.slot; updateUI(); }); }); + topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); }); + randomizeBtn?.addEventListener('click', () => { + const pool = allPaletteColors.slice(); const picks = []; + const colorCount = (patterns[document.getElementById('classic-pattern')?.value] || {}).balloonsPerCluster || 5; + 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(); + }); + updateUI(); + } + + function initClassic() { + try { + if (typeof window.m === 'undefined') return fail('Mithril not loaded'); + const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), rebuildBtn = document.getElementById('classic-rerender'), topperControls = document.getElementById('topper-controls'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperTypeSelect = document.getElementById('classic-topper-type'), topperOffsetX_Inp = document.getElementById('classic-topper-offset-x'), topperOffsetY_Inp = document.getElementById('classic-topper-offset-y'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'); + if (!display) return fail('#classic-display not found'); + if (window.setupAccordionPanel) { window.setupAccordionPanel({ panelId: 'classic-controls-panel', expandBtnId: 'classic-expand-all', collapseBtnId: 'classic-collapse-all', reorderBtnId: 'classic-toggle-reorder', storagePrefix: 'cbd' }); } + const GC = GridCalculator(), ctrl = GC.controller(display); + + function updateClassicDesign() { + if (!lengthInp || !patSel) return; + const patternName = patSel.value || 'Arch 4'; + const isColumn = patternName.toLowerCase().includes('column'); + const hasTopper = patternName.includes('4'); + + topperControls.classList.toggle('hidden', !isColumn || !hasTopper); + topperTypeSelect.disabled = !topperEnabledCb.checked; + + GC.setTopperEnabled(isColumn && hasTopper && topperEnabledCb.checked); + GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2)); + GC.setReverse(!!reverseCb?.checked); + GC.setTopperType(topperTypeSelect.value); + GC.setTopperOffsetX(topperOffsetX_Inp?.value); + GC.setTopperOffsetY(topperOffsetY_Inp?.value); + GC.setTopperSize(topperSizeInp?.value); + GC.setShineEnabled(!!shineEnabledCb?.checked); + if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`; + ctrl.selectPattern(patternName); + } + + window.ClassicDesigner = window.ClassicDesigner || {}; + window.ClassicDesigner.api = GC; + window.ClassicDesigner.redraw = updateClassicDesign; + window.ClassicDesigner.getColors = getClassicColors; + window.ClassicDesigner.getTopperColor = getTopperColor; + + document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50)); + patSel?.addEventListener('change', () => { + const isArch = patSel.value.toLowerCase().includes('arch'); + lengthInp.value = isArch ? 25 : 5; + updateClassicDesign(); + }); + [lengthInp, reverseCb, topperEnabledCb, topperTypeSelect, topperOffsetX_Inp, topperOffsetY_Inp, topperSizeInp, rebuildBtn] + .forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener((el === rebuildBtn) ? 'click' : eventType, updateClassicDesign); }); + shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); }); + initClassicColorPicker(updateClassicDesign); + try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {} + updateClassicDesign(); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + log('Classic ready'); + } catch (e) { fail(e.message || e); } + } + + window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null }; + document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } }); +})(); \ No newline at end of file diff --git a/colors.js b/colors.js new file mode 100644 index 0000000..73a5692 --- /dev/null +++ b/colors.js @@ -0,0 +1,66 @@ +const PALETTE = [ + { family: "Whites & Neutrals", colors: [ + { name:"White",hex:"#ffffff"},{name:"Retro White",hex:"#e8e3d9"},{name:"Sand",hex:"#e1d8c6"}, + { name:"Cameo",hex:"#e9ccc8"},{name:"Grey",hex:"#ced3d4"},{name:"Stone",hex:"#989689"}, + { name:"Fog",hex:"#6b9098"},{name:"Smoke",hex:"#75777b"},{name:"Black",hex:"#0b0d0f"} + ]}, + { family: "Pinks & Reds", colors: [ + {name:"Blush",hex:"#fbd6c0"},{name:"Light Pink",hex:"#fcccda"},{name:"Melon",hex:"#fac4bc"}, + {name:"Rose Pink",hex:"#d984a3"},{name:"Fuchsia",hex:"#eb4799"},{name:"Aloha",hex:"#e45c56"}, + {name:"Red",hex:"#ef2a2f"},{name:"Pastel Magenta",hex:"#B72E6C"},{name:"Coral",hex:"#bd4b3b"}, + {name:"Wild Berry",hex:"#79384c"},{name:"Maroon",hex:"#80011f"} + ]}, + { family: "Oranges & Browns & Yellows", colors: [ + {name:"Pastel Yellow",hex:"#fcfd96"},{name:"Yellow",hex:"#f5e812"},{name:"Goldenrod",hex:"#f7b615"}, + {name:"Orange",hex:"#ef6b24"},{name:"Coffee",hex:"#957461"},{name:"Burnt Orange",hex:"#9d4223"} + ]}, + { family: "Greens", colors: [ + {name:"Eucalyptus",hex:"#a3bba3"},{name:"Pastel Green",hex:"#acdba7"},{name:"Lime Green",hex:"#8fc73e"}, + {name:"Seafoam",hex:"#479a87"},{name:"Grass Green",hex:"#28b35e"},{name:"Empowermint",hex:"#779786"}, + {name:"Forest Green",hex:"#218b21"},{name:"Willow",hex:"#4a715c"} + ]}, + { family: "Blues", colors: [ + {name:"Sky Blue",hex:"#87ceec"},{name:"Sea Glass",hex:"#80a4bc"},{name:"Caribbean Blue",hex:"#0bbbb6"}, + {name:"Medium Blue",hex:"#1b89e8"},{name:"Blue Slate",hex:"#327295"},{name:"Tropical Teal",hex:"#0d868f"}, + {name:"Royal Blue",hex:"#005eb7"},{name:"Dark Blue",hex:"#26408e"},{name:"Navy",hex:"#262266"} + ]}, + { family: "Purples", colors: [ + {name:"Pastel Dusk",hex:"#d7c4c8"},{name:"Lilac",hex:"#c69edb"},{name:"Canyon Rose",hex:"#ca93b3"}, + {name:"Rosewood",hex:"#ad7271"},{name:"Lavender",hex:"#866c92"},{name:"Orchid",hex:"#a42487"}, + {name:"Violet",hex:"#812a8c"} + ]}, + + // === Pearl & Metallic: image-backed swatches === + { family: "Pearl and Matallic Colors", colors: [ + { name:"Pearl White", hex:"#F8F8F8", metallic:true, pearlType:"white", image:"images/pearl-white.webp" }, + { name:"Classic Silver", hex:"#F4C2C2", metallic:true, pearlType:"silver", image:"images/classic-silver.webp" }, + { name:"Pearl Pink", hex:"#F4C2C2", metallic:true, pearlType:"pink", image:"images/pearl-pink.webp" }, + { name:"Pearl Peach", hex:"#F4C2C2", metallic:true, pearlType:"pink", image:"images/pearl-peach.webp" }, + { name:"Classic Rose Gold", hex:"#F4C2C2", metallic:true, pearlType:"pink", image:"images/metalic-rosegold.webp" }, + { name:"Pearl Lilac", hex:"#C8A2C8", metallic:true, pearlType:"lilac", image:"images/pearl-lilac.webp" }, + { name:"Pearl Light Blue", hex:"#87CEEB", metallic:true, pearlType:"blue", image:"images/pearl-lightblue.webp" }, + { name:"Pearl Periwinkle", hex:"#F4C2C2", metallic:true, pearlType:"blue", image:"images/pearl-periwinkle.webp" }, + { name:"Pearl Fuchsia", hex:"#FD49AB", metallic:true, pearlType:"fuchsia", image:"images/pearl-fuchsia.webp" }, + { name:"Pearl Violet", hex:"#8F00FF", metallic:true, pearlType:"violet", image:"images/pearl-violet.webp" }, + { name:"Pearl Sapphire", hex:"#0F52BA", metallic:true, pearlType:"sapphire", image:"images/pearl-sapphire.webp" }, + { name:"Pearl Midnight Blue",hex:"#191970", metallic:true, pearlType:"midnight-blue", image:"images/pearl-midnightblue.webp" }, + { name:"Classic Gold", hex:"#E32636", metallic:true, pearlType:"gold", image:"images/classic-gold.webp" } + ]}, + + // === Chrome: image-backed swatches === + { family: "Chrome Colors", colors: [ + { name:"Chrome Rose Gold", hex:"#FFBF00", metallic:true, chromeType:"rosegold", image:"images/chrome-rosegold.webp" }, + { name:"Chrome Pink", hex:"#FFBF00", metallic:true, chromeType:"rosegold", image:"images/chrome-pink.webp" }, + { name:"Chrome Purple", hex:"#DFFF00", metallic:true, chromeType:"purple", image:"images/chrome-purple.webp" }, + { name:"Chrome Champagne", hex:"#FF1DCE", metallic:true, chromeType:"champagne", image:"images/chrome-champagne.webp" }, + { name:"Chrome Truffle", hex:"#FF1DCE", metallic:true, chromeType:"champagne", image:"images/chrome-truffle.webp" }, + { name:"Chrome Silver", hex:"#a8a9a4", metallic:true, chromeType:"silver", image:"images/chrome-silver.webp" }, + { name:"Chrome Space Grey",hex:"#a8a9a4", metallic:true, chromeType:"spacegrey", image:"images/chrome-spacegrey.webp" }, + { name:"Chrome Gold", hex:"#a18b67", metallic:true, chromeType:"gold", image:"images/chrome-gold.webp" }, + { name:"Chrome Green", hex:"#457066", metallic:true, chromeType:"green", image:"images/chrome-green.webp" }, + { name:"Chrome Blue", hex:"#2d576f", metallic:true, chromeType:"blue", image:"images/chrome-blue.webp" } + ]} + ]; + + window.CLASSIC_COLORS = ['#D92E3A', '#FFFFFF', '#0055A4', '#40E0D0']; + window.PALETTE = window.PALETTE || (typeof PALETTE !== "undefined" ? PALETTE : []); \ No newline at end of file diff --git a/images/1balloon-mask.svg b/images/1balloon-mask.svg new file mode 100644 index 0000000..cc04ded --- /dev/null +++ b/images/1balloon-mask.svg @@ -0,0 +1,71 @@ + + + + diff --git a/images/balloon-mask(1).svg b/images/balloon-mask(1).svg new file mode 100644 index 0000000..ab283b1 --- /dev/null +++ b/images/balloon-mask(1).svg @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/images/balloon-mask.svg b/images/balloon-mask.svg new file mode 100644 index 0000000..ab283b1 --- /dev/null +++ b/images/balloon-mask.svg @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/images/chrome-blue.webp b/images/chrome-blue.webp new file mode 100644 index 0000000..10732e6 Binary files /dev/null and b/images/chrome-blue.webp differ diff --git a/images/chrome-champagne.webp b/images/chrome-champagne.webp new file mode 100644 index 0000000..e6e3608 Binary files /dev/null and b/images/chrome-champagne.webp differ diff --git a/images/chrome-gold.webp b/images/chrome-gold.webp new file mode 100644 index 0000000..27f832a Binary files /dev/null and b/images/chrome-gold.webp differ diff --git a/images/chrome-green.webp b/images/chrome-green.webp new file mode 100644 index 0000000..840945b Binary files /dev/null and b/images/chrome-green.webp differ diff --git a/images/chrome-pink.webp b/images/chrome-pink.webp new file mode 100644 index 0000000..e6ae8e3 Binary files /dev/null and b/images/chrome-pink.webp differ diff --git a/images/chrome-purple.webp b/images/chrome-purple.webp new file mode 100644 index 0000000..8beaa5d Binary files /dev/null and b/images/chrome-purple.webp differ diff --git a/images/chrome-rosegold.webp b/images/chrome-rosegold.webp new file mode 100644 index 0000000..24ad095 Binary files /dev/null and b/images/chrome-rosegold.webp differ diff --git a/images/chrome-silver.webp b/images/chrome-silver.webp new file mode 100644 index 0000000..be0dd4c Binary files /dev/null and b/images/chrome-silver.webp differ diff --git a/images/chrome-spacegrey.webp b/images/chrome-spacegrey.webp new file mode 100644 index 0000000..bd8d388 Binary files /dev/null and b/images/chrome-spacegrey.webp differ diff --git a/images/chrome-truffle.webp b/images/chrome-truffle.webp new file mode 100644 index 0000000..be99a06 Binary files /dev/null and b/images/chrome-truffle.webp differ diff --git a/images/classic-gold.webp b/images/classic-gold.webp new file mode 100644 index 0000000..0ab3e96 Binary files /dev/null and b/images/classic-gold.webp differ diff --git a/images/classic-silver.webp b/images/classic-silver.webp new file mode 100644 index 0000000..ffd60b4 Binary files /dev/null and b/images/classic-silver.webp differ diff --git a/images/metalic-rosegold.webp b/images/metalic-rosegold.webp new file mode 100644 index 0000000..f742faf Binary files /dev/null and b/images/metalic-rosegold.webp differ diff --git a/images/pearl-fuchsia.webp b/images/pearl-fuchsia.webp new file mode 100644 index 0000000..cb0c43a Binary files /dev/null and b/images/pearl-fuchsia.webp differ diff --git a/images/pearl-lightblue.webp b/images/pearl-lightblue.webp new file mode 100644 index 0000000..fad0fc2 Binary files /dev/null and b/images/pearl-lightblue.webp differ diff --git a/images/pearl-lilac.webp b/images/pearl-lilac.webp new file mode 100644 index 0000000..951f03a Binary files /dev/null and b/images/pearl-lilac.webp differ diff --git a/images/pearl-midnightblue.webp b/images/pearl-midnightblue.webp new file mode 100644 index 0000000..2db2bd6 Binary files /dev/null and b/images/pearl-midnightblue.webp differ diff --git a/images/pearl-peach.webp b/images/pearl-peach.webp new file mode 100644 index 0000000..6965d72 Binary files /dev/null and b/images/pearl-peach.webp differ diff --git a/images/pearl-periwinkle.webp b/images/pearl-periwinkle.webp new file mode 100644 index 0000000..6580a77 Binary files /dev/null and b/images/pearl-periwinkle.webp differ diff --git a/images/pearl-pink.webp b/images/pearl-pink.webp new file mode 100644 index 0000000..2286f66 Binary files /dev/null and b/images/pearl-pink.webp differ diff --git a/images/pearl-sapphire.webp b/images/pearl-sapphire.webp new file mode 100644 index 0000000..d92b635 Binary files /dev/null and b/images/pearl-sapphire.webp differ diff --git a/images/pearl-violet.webp b/images/pearl-violet.webp new file mode 100644 index 0000000..51d9a48 Binary files /dev/null and b/images/pearl-violet.webp differ diff --git a/images/pearl-white.webp b/images/pearl-white.webp new file mode 100644 index 0000000..d5d1d61 Binary files /dev/null and b/images/pearl-white.webp differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..63410f0 --- /dev/null +++ b/index.html @@ -0,0 +1,336 @@ + + + + + + Balloon Designer — Organic & Classic + + + + + + + + + + + + +
+ + + +
+ + + +

(PNG for both modes, SVG for Classic mode)

+
+ + +
+ + +
+
+ + +
+ +
+
+ + + + + +
+ + + + + + + + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..ab3ac0f --- /dev/null +++ b/script.js @@ -0,0 +1,1284 @@ +// script.js +(() => { + 'use strict'; + + // ----------------------------- + // Accordion panel (shared) + // ----------------------------- + function setupAccordionPanel(options) { + const { panelId, expandBtnId, collapseBtnId, reorderBtnId, storagePrefix } = options; + + const accPanel = document.getElementById(panelId); + if (!accPanel) return; + + const expandBtn = document.getElementById(expandBtnId); + const collapseBtn = document.getElementById(collapseBtnId); + const reorderBtn = document.getElementById(reorderBtnId); + + const ACC_ORDER_KEY = `${storagePrefix}:accOrder:v1`; + const ACC_OPEN_KEY = `${storagePrefix}:accOpen:v1`; + const SCROLL_KEY = `${storagePrefix}:controlsScroll:v1`; + + const accSections = () => Array.from(accPanel.querySelectorAll('details[data-acc-id]')); + + function saveAccOpen() { + const map = {}; + accSections().forEach(d => (map[d.dataset.accId] = d.open ? 1 : 0)); + try { localStorage.setItem(ACC_OPEN_KEY, JSON.stringify(map)); } catch {} + } + + function restoreAccOpen() { + try { + const map = JSON.parse(localStorage.getItem(ACC_OPEN_KEY) || '{}'); + accSections().forEach(d => { + if (map[d.dataset.accId] === 1) d.open = true; + if (map[d.dataset.accId] === 0) d.open = false; + }); + } catch {} + } + + function saveAccOrder() { + const order = accSections().map(d => d.dataset.accId); + try { localStorage.setItem(ACC_ORDER_KEY, JSON.stringify(order)); } catch {} + } + + function restoreAccOrder() { + try { + const order = JSON.parse(localStorage.getItem(ACC_ORDER_KEY) || '[]'); + if (!Array.isArray(order) || order.length === 0) return; + const map = new Map(accSections().map(d => [d.dataset.accId, d])); + order.forEach(id => { + const el = map.get(id); + if (el) accPanel.appendChild(el); + }); + } catch {} + } + + // --- drag/reorder within the panel + let drag = { el: null, ph: null }; + accPanel.addEventListener('click', e => { if (e.target.closest('.drag-handle')) e.preventDefault(); }); + accPanel.addEventListener('pointerdown', e => { if (e.target.closest('.drag-handle')) e.stopPropagation(); }); + + accPanel.addEventListener('dragstart', e => { + const handle = e.target.closest('.drag-handle'); + if (!handle) return; + drag.el = handle.closest('details[data-acc-id]'); + drag.ph = document.createElement('div'); + drag.ph.className = 'rounded-lg border border-dashed border-gray-300 bg-white/30'; + drag.ph.style.height = drag.el.offsetHeight + 'px'; + drag.el.classList.add('opacity-50'); + drag.el.after(drag.ph); + e.dataTransfer.effectAllowed = 'move'; + }); + + accPanel.addEventListener('dragover', e => { + if (!drag.el) return; + e.preventDefault(); + const y = e.clientY; + let closest = null, dist = Infinity; + for (const it of accSections().filter(x => x !== drag.el)) { + const r = it.getBoundingClientRect(); + const m = r.top + r.height / 2; + const d = Math.abs(y - m); + if (d < dist) { dist = d; closest = it; } + } + if (!closest) return; + const r = closest.getBoundingClientRect(); + if (y < r.top + r.height / 2) accPanel.insertBefore(drag.ph, closest); + else accPanel.insertBefore(drag.ph, closest.nextSibling); + }); + + function cleanupDrag() { + if (!drag.el) return; + drag.el.classList.remove('opacity-50'); + if (drag.ph && drag.ph.parentNode) { + accPanel.insertBefore(drag.el, drag.ph); + drag.ph.remove(); + } + drag.el = drag.ph = null; + saveAccOrder(); + } + + accPanel.addEventListener('drop', e => { if (drag.el) { e.preventDefault(); cleanupDrag(); }}); + accPanel.addEventListener('dragend', () => cleanupDrag()); + accPanel.addEventListener('toggle', e => { if (e.target.matches('details[data-acc-id]')) saveAccOpen(); }, true); + accPanel.addEventListener('scroll', () => { try { localStorage.setItem(SCROLL_KEY, String(accPanel.scrollTop)); } catch {} }); + + function restorePanelScroll() { accPanel.scrollTop = Number(localStorage.getItem(SCROLL_KEY)) || 0; } + + // Toolbar + expandBtn?.addEventListener('click', () => { accSections().forEach(d => (d.open = true)); saveAccOpen(); }); + collapseBtn?.addEventListener('click', () => { accSections().forEach(d => (d.open = false)); saveAccOpen(); }); + reorderBtn?.addEventListener('click', () => { + const isReordering = accPanel.classList.toggle('reorder-on'); + reorderBtn.setAttribute('aria-pressed', String(isReordering)); + }); + + // Init + restoreAccOrder(); + restoreAccOpen(); + restorePanelScroll(); + } + + // expose for classic.js + window.setupAccordionPanel = setupAccordionPanel; + + // ----------------------------- + // Organic app logic + // ----------------------------- + document.addEventListener('DOMContentLoaded', () => { + // ====== GLOBAL SCALE ====== + const PX_PER_INCH = 4; + const SIZE_PRESETS = [24, 18, 11, 9, 5]; + + // ====== Shine ellipse tuning ====== + const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.7; // ROT is now in degrees + let view = { s: 1, tx: 0, ty: 0 }; + const FIT_PADDING_PX = 15; + + // ====== Texture defaults ====== + const TEXTURE_ZOOM_DEFAULT = 1.8; + const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 }; + const SWATCH_TEXTURE_ZOOM = 2.5; + + const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); + const clamp01 = v => clamp(v, 0, 1); + + const QUERY_KEY = 'd'; + + // ====== Flatten palette & maps ====== + const FLAT_COLORS = []; + const NAME_BY_HEX = new Map(); + const HEX_TO_FIRST_IDX = new Map(); + const allowedSet = new Set(); + + (function buildFlat() { + if (!Array.isArray(window.PALETTE)) return; + window.PALETTE.forEach(group => { + (group.colors || []).forEach(c => { + if (!c?.hex) return; + const item = { ...c, family: group.family }; + item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT; + item.imageFocus = { + x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x), + y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y) + }; + item._idx = FLAT_COLORS.length; + FLAT_COLORS.push(item); + + const key = (c.hex || '').toLowerCase(); + if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name); + if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx); + allowedSet.add(key); + }); + }); + })(); + + // ====== Image cache ====== + const IMG_CACHE = new Map(); + function getImage(path) { + if (!path) return null; + let img = IMG_CACHE.get(path); + if (!img) { + img = new Image(); + img.decoding = 'async'; + img.loading = 'eager'; + img.src = path; + img.onload = () => draw(); + IMG_CACHE.set(path, img); + } + return img; + } + + // ====== DOM ====== + const canvas = document.getElementById('balloon-canvas'); + const ctx = canvas?.getContext('2d'); + + // tool buttons + const toolDrawBtn = document.getElementById('tool-draw'); + const toolEraseBtn = document.getElementById('tool-erase'); + const toolSelectBtn = document.getElementById('tool-select'); + + // panels/controls + const eraserControls = document.getElementById('eraser-controls'); + const selectControls = document.getElementById('select-controls'); + const eraserSizeInput = document.getElementById('eraser-size'); + const eraserSizeLabel = document.getElementById('eraser-size-label'); + const deleteSelectedBtn = document.getElementById('delete-selected'); + const duplicateSelectedBtn = document.getElementById('duplicate-selected'); + + const sizePresetGroup = document.getElementById('size-preset-group'); + const toggleShineBtn = document.getElementById('toggle-shine-btn'); + + const paletteBox = document.getElementById('color-palette'); + const usedPaletteBox = document.getElementById('used-palette'); + const sortUsedToggle = document.getElementById('sort-used-toggle'); + + // replace colors panel + const replaceFromSel = document.getElementById('replace-from'); + const replaceToSel = document.getElementById('replace-to'); + const replaceBtn = document.getElementById('replace-btn'); + const replaceMsg = document.getElementById('replace-msg'); + + // IO + const clearCanvasBtn = document.getElementById('clear-canvas-btn'); + const saveJsonBtn = document.getElementById('save-json-btn'); + const loadJsonInput = document.getElementById('load-json-input'); + + // delegate export buttons (shared IDs across tabs) + document.body.addEventListener('click', e => { + if (e.target.id === 'export-png-btn') exportPng(); + else if (e.target.id === 'export-svg-btn') exportSvg(); + }); + + const generateLinkBtn = document.getElementById('generate-link-btn'); + const shareLinkOutput = document.getElementById('share-link-output'); + const copyMessage = document.getElementById('copy-message'); + + // messages + const messageModal = document.getElementById('message-modal'); + const modalText = document.getElementById('modal-text'); + const modalCloseBtn = document.getElementById('modal-close-btn'); + + // layout + const controlsPanel = document.getElementById('controls-panel'); + const canvasPanel = document.getElementById('canvas-panel'); + const expandBtn = document.getElementById('expand-workspace-btn'); + const fullscreenBtn = document.getElementById('fullscreen-btn'); + + if (!canvas || !ctx) return; // nothing to do if organic UI isn't on page + + // ====== State ====== + let balloons = []; + let selectedColorIdx = 0; + let currentDiameterInches = 11; + let currentRadius = inchesToRadiusPx(currentDiameterInches); + let isShineEnabled = true; // will be initialized from localStorage + + let dpr = 1; + let mode = 'draw'; + let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10); + let mouseInside = false; + let mousePos = { x: 0, y: 0 }; + let selectedBalloonId = null; + let usedSortDesc = true; + + // ====== Helpers ====== + const normalizeHex = h => (h || '').toLowerCase(); + function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; } + function radiusToSizeIndex(r) { + let best = 0, bestDiff = Infinity; + for (let i = 0; i < SIZE_PRESETS.length; i++) { + const diff = Math.abs(inchesToRadiusPx(SIZE_PRESETS[i]) - r); + if (diff < bestDiff) { best = i; bestDiff = diff; } + } + return best; + } + function showModal(msg) { if (!messageModal) return; modalText.textContent = msg; messageModal.classList.remove('hidden'); } + function hideModal() { if (!messageModal) return; messageModal.classList.add('hidden'); } + function showCopyMessage() { if (!copyMessage) return; copyMessage.classList.add('show'); setTimeout(() => copyMessage.classList.remove('show'), 2000); } + function getMousePos(e) { + const r = canvas.getBoundingClientRect(); + return { + x: (e.clientX - r.left) / view.s - view.tx, + y: (e.clientY - r.top) / view.s - view.ty + }; + } + + // ====== Global shine sync (shared with Classic) + window.syncAppShine = function(isEnabled) { + isShineEnabled = isEnabled; + + // mirror both UIs + const organicBtn = document.getElementById('toggle-shine-btn'); + const classicCb = document.getElementById('classic-shine-enabled'); + if (organicBtn) organicBtn.textContent = isEnabled ? 'Turn Off Shine' : 'Turn On Shine'; + if (classicCb) classicCb.checked = isEnabled; + + try { localStorage.setItem('app:shineEnabled:v1', JSON.stringify(isEnabled)); } catch {} + + // push into Classic engine if available + if (window.ClassicDesigner?.api?.setShineEnabled) { + window.ClassicDesigner.api.setShineEnabled(isEnabled); + } + + // redraw both tabs (cheap + robust) + try { draw?.(); } catch {} + try { window.ClassicDesigner?.redraw?.(); } catch {} + }; + + function setMode(next) { + mode = next; + toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw')); + toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase')); + toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select')); + eraserControls?.classList.toggle('hidden', mode !== 'erase'); + selectControls?.classList.toggle('hidden', mode !== 'select'); + canvas.style.cursor = (mode === 'erase') ? 'none' : (mode === 'select' ? 'pointer' : 'crosshair'); + draw(); + persist(); + } + + function updateSelectButtons() { + const has = !!selectedBalloonId; + if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has; + if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has; + } + + // ====== Pointer Events ====== + let pointerDown = false; + + canvas.addEventListener('pointerdown', e => { + e.preventDefault(); + canvas.setPointerCapture?.(e.pointerId); + mouseInside = true; + mousePos = getMousePos(e); + + if (e.altKey) { pickColorAt(mousePos.x, mousePos.y); return; } + if (mode === 'erase') { pointerDown = true; eraseAt(mousePos.x, mousePos.y); return; } + if (mode === 'select') { selectAt(mousePos.x, mousePos.y); return; } + + // draw mode: add + addBalloon(mousePos.x, mousePos.y); + }, { passive: false }); + + canvas.addEventListener('pointermove', e => { + mousePos = getMousePos(e); + if (mode === 'erase') { + if (pointerDown) eraseAt(mousePos.x, mousePos.y); + else draw(); + } + }, { passive: true }); + + canvas.addEventListener('pointerup', e => { + pointerDown = false; + canvas.releasePointerCapture?.(e.pointerId); + }, { passive: true }); + + canvas.addEventListener('pointerleave', () => { + mouseInside = false; + if (mode === 'erase') draw(); + }, { passive: true }); + + // ====== Canvas & Drawing ====== + function resizeCanvas() { + const rect = canvas.getBoundingClientRect(); + dpr = Math.max(1, window.devicePixelRatio || 1); + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + fitView(); + draw(); + } + function clearCanvasArea() { + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + } + + function draw() { + clearCanvasArea(); + ctx.save(); + ctx.scale(view.s, view.s); + ctx.translate(view.tx, view.ty); + + balloons.forEach(b => { + if (b.image) { + const img = getImage(b.image); + if (img && img.complete && img.naturalWidth > 0) { + const meta = FLAT_COLORS[b.colorIdx] || {}; + const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT); + const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); + const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); + + const srcW = img.naturalWidth / zoom; + const srcH = img.naturalHeight / zoom; + const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW); + const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH); + + ctx.save(); + ctx.beginPath(); + ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2); + ctx.restore(); + } else { + // fallback solid + ctx.beginPath(); + ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); + ctx.fillStyle = b.color; + ctx.shadowColor = 'rgba(0,0,0,0.2)'; + ctx.shadowBlur = 10; + ctx.fill(); + ctx.shadowBlur = 0; + } + } else { + // solid fill + ctx.beginPath(); + ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); + ctx.fillStyle = b.color; + ctx.shadowColor = 'rgba(0,0,0,0.2)'; + ctx.shadowBlur = 10; + ctx.fill(); + ctx.shadowBlur = 0; + } + + if (isShineEnabled) { + const sx = b.x - b.radius * SHINE_OFFSET; + const sy = b.y - b.radius * SHINE_OFFSET; + const rx = b.radius * SHINE_RX; + const ry = b.radius * SHINE_RY; + const rotRad = SHINE_ROT * Math.PI / 180; + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.1)'; + ctx.shadowBlur = 3; // SHINE_BLUR + ctx.beginPath(); + if (ctx.ellipse) { + ctx.ellipse(sx, sy, rx, ry, rotRad, 0, Math.PI * 2); + } else { + ctx.translate(sx, sy); + ctx.rotate(rotRad); + ctx.scale(rx / ry, 1); + ctx.arc(0, 0, ry, 0, Math.PI * 2); + } + ctx.fillStyle = `rgba(255,255,255,${SHINE_ALPHA})`; + ctx.fill(); + ctx.restore(); + } + }); + + // selection ring + if (selectedBalloonId) { + const b = balloons.find(bb => bb.id === selectedBalloonId); + if (b) { + ctx.save(); + ctx.beginPath(); + ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2); + ctx.setLineDash([6, 4]); + ctx.lineWidth = 2 / view.s; + ctx.strokeStyle = '#2563eb'; + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } + } + + // eraser preview + if (mode === 'erase' && mouseInside) { + ctx.save(); + ctx.beginPath(); + ctx.arc(mousePos.x, mousePos.y, eraserRadius, 0, Math.PI * 2); + ctx.lineWidth = 1.5 / view.s; + ctx.strokeStyle = 'rgba(31,41,55,0.8)'; + ctx.setLineDash([4 / view.s, 4 / view.s]); + ctx.stroke(); + ctx.restore(); + } + + ctx.restore(); + } + + // --- Workspace expansion + Fullscreen --- + let expanded = false; + function setExpanded(on) { + expanded = on; + controlsPanel?.classList.toggle('hidden', expanded); + if (canvasPanel) { + canvasPanel.classList.toggle('lg:w-full', expanded); + canvasPanel.classList.toggle('lg:w-2/3', !expanded); + } + if (expanded) { + canvas.classList.remove('aspect-video'); + canvas.style.height = '85vh'; + } else { + canvas.classList.add('aspect-video'); + canvas.style.height = ''; + } + resizeCanvas(); + if (expandBtn) expandBtn.textContent = expanded ? 'Exit expanded view' : 'Expand workspace'; + persist(); + } + function isFullscreen() { return !!(document.fullscreenElement || document.webkitFullscreenElement); } + async function toggleFullscreenPage() { + try { + if (!isFullscreen()) { await document.documentElement.requestFullscreen(); } + else { await document.exitFullscreen(); } + } catch { + // if blocked, just use expanded + setExpanded(true); + } + } + const onFsChange = () => { + if (fullscreenBtn) fullscreenBtn.textContent = isFullscreen() ? 'Exit Fullscreen' : 'Fullscreen'; + resizeCanvas(); + }; + expandBtn?.addEventListener('click', () => setExpanded(!expanded)); + fullscreenBtn?.addEventListener('click', toggleFullscreenPage); + document.addEventListener('fullscreenchange', onFsChange); + document.addEventListener('webkitfullscreenchange', onFsChange); + + new ResizeObserver(() => resizeCanvas()).observe(canvas.parentElement); + canvas.style.touchAction = 'none'; + + // ====== State Persistence ====== + const APP_STATE_KEY = 'obd:state:v3'; + + function saveAppState() { + // Note: isShineEnabled is managed globally. + const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, mode, view, usedSortDesc, expanded }; + try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {} + } + const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })(); + + function loadAppState() { + try { + const s = JSON.parse(localStorage.getItem(APP_STATE_KEY) || '{}'); + if (Array.isArray(s.balloons)) balloons = s.balloons; + if (typeof s.selectedColorIdx === 'number') selectedColorIdx = s.selectedColorIdx; + if (typeof s.currentDiameterInches === 'number') { + currentDiameterInches = s.currentDiameterInches; + currentRadius = inchesToRadiusPx(currentDiameterInches); + } + if (typeof s.eraserRadius === 'number') { + eraserRadius = s.eraserRadius; + if (eraserSizeInput) eraserSizeInput.value = eraserRadius; + if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius; + } + if (typeof s.mode === 'string') mode = s.mode; + if (s.view && typeof s.view.s === 'number') view = s.view; + if (typeof s.usedSortDesc === 'boolean') { + usedSortDesc = s.usedSortDesc; + if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most'; + } + if (typeof s.expanded === 'boolean') setExpanded(s.expanded); + } catch {} + } + + loadAppState(); + + // ====== UI Rendering (Palettes) ====== + function renderAllowedPalette() { + if (!paletteBox) return; + paletteBox.innerHTML = ''; + (window.PALETTE || []).forEach(group => { + const title = document.createElement('div'); + title.className = 'family-title'; + title.textContent = group.family; + paletteBox.appendChild(title); + + const row = document.createElement('div'); + row.className = 'swatch-row'; + (group.colors || []).forEach(c => { + const idx = + FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx + ?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)); + const sw = document.createElement('div'); + sw.className = 'swatch'; + + if (c.image) { + const meta = FLAT_COLORS[idx] || {}; + sw.style.backgroundImage = `url("${c.image}")`; + sw.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`; + sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`; + } else { + sw.style.backgroundColor = c.hex; + } + + if (idx === selectedColorIdx) sw.classList.add('active'); + sw.title = c.name; + + sw.addEventListener('click', () => { + selectedColorIdx = idx ?? 0; + renderAllowedPalette(); + persist(); + }); + row.appendChild(sw); + }); + paletteBox.appendChild(row); + }); + } + + function getUsedColors() { + const map = new Map(); + balloons.forEach(b => { + const key = normalizeHex(b.color); + if (!allowedSet.has(key)) return; + if (!map.has(key)) { + const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(key)] || {}; + map.set(key, { hex: key, count: 0, image: meta.image, name: meta.name }); + } + map.get(key).count++; + }); + const arr = [...map.values()]; + arr.sort((a, b) => (usedSortDesc ? (b.count - a.count) : (a.count - b.count))); + return arr; + } + + function renderUsedPalette() { + if (!usedPaletteBox) return; + usedPaletteBox.innerHTML = ''; + const used = getUsedColors(); + if (used.length === 0) { + usedPaletteBox.innerHTML = '
No colors yet.
'; + if (replaceFromSel) replaceFromSel.innerHTML = ''; + return; + } + const row = document.createElement('div'); + row.className = 'swatch-row'; + used.forEach(item => { + const sw = document.createElement('div'); + sw.className = 'swatch'; + if (item.image) { + const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(item.hex)] || {}; + sw.style.backgroundImage = `url("${item.image}")`; + sw.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`; + sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`; + } else { + sw.style.backgroundColor = item.hex; + } + if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === item.hex) sw.classList.add('active'); + sw.title = `${item.name || NAME_BY_HEX.get(item.hex) || item.hex} — ${item.count}`; + sw.addEventListener('click', () => { + selectedColorIdx = HEX_TO_FIRST_IDX.get(item.hex) ?? 0; + renderAllowedPalette(); + renderUsedPalette(); + }); + + const badge = document.createElement('div'); + badge.className = 'badge'; + badge.textContent = String(item.count); + sw.appendChild(badge); + row.appendChild(sw); + }); + usedPaletteBox.appendChild(row); + + // fill "replace from" + if (replaceFromSel) { + replaceFromSel.innerHTML = ''; + used.forEach(item => { + const opt = document.createElement('option'); + const name = item.name || NAME_BY_HEX.get(item.hex) || item.hex; + opt.value = item.hex; + opt.textContent = `${name} (${item.count})`; + replaceFromSel.appendChild(opt); + }); + } + } + + // ====== Balloon Ops & Data/Export ====== + function addBalloon(x, y) { + const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; + balloons.push({ + x, y, + radius: currentRadius, + color: meta.hex, + image: meta.image || null, + colorIdx: meta._idx, + id: crypto.randomUUID() + }); + ensureVisibleAfterAdd(balloons[balloons.length - 1]); + refreshAll(); + } + + function findBalloonIndexAt(x, y) { + for (let i = balloons.length - 1; i >= 0; i--) { + const b = balloons[i]; + if (Math.hypot(x - b.x, y - b.y) <= b.radius) return i; + } + return -1; + } + + function selectAt(x, y) { + const i = findBalloonIndexAt(x, y); + selectedBalloonId = (i !== -1) ? balloons[i].id : null; + updateSelectButtons(); + draw(); + } + + function deleteSelected() { + if (!selectedBalloonId) return; + balloons = balloons.filter(b => b.id !== selectedBalloonId); + selectedBalloonId = null; + updateSelectButtons(); + refreshAll(); + } + + function duplicateSelected() { + if (!selectedBalloonId) return; + const b = balloons.find(bb => bb.id === selectedBalloonId); + if (!b) return; + const copy = { ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() }; + balloons.push(copy); + selectedBalloonId = copy.id; + refreshAll(); + } + + function eraseAt(x, y) { + balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius); + if (selectedBalloonId && !balloons.find(b => b.id === selectedBalloonId)) { + selectedBalloonId = null; + updateSelectButtons(); + } + refreshAll(); + } + + function pickColorAt(x, y) { + const i = findBalloonIndexAt(x, y); + if (i !== -1) { + selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0; + renderAllowedPalette(); + renderUsedPalette(); + } + } + + function promptForFilename(suggested) { + const m = suggested.match(/\.([a-z0-9]+)$/i); + const ext = m ? m[1].toLowerCase() : ''; + const defaultBase = suggested.replace(/\.[^.]+$/, ''); + const lsKey = ext ? `lastFilenameBase.${ext}` : `lastFilenameBase`; + const last = localStorage.getItem(lsKey) || defaultBase; + + const input = window.prompt(ext ? `File name (.${ext} will be added)` : 'File name', last); + if (input === null) return null; + + let base = (input.trim() || defaultBase) + .replace(/[<>:"/\\|?*\x00-\x1F]/g, '') + .replace(/\.+$/, '') + .replace(/\.[^.]+$/, ''); + try { localStorage.setItem(lsKey, base); } catch {} + + return ext ? `${base}.${ext}` : base; + } + + function download(href, suggestedFilename) { + const finalName = promptForFilename(suggestedFilename); + if (!finalName) return; + const a = document.createElement('a'); + a.href = href; + a.download = finalName; + a.click(); + a.remove(); + } + + function saveJson() { + download('data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify({ balloons })), 'balloon_design.json'); + } + + function loadJson(e) { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + try { + const data = JSON.parse(ev.target.result); + balloons = Array.isArray(data.balloons) + ? data.balloons.map(b => { + const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0); + const meta = FLAT_COLORS[idx] || {}; + return { + x: b.x, y: b.y, radius: b.radius, + color: meta.hex || b.color, + image: meta.image || null, + colorIdx: idx, + id: crypto.randomUUID() + }; + }) + : []; + selectedBalloonId = null; + updateSelectButtons(); + refreshAll({ refit: true }); + persist(); + } catch { + showModal('Error parsing JSON file.'); + } + }; + reader.readAsText(file); + } + + // ** NEW ** Rewritten export function to embed images + async function exportPng() { + const currentTab = (window.__whichTab && window.__whichTab()) || '#tab-organic'; + + if (currentTab === '#tab-classic') { + const svgElement = document.querySelector('#classic-display svg'); + if (!svgElement) { + showModal('Classic design not found. Please create a design first.'); + return; + } + + // 1. Clone the SVG to avoid modifying the live one + const clonedSvg = svgElement.cloneNode(true); + const imageElements = Array.from(clonedSvg.querySelectorAll('image')); + + // 2. Create promises to fetch and convert each image to a Data URL + const promises = imageElements.map(async (image) => { + const href = image.getAttribute('href'); + if (!href || href.startsWith('data:')) return; // Skip if no href or already a data URL + + try { + const response = await fetch(href); + const blob = await response.blob(); + const dataUrl = await new Promise(resolve => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + image.setAttribute('href', dataUrl); + } catch (error) { + console.error(`Could not fetch image ${href}:`, error); + } + }); + + // 3. Wait for all images to be embedded + await Promise.all(promises); + + // 4. Serialize the modified, self-contained SVG + const svgData = new XMLSerializer().serializeToString(clonedSvg); + const img = new Image(); + + img.onload = () => { + const viewBox = svgElement.getAttribute('viewBox').split(' ').map(Number); + const svgWidth = viewBox[2]; + const svgHeight = viewBox[3]; + const scale = 2; // for higher resolution + + const canvasEl = document.createElement('canvas'); + canvasEl.width = svgWidth * scale; + canvasEl.height = svgHeight * scale; + const ctx2 = canvasEl.getContext('2d'); + + ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height); + download(canvasEl.toDataURL('image/png'), 'classic_design.png'); + }; + + img.onerror = () => { + showModal("An error occurred while creating the PNG from the SVG."); + }; + + img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); + + } else { + // Organic canvas export (remains the same) + if (balloons.length === 0) { + showModal('Canvas is empty.'); + return; + } + download(canvas.toDataURL('image/png'), 'balloon_design.png'); + } + } + + + function exportSvg() { + const currentTab = (window.__whichTab && window.__whichTab()) || '#tab-organic'; + + if (currentTab === '#tab-classic') { + const svgElement = document.querySelector('#classic-display svg'); + if (!svgElement) { showModal('Classic design not found.'); return; } + const svgData = new XMLSerializer().serializeToString(svgElement); + const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`; + download(url, 'classic_design.svg'); + } else { + // Organic canvas-to-SVG export + if (balloons.length === 0) { + showModal('Canvas is empty. Add some balloons first.'); + return; + } + + const bounds = balloonsBounds(); + const pad = 20; + const vb = [bounds.minX - pad, bounds.minY - pad, bounds.w + pad * 2, bounds.h + pad * 2].join(' '); + + let defs = ''; + let elements = ''; + const patterns = new Map(); + + balloons.forEach(b => { + let fill = b.color; + if (b.image) { + const patternId = `p${b.colorIdx}`; + if (!patterns.has(b.colorIdx)) { + patterns.set(b.colorIdx, patternId); + const meta = FLAT_COLORS[b.colorIdx] || {}; + const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT); + const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); + const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); + + // Calculate image attributes to simulate the canvas crop/zoom + const imgW = zoom; + const imgH = zoom; + const imgX = 0.5 - (fx * zoom); + const imgY = 0.5 - (fy * zoom); + + defs += ` + + `; + } + fill = `url(#${patternId})`; + } + elements += `\n`; + + if (isShineEnabled) { + const sx = b.x - b.radius * SHINE_OFFSET; + const sy = b.y - b.radius * SHINE_OFFSET; + const rx = b.radius * SHINE_RX; + const ry = b.radius * SHINE_RY; + elements += `\n`; + } + }); + + const svgData = ` + ${defs} + ${elements} + `; + + const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`; + download(url, 'organic_design.svg'); + } + } + + function designToCompact(list) { + return { v: 2, b: list.map(b => [ Math.round(b.x), Math.round(b.y), radiusToSizeIndex(b.radius), b.colorIdx ?? 0 ]) }; + } + + function compactToDesign(obj) { + if (!obj || !Array.isArray(obj.b)) return []; + return obj.b.map(row => { + const [x, y, sizeIdx, colorIdx] = row; + const diam = SIZE_PRESETS[sizeIdx] ?? SIZE_PRESETS[0]; + const radius = inchesToRadiusPx(diam); + const meta = FLAT_COLORS[colorIdx] || FLAT_COLORS[0]; + return { x, y, radius, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: crypto.randomUUID() }; + }); + } + + function generateShareLink() { + const base = `${window.location.origin}${window.location.pathname}`; + const link = `${base}?${QUERY_KEY}=${LZString.compressToEncodedURIComponent(JSON.stringify(designToCompact(balloons)))}`; + if (shareLinkOutput) shareLinkOutput.value = link; + navigator.clipboard?.writeText(link).then(showCopyMessage); + } + + function loadFromUrl() { + const params = new URLSearchParams(window.location.search); + const encoded = params.get(QUERY_KEY) || params.get('design'); + if (!encoded) return; + try { + let jsonStr = LZString.decompressFromEncodedURIComponent(encoded) || atob(encoded); + const data = JSON.parse(jsonStr); + balloons = Array.isArray(data.balloons) + ? data.balloons.map(b => { + const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0); + const meta = FLAT_COLORS[idx] || {}; + return { x: b.x, y: b.y, radius: b.radius, color: meta.hex, image: meta.image, colorIdx: idx, id: crypto.randomUUID() }; + }) + : compactToDesign(data); + refreshAll({ refit: true }); + persist(); + showModal('Design loaded from link!'); + } catch { + showModal('Could not load design from URL.'); + } + } + + // ====== Fit/Camera helpers ====== + function balloonsBounds() { + if (balloons.length === 0) return { minX: 0, minY: 0, maxX: 500, maxY: 500, w: 500, h: 500 }; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const b of balloons) { + minX = Math.min(minX, b.x - b.radius); + minY = Math.min(minY, b.y - b.radius); + maxX = Math.max(maxX, b.x + b.radius); + maxY = Math.max(maxY, b.y + b.radius); + } + return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }; + } + + function fitView() { + const box = balloonsBounds(); + const cw = canvas.width / dpr; // CSS px + const ch = canvas.height / dpr; + if (balloons.length === 0) { view.s = 1; view.tx = 0; view.ty = 0; return; } + + const pad = FIT_PADDING_PX; + const w = Math.max(1, box.w); + const h = Math.max(1, box.h); + + const sFit = Math.min((cw - 2*pad) / w, (ch - 2*pad) / h); + view.s = Math.min(1, isFinite(sFit) && sFit > 0 ? sFit : 1); + + const worldW = cw / view.s; + const worldH = ch / view.s; + view.tx = (worldW - w) * 0.5 - box.minX; + view.ty = (worldH - h) * 0.5 - box.minY; + } + + function balloonScreenBounds(b) { + const left = (b.x - b.radius + view.tx) * view.s; + const right = (b.x + b.radius + view.tx) * view.s; + const top = (b.y - b.radius + view.ty) * view.s; + const bottom = (b.y + b.radius + view.ty) * view.s; + return { left, right, top, bottom }; + } + + function ensureVisibleAfterAdd(b) { + const pad = FIT_PADDING_PX; + const cw = canvas.width / dpr; + const ch = canvas.height / dpr; + + // zoom out only if needed to keep the new balloon visible + const needSx = (cw - 2*pad) / (2*b.radius); + const needSy = (ch - 2*pad) / (2*b.radius); + const sNeeded = Math.min(needSx, needSy); + if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) { + view.s = Math.max(0.05, sNeeded); + } + + const r = balloonScreenBounds(b); + let dx = 0, dy = 0; + if (r.left < pad) dx = (pad - r.left) / view.s; + else if (r.right > cw-pad) dx = ((cw - pad) - r.right) / view.s; + + if (r.top < pad) dy = (pad - r.top) / view.s; + else if (r.bottom > ch-pad) dy = ((ch - pad) - r.bottom) / view.s; + + view.tx += dx; + view.ty += dy; + } + + // ====== Refresh & Events ====== + function refreshAll({ refit = false } = {}) { + if (refit) fitView(); + draw(); + renderUsedPalette(); + persist(); + if(window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + } + + // --- UI bindings --- + modalCloseBtn?.addEventListener('click', hideModal); + + toolDrawBtn?.addEventListener('click', () => setMode('draw')); + toolEraseBtn?.addEventListener('click', () => setMode('erase')); + toolSelectBtn?.addEventListener('click', () => setMode('select')); + + eraserSizeInput?.addEventListener('input', e => { + eraserRadius = parseInt(e.target.value, 10); + if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius; + if (mode === 'erase') draw(); + persist(); + }); + + deleteSelectedBtn?.addEventListener('click', deleteSelected); + duplicateSelectedBtn?.addEventListener('click', duplicateSelected); + + document.addEventListener('keydown', e => { + if (document.activeElement && document.activeElement.tagName === 'INPUT') return; + if (e.key === 'e' || e.key === 'E') setMode('erase'); + else if (e.key === 'v' || e.key === 'V') setMode('draw'); + else if (e.key === 's' || e.key === 'S') setMode('select'); + else if (e.key === 'Escape') { + selectedBalloonId = null; + updateSelectButtons(); + draw(); + } else if (e.key === 'Delete' || e.key === 'Backspace') { + if (selectedBalloonId) { e.preventDefault(); deleteSelected(); } + } + }); + + clearCanvasBtn?.addEventListener('click', () => { + balloons = []; + selectedBalloonId = null; + updateSelectButtons(); + refreshAll({ refit: true }); + }); + + saveJsonBtn?.addEventListener('click', saveJson); + loadJsonInput?.addEventListener('change', loadJson); + + generateLinkBtn?.addEventListener('click', generateShareLink); + + sortUsedToggle?.addEventListener('click', () => { + usedSortDesc = !usedSortDesc; + sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most'; + renderUsedPalette(); + persist(); + }); + + function populateReplaceTo() { + if (!replaceToSel) return; + replaceToSel.innerHTML = ''; + (window.PALETTE || []).forEach(group => { + const og = document.createElement('optgroup'); + og.label = group.family; + (group.colors || []).forEach(c => { + const idx = + FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx + ?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)) ?? 0; + const opt = document.createElement('option'); + opt.value = String(idx); + opt.textContent = c.name + (c.image ? ' (image)' : ''); + og.appendChild(opt); + }); + replaceToSel.appendChild(og); + }); + } + + replaceBtn?.addEventListener('click', () => { + const fromHex = replaceFromSel?.value; + const toIdx = parseInt(replaceToSel?.value || '', 10); + if (!fromHex || Number.isNaN(toIdx)) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; } + + let count = 0; + balloons.forEach(b => { + if (normalizeHex(b.color) === normalizeHex(fromHex)) { + const toMeta = FLAT_COLORS[toIdx]; + b.color = toMeta.hex; + b.image = toMeta.image || null; + b.colorIdx = toMeta._idx; + count++; + } + }); + + if (count > 0) { + if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`; + if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx; + refreshAll(); + renderAllowedPalette(); + } else { + if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.'; + } + }); + + // ====== Init ====== + sizePresetGroup && (sizePresetGroup.innerHTML = ''); + SIZE_PRESETS.forEach(di => { + const btn = document.createElement('button'); + btn.className = 'tool-btn'; + btn.textContent = `${di}"`; + btn.setAttribute('aria-pressed', String(di === currentDiameterInches)); + btn.addEventListener('click', () => { + currentDiameterInches = di; + currentRadius = inchesToRadiusPx(di); + [...sizePresetGroup.querySelectorAll('button')].forEach(b => b.setAttribute('aria-pressed', 'false')); + btn.setAttribute('aria-pressed', 'true'); + persist(); + }); + sizePresetGroup?.appendChild(btn); + }); + + toggleShineBtn?.addEventListener('click', () => { + window.syncAppShine(!isShineEnabled); + }); + + renderAllowedPalette(); + resizeCanvas(); + loadFromUrl(); + renderUsedPalette(); + setMode('draw'); + updateSelectButtons(); + populateReplaceTo(); + + if (window.matchMedia('(max-width: 768px)').matches) setExpanded(true); + + // Init accordion for the Organic panel + setupAccordionPanel({ + panelId: 'controls-panel', + expandBtnId: 'expand-all', + collapseBtnId: 'collapse-all', + reorderBtnId: 'toggle-reorder', + storagePrefix: 'obd' // Organic Balloon Designer + }); + + // Initialize shine state from localStorage for both panels + let initialShineState = true; + try { + const saved = localStorage.getItem('app:shineEnabled:v1'); + if (saved !== null) initialShineState = JSON.parse(saved); + } catch {} + + // Set Organic panel's internal state and UI + isShineEnabled = initialShineState; + if (toggleShineBtn) toggleShineBtn.textContent = isShineEnabled ? 'Turn Off Shine' : 'Turn On Shine'; + + // Set Classic panel's UI checkbox (its script will read this too) + const classicCb = document.getElementById('classic-shine-enabled'); + if (classicCb) classicCb.checked = isShineEnabled; + + // =============================== + // ===== TAB SWITCHING (UI) ====== + // =============================== + (() => { + const orgSection = document.getElementById('tab-organic'); + const claSection = document.getElementById('tab-classic'); + const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn'); + + if (!orgSection || !claSection || tabBtns.length === 0) return; + + let current = '#tab-organic'; + + function show(id) { + orgSection.classList.toggle('hidden', id !== '#tab-organic'); + claSection.classList.toggle('hidden', id !== '#tab-classic'); + + tabBtns.forEach(btn => { + const active = btn.dataset.target === id; + btn.classList.toggle('tab-active', active); + btn.classList.toggle('tab-idle', !active); + btn.setAttribute('aria-pressed', String(active)); + }); + + current = id; + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + } + + tabBtns.forEach(btn => btn.addEventListener('click', () => show(btn.dataset.target))); + show('#tab-organic'); // default + + // Helper so other code (e.g., export) can know which tab is visible + window.__whichTab = () => current; + })(); + }); + + document.addEventListener('DOMContentLoaded', () => { + const modeTabs = document.getElementById('mode-tabs'); + const allPanels = document.querySelectorAll('#tab-organic, #tab-classic'); + const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1'; + + function switchTab(targetId) { + if (!targetId || !document.querySelector(targetId)) return; + + const targetPanel = document.querySelector(targetId); + const targetButton = modeTabs.querySelector(`button[data-target="${targetId}"]`); + + modeTabs.querySelectorAll('button').forEach(btn => { + btn.classList.remove('tab-active'); + btn.classList.add('tab-idle'); + btn.setAttribute('aria-pressed', 'false'); + }); + allPanels.forEach(panel => panel.classList.add('hidden')); + + if (targetButton && targetPanel) { + targetButton.classList.add('tab-active'); + targetButton.classList.remove('tab-idle'); + targetButton.setAttribute('aria-pressed', 'true'); + targetPanel.classList.remove('hidden'); + } + + if (window.updateExportButtonVisibility) { + window.updateExportButtonVisibility(); + } + } + + modeTabs.addEventListener('click', (e) => { + const button = e.target.closest('button[data-target]'); + if (button) { + const targetId = button.dataset.target; + localStorage.setItem(ACTIVE_TAB_KEY, targetId); + switchTab(targetId); + } + }); + + const savedTab = localStorage.getItem(ACTIVE_TAB_KEY); + if (savedTab) { + switchTab(savedTab); + } + }); +})(); \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..13147c1 --- /dev/null +++ b/style.css @@ -0,0 +1,147 @@ +/* Minimal extras (Tailwind handles most styling) */ +body { color: #1f2937; } + +#balloon-canvas { touch-action: none; } + +.balloon-canvas { + background: #fff; + border-radius: 1rem; + box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06); + border: 1px black solid; +} + +/* Buttons */ +.tool-btn { + padding: .5rem .75rem; + border: 1px solid #d1d5db; + border-radius: .5rem; + background: #fff; +} +.tool-btn[aria-pressed="true"] { background:#1f2937; color:#fff; border-color:#1f2937; } + +.btn-dark { background:#1f2937; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } +.btn-blue { background:#2563eb; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } +.btn-green { background:#16a34a; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } +.btn-yellow { background:#eab308; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } +.btn-danger { background:#ef4444; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } +.btn-indigo { background:#4f46e5; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } + +.copy-message{ opacity:0; transition:opacity .3s; } +.copy-message.show{ opacity:1; } + +.hint { font-size:.8rem; color:#6b7280; } + +/* Palette / Swatches */ +.palette-box { + display: flex; + flex-direction: column; + gap: .5rem; + padding: .5rem; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: .5rem; +} + +.swatch { + position: relative; + width: 2rem; + height: 2rem; + border-radius: 9999px; + border: 2px solid rgba(0,0,0,.15); + box-shadow: 0 1px 2px rgba(0,0,0,.08); + cursor: pointer; +} +.swatch.active { outline: 2px solid #3b82f6; outline-offset: 2px; } + +.swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; } +.family-title { font-weight:600; color:#4b5563; margin-top:.25rem; font-size:.95rem; } + +.badge { + position:absolute; + right:-6px; top:-6px; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 .25rem; + background:#111827; + color:#fff; + border-radius: 9999px; + font-size:.7rem; + display:flex; + align-items:center; + justify-content:center; +} + +/* Selects */ +.select { + width: 100%; + padding: .5rem .6rem; + border: 1px solid #d1d5db; + border-radius: .5rem; + background: #fff; +} + + +/* hidden by default; only show in reorder mode */ +.drag-handle { + display: none; + width: 28px; height: 28px; margin-right: .25rem; + align-items: center; justify-content: center; + border-radius: .5rem; border: 1px solid #e5e7eb; /* gray-200 */ + background: #f8fafc; /* slate-50 */ + font-size: 18px; line-height: 1; + touch-action: none; /* better touch-drag */ + cursor: grab; + } + .drag-handle:active { cursor: grabbing; } + + .reorder-on .drag-handle { display: inline-flex; } + .reorder-on section { outline: 2px dashed #cbd5e1; outline-offset: 4px; } /* slate-300 */ + .drag-ghost { opacity: .6; } + + #classic-swatch-grid .sw { width: 24px; height: 24px; border-radius: 6px; border: 1px solid rgba(0,0,0,.1); cursor: pointer; } +#classic-swatch-grid .sw:focus { outline: 2px solid #2563eb; outline-offset: 2px; } +.slot-btn[aria-pressed="true"] { background:#2563eb; color:#fff; } +/* Add these new rules to your stylesheet */ +.slot-container { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.slot-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.slot-swatch { + width: 2.5rem; /* 40px */ + height: 2.5rem; /* 40px */ + border-radius: 9999px; + border: 3px solid #e5e7eb; /* gray-200 */ + cursor: pointer; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + transition: border-color .2s, transform .2s; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + color: rgba(0,0,0,0.4); + background-size: cover; + background-position: center; +} + +.slot-swatch:hover { + border-color: #9ca3af; /* gray-400 */ +} + +.slot-swatch.active { + border-color: #2563eb; /* blue-600 */ + transform: scale(1.1); +} + +.slot-label { + font-weight: 600; + color: #4b5563; /* gray-600 */ +} \ No newline at end of file