// script.js (() => { 'use strict'; // ----------------------------- // Organic app logic // ----------------------------- document.addEventListener('DOMContentLoaded', () => { // Shared values const { PX_PER_INCH, SIZE_PRESETS, TEXTURE_ZOOM_DEFAULT, TEXTURE_FOCUS_DEFAULT, SWATCH_TEXTURE_ZOOM, PNG_EXPORT_SCALE, clamp, clamp01, normalizeHex, hexToRgb, shineStyle, luminance, FLAT_COLORS, NAME_BY_HEX, HEX_TO_FIRST_IDX, allowedSet, getImage: sharedGetImage, imageUrlToDataUrl, download: sharedDownload, XLINK_NS, blobToDataUrl, imageToDataUrl, DATA_URL_CACHE, } = window.shared || {}; if (!window.shared) return; const getImageHrefShared = (el) => el.getAttribute('href') || el.getAttributeNS(window.shared.XLINK_NS, 'href'); const getImage = (path) => sharedGetImage(path, () => draw()); // ====== Shine ellipse tuning ====== const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.20; // ROT is now in degrees let view = { s: 1, tx: 0, ty: 0 }; const FIT_PADDING_PX = 30; const VIEW_MIN_SCALE = 0.12; const VIEW_MAX_SCALE = 1.05; const MAX_BALLOONS = 800; // ====== Garland path defaults ====== const GARLAND_POINT_STEP = 8; const GARLAND_BASE_DIAM = 18; const GARLAND_FILLER_DIAMS = [11, 9]; const GARLAND_ACCENT_DIAM = 5; const GARLAND_SPACING_RATIO = 0.85; // spacing along path vs base diameter const GARLAND_WOBBLE_RATIO = 0.35; const GARLAND_SIZE_JITTER = 0.14; // Make sure shared palette is populated (fallback in case shared init missed) if (Array.isArray(window.PALETTE) && FLAT_COLORS.length === 0) { 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); }); }); } // Ensure palette exists if shared initialization was skipped if (Array.isArray(window.PALETTE) && FLAT_COLORS.length === 0) { 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); }); }); } const makeSeededRng = seed => { let s = seed || 1; return () => { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; return (s >>> 0) / 4294967296; }; }; const QUERY_KEY = 'd'; // ====== DOM ====== const canvas = document.getElementById('balloon-canvas'); const ctx = canvas?.getContext('2d'); const orgSheet = document.getElementById('controls-panel'); const claSheet = document.getElementById('classic-controls-panel'); const wallSheet = document.getElementById('wall-controls-panel'); // tool buttons const toolDrawBtn = document.getElementById('tool-draw'); const toolGarlandBtn = document.getElementById('tool-garland'); const toolEraseBtn = document.getElementById('tool-erase'); const toolSelectBtn = document.getElementById('tool-select'); const toolUndoBtn = document.getElementById('tool-undo'); const toolRedoBtn = document.getElementById('tool-redo'); // 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 selectedSizeInput = document.getElementById('selected-size'); const selectedSizeLabel = document.getElementById('selected-size-label'); const nudgeSelectedBtns = Array.from(document.querySelectorAll('.nudge-selected')); const bringForwardBtn = document.getElementById('bring-forward'); const sendBackwardBtn = document.getElementById('send-backward'); const applyColorBtn = document.getElementById('apply-selected-color'); const fitViewBtn = document.getElementById('fit-view-btn'); const garlandDensityInput = document.getElementById('garland-density'); const garlandDensityLabel = document.getElementById('garland-density-label'); const garlandColorMain1Sel = document.getElementById('garland-color-main1'); const garlandColorMain2Sel = document.getElementById('garland-color-main2'); const garlandColorMain3Sel = document.getElementById('garland-color-main3'); const garlandColorMain4Sel = document.getElementById('garland-color-main4'); const garlandColorAccentSel = document.getElementById('garland-color-accent'); const garlandSwatchMain1 = document.getElementById('garland-swatch-main1'); const garlandSwatchMain2 = document.getElementById('garland-swatch-main2'); const garlandSwatchMain3 = document.getElementById('garland-swatch-main3'); const garlandSwatchMain4 = document.getElementById('garland-swatch-main4'); const garlandSwatchAccent = document.getElementById('garland-swatch-accent'); const garlandControls = document.getElementById('garland-controls'); const sizePresetGroup = document.getElementById('size-preset-group'); const toggleShineBtn = null; const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox'); const toggleBorderCheckbox = document.getElementById('toggle-border-checkbox'); 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'); const generateLinkBtn = document.getElementById('generate-link-btn'); const shareLinkOutput = document.getElementById('share-link-output'); const copyMessage = document.getElementById('copy-message'); const clearCanvasBtnTop = document.getElementById('clear-canvas-btn-top'); // Debug overlay to diagnose mobile input issues const debugOverlay = document.createElement('div'); debugOverlay.id = 'organic-debug-overlay'; debugOverlay.style.cssText = 'position:fixed;bottom:8px;right:8px;z-index:9999;background:rgba(0,0,0,0.7);color:#fff;padding:6px 8px;border-radius:8px;font-size:10px;font-family:monospace;pointer-events:none;opacity:0.9;line-height:1.3;'; debugOverlay.textContent = 'organic debug'; document.body.appendChild(debugOverlay); // 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 = null; const fullscreenBtn = null; 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 isBorderEnabled = true; let dpr = 1; let mode = 'draw'; let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10); let mouseInside = false; let mousePos = { x: 0, y: 0 }; let selectedIds = new Set(); let usedSortDesc = true; let garlandPath = []; let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1; let garlandMainIdx = [0, 0, 0, 0]; let garlandAccentIdx = 0; let lastCommitMode = ''; let lastAddStatus = ''; let evtStats = { down: 0, up: 0, cancel: 0, touchEnd: 0, addBalloon: 0, addGarland: 0, lastType: '' }; let addedThisPointer = false; // History for Undo/Redo const historyStack = []; let historyPointer = -1; function resetHistory() { historyStack.length = 0; historyPointer = -1; pushHistory(); } function updateHistoryUi() { const canUndo = historyPointer > 0; const canRedo = historyPointer < historyStack.length - 1; if (toolUndoBtn) { toolUndoBtn.disabled = !canUndo; toolUndoBtn.title = canUndo ? 'Undo (Ctrl+Z)' : 'Nothing to undo'; } if (toolRedoBtn) { toolRedoBtn.disabled = !canRedo; toolRedoBtn.title = canRedo ? 'Redo (Ctrl+Y)' : 'Nothing to redo'; } } function pushHistory() { // Remove any future history if we are in the middle of the stack if (historyPointer < historyStack.length - 1) { historyStack.splice(historyPointer + 1); } // Deep clone balloons array const snapshot = JSON.parse(JSON.stringify(balloons)); historyStack.push(snapshot); historyPointer++; // Limit stack size if (historyStack.length > 50) { historyStack.shift(); historyPointer--; } updateHistoryUi(); } function undo() { if (historyPointer > 0) { historyPointer--; balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); selectedIds.clear(); // clear selection on undo to avoid issues updateSelectButtons(); draw(); renderUsedPalette(); persist(); } updateHistoryUi(); } function redo() { if (historyPointer < historyStack.length - 1) { historyPointer++; balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); selectedIds.clear(); updateSelectButtons(); draw(); renderUsedPalette(); persist(); } updateHistoryUi(); } // Bind Undo/Redo Buttons toolUndoBtn?.addEventListener('click', undo); toolRedoBtn?.addEventListener('click', redo); // Eyedropper Tool const toolEyedropperBtn = document.getElementById('tool-eyedropper'); toolEyedropperBtn?.addEventListener('click', () => { // Toggle eyedropper mode if (mode === 'eyedropper') { setMode('draw'); // toggle off } else { setMode('eyedropper'); } }); function clampViewScale() { view.s = clamp(view.s, VIEW_MIN_SCALE, VIEW_MAX_SCALE); } function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; } function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; } function fmtInches(val) { const v = Math.round(val * 10) / 10; return `${String(v).replace(/\.0$/, '')}"`; } 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, opts = {}) { if (window.Swal) { Swal.fire({ title: opts.title || 'Notice', text: msg, icon: opts.icon || 'info', confirmButtonText: opts.confirmText || 'OK' }); return; } if (!messageModal || !modalText) { window.alert?.(msg); return; } modalText.textContent = msg; messageModal.classList.remove('hidden'); } function hideModal() { if (window.Swal) { Swal.close?.(); return; } 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 }; } function getTouchPos(touch) { const r = canvas.getBoundingClientRect(); return { x: (touch.clientX - r.left) / view.s - view.tx, y: (touch.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) { if (mode === 'garland' && next !== 'garland') { garlandPath = []; } mode = next; toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw')); toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland')); toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase')); toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select')); toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper')); eraserControls?.classList.toggle('hidden', mode !== 'erase'); selectControls?.classList.toggle('hidden', mode !== 'select'); garlandControls?.classList.toggle('hidden', mode !== 'garland'); if (mode === 'erase') canvas.style.cursor = 'none'; else if (mode === 'select') canvas.style.cursor = 'default'; // will be move over items else if (mode === 'garland') canvas.style.cursor = 'crosshair'; else if (mode === 'eyedropper') canvas.style.cursor = 'cell'; else canvas.style.cursor = 'crosshair'; draw(); persist(); } function selectionArray() { return Array.from(selectedIds); } function selectionBalloons() { const set = new Set(selectedIds); return balloons.filter(b => set.has(b.id)); } function setSelection(ids, { additive = false } = {}) { if (!additive) selectedIds.clear(); ids.forEach(id => selectedIds.add(id)); updateSelectButtons(); draw(); } function primarySelection() { const first = selectedIds.values().next(); return first.done ? null : first.value; } function clearSelection() { selectedIds.clear(); updateSelectButtons(); draw(); } function updateSelectButtons() { const has = selectedIds.size > 0; if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has; if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has; if (selectedSizeInput) { selectedSizeInput.disabled = !has; selectedSizeInput.min = '5'; selectedSizeInput.max = '32'; selectedSizeInput.step = '0.5'; } if (bringForwardBtn) bringForwardBtn.disabled = !has; if (sendBackwardBtn) sendBackwardBtn.disabled = !has; if (applyColorBtn) applyColorBtn.disabled = !has; if (selectedSizeInput && selectedSizeLabel) { if (has) { const first = balloons.find(bb => selectedIds.has(bb.id)); if (first) { const diam = radiusPxToInches(first.radius); selectedSizeInput.value = String(Math.min(32, Math.max(5, diam))); selectedSizeLabel.textContent = fmtInches(diam); } } else { selectedSizeLabel.textContent = '0"'; } } } // ====== Pointer Events ====== let pointerDown = false; let isDragging = false; let dragStartPos = { x: 0, y: 0 }; let initialBalloonPos = { x: 0, y: 0 }; let eraseChanged = false; let dragMoved = false; let resizeChanged = false; let resizeSaveTimer = null; let erasingActive = false; let drawPending = false; let dragOffsets = []; let marqueeActive = false; let marqueeStart = { x: 0, y: 0 }; let marqueeEnd = { x: 0, y: 0 }; function requestDraw() { if (drawPending) return; drawPending = true; requestAnimationFrame(() => { drawPending = false; draw(); }); } // Avoid touch scrolling stealing pointer events. canvas.style.touchAction = 'none'; function handlePointerDownInternal(pos, pointerType) { if (pointerType === 'touch') evtStats.lastType = 'touch'; mouseInside = true; mousePos = pos; if (mode === 'erase') { pointerDown = true; evtStats.down += 1; erasingActive = true; eraseChanged = eraseAt(mousePos.x, mousePos.y); return; } if (mode === 'garland') { pointerDown = true; evtStats.down += 1; garlandPath = [{ ...mousePos }]; requestDraw(); return; } if (mode === 'select') { pointerDown = true; const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y); if (clickedIdx !== -1) { const b = balloons[clickedIdx]; if (event?.shiftKey) { if (selectedIds.has(b.id)) selectedIds.delete(b.id); else selectedIds.add(b.id); } else if (!selectedIds.has(b.id)) { selectedIds.clear(); selectedIds.add(b.id); } updateSelectButtons(); draw(); isDragging = true; dragStartPos = { ...mousePos }; dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y })); dragMoved = false; } else { if (!event?.shiftKey) selectedIds.clear(); updateSelectButtons(); marqueeActive = true; marqueeStart = { ...mousePos }; marqueeEnd = { ...mousePos }; requestDraw(); } return; } // draw mode: add pointerDown = true; evtStats.down += 1; addBalloon(mousePos.x, mousePos.y); evtStats.addBalloon += 1; addedThisPointer = true; } canvas.addEventListener('pointerdown', e => { if (e.pointerType === 'touch') e.preventDefault(); canvas.setPointerCapture?.(e.pointerId); mouseInside = true; mousePos = getMousePos(e); evtStats.down += 1; evtStats.lastType = e.pointerType || ''; if (e.altKey || mode === 'eyedropper') { pickColorAt(mousePos.x, mousePos.y); if (mode === 'eyedropper') setMode('draw'); // Auto-switch back? or stay? Let's stay for multi-pick, or switch for quick workflow. Let's switch back for now. return; } if (mode === 'erase') { pointerDown = true; erasingActive = true; eraseChanged = eraseAt(mousePos.x, mousePos.y); return; } if (mode === 'garland') { pointerDown = true; garlandPath = [{ ...mousePos }]; requestDraw(); return; } if (mode === 'select') { pointerDown = true; const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y); if (clickedIdx !== -1) { const b = balloons[clickedIdx]; if (e.shiftKey) { if (selectedIds.has(b.id)) selectedIds.delete(b.id); else selectedIds.add(b.id); } else if (!selectedIds.has(b.id)) { selectedIds.clear(); selectedIds.add(b.id); } updateSelectButtons(); draw(); isDragging = true; dragStartPos = { ...mousePos }; dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y })); dragMoved = false; } else { if (!e.shiftKey) selectedIds.clear(); updateSelectButtons(); marqueeActive = true; marqueeStart = { ...mousePos }; marqueeEnd = { ...mousePos }; requestDraw(); } return; } // draw mode: add pointerDown = true; evtStats.down += 1; evtStats.lastType = e.pointerType || ''; addBalloon(mousePos.x, mousePos.y); evtStats.addBalloon += 1; addedThisPointer = true; }, { passive: false }); canvas.addEventListener('pointermove', e => { mouseInside = true; mousePos = getMousePos(e); if (mode === 'select') { if (isDragging && selectedIds.size) { const dx = mousePos.x - dragStartPos.x; const dy = mousePos.y - dragStartPos.y; dragOffsets.forEach(off => { const b = balloons.find(bb => bb.id === off.id); if (b) { b.x = mousePos.x + off.dx; b.y = mousePos.y + off.dy; } }); requestDraw(); dragMoved = true; } else if (marqueeActive) { marqueeEnd = { ...mousePos }; requestDraw(); } else { const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y); canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default'; } } if (mode === 'garland') { if (pointerDown) { const last = garlandPath[garlandPath.length - 1]; if (!last || Math.hypot(mousePos.x - last.x, mousePos.y - last.y) >= GARLAND_POINT_STEP) { garlandPath.push({ ...mousePos }); requestDraw(); } } return; } if (mode === 'erase') { if (pointerDown) { eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged; if (eraseChanged) requestDraw(); } else { requestDraw(); } } }, { passive: true }); canvas.addEventListener('pointerenter', () => { mouseInside = true; if (mode === 'erase') requestDraw(); }); function commitGarlandPath() { if (garlandPath.length > 1) addGarlandFromPath(garlandPath); garlandPath = []; requestDraw(); lastCommitMode = mode; } canvas.addEventListener('pointerup', e => { pointerDown = false; isDragging = false; evtStats.up += 1; evtStats.lastType = e.pointerType || ''; if (mode === 'garland') { commitGarlandPath(); canvas.releasePointerCapture?.(e.pointerId); return; } if (mode === 'select' && dragMoved) { refreshAll(); pushHistory(); } if (mode === 'select' && marqueeActive) { const minX = Math.min(marqueeStart.x, marqueeEnd.x); const maxX = Math.max(marqueeStart.x, marqueeEnd.x); const minY = Math.min(marqueeStart.y, marqueeEnd.y); const maxY = Math.max(marqueeStart.y, marqueeEnd.y); const ids = balloons.filter(b => b.x >= minX && b.x <= maxX && b.y >= minY && b.y <= maxY).map(b => b.id); if (!e.shiftKey) selectedIds.clear(); ids.forEach(id => selectedIds.add(id)); marqueeActive = false; updateSelectButtons(); requestDraw(); } if (mode === 'erase' && eraseChanged) { refreshAll(); // update palette/persist once after the stroke pushHistory(); } if (mode === 'draw' && !addedThisPointer) { addBalloon(mousePos.x, mousePos.y); evtStats.addBalloon += 1; lastAddStatus = 'balloon:up'; } addedThisPointer = false; erasingActive = false; dragMoved = false; eraseChanged = false; marqueeActive = false; canvas.releasePointerCapture?.(e.pointerId); requestDraw(); }, { passive: true }); canvas.addEventListener('pointerleave', () => { mouseInside = false; marqueeActive = false; if (mode === 'garland') { pointerDown = false; commitGarlandPath(); } if (mode === 'draw') addedThisPointer = false; if (mode === 'erase') requestDraw(); }, { passive: true }); canvas.addEventListener('pointercancel', (e) => { pointerDown = false; evtStats.cancel += 1; evtStats.lastType = e.pointerType || ''; if (mode === 'draw') addedThisPointer = false; if (mode === 'garland') commitGarlandPath(); }, { passive: true }); const commitIfGarland = () => { if (mode === 'garland' && garlandPath.length > 1) { commitGarlandPath(); } else if (mode === 'garland') { garlandPath = []; requestDraw(); } }; window.addEventListener('pointerup', () => { pointerDown = false; if (mode === 'draw') addedThisPointer = false; commitIfGarland(); }, { passive: true }); const touchEndCommit = () => { pointerDown = false; evtStats.touchEnd += 1; evtStats.lastType = 'touch'; if (mode === 'draw') addedThisPointer = false; commitIfGarland(); }; window.addEventListener('touchend', touchEndCommit, { passive: true }); window.addEventListener('touchcancel', touchEndCommit, { 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(); const lum = luminance(meta.hex || b.color); if (lum > 0.6) { const strength = clamp01((lum - 0.6) / 0.4); // more shadow for lighter colors ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`; ctx.shadowBlur = 4 + 4 * strength; ctx.shadowOffsetY = 1 + 2 * strength; } ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.35, 0.5 / view.s); ctx.stroke(); } 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(); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.35, 0.5 / view.s); ctx.stroke(); } 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(); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.35, 0.5 / view.s); ctx.stroke(); } ctx.shadowBlur = 0; } if (isShineEnabled) { const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color); 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 = shineFill; if (shineStroke) { ctx.strokeStyle = shineStroke; ctx.lineWidth = 1.5; ctx.stroke(); } ctx.fill(); ctx.restore(); } }); // garland path preview if (mode === 'garland' && garlandPath.length) { ctx.save(); ctx.lineWidth = 1.5 / view.s; ctx.strokeStyle = 'rgba(59,130,246,0.7)'; ctx.setLineDash([8 / view.s, 6 / view.s]); ctx.beginPath(); garlandPath.forEach((p, idx) => { if (idx === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); ctx.stroke(); ctx.setLineDash([]); const previewNodes = computeGarlandNodes(garlandPath).sort((a, b) => b.radius - a.radius); ctx.strokeStyle = 'rgba(59,130,246,0.28)'; previewNodes.forEach(n => { ctx.beginPath(); ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); ctx.stroke(); }); ctx.restore(); } // selection ring(s) if (selectedIds.size) { ctx.save(); selectedIds.forEach(id => { const b = balloons.find(bb => bb.id === id); if (!b) return; ctx.beginPath(); ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2); ctx.lineWidth = 4 / view.s; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.stroke(); ctx.lineWidth = 2 / view.s; ctx.strokeStyle = '#3b82f6'; ctx.stroke(); }); ctx.restore(); } // marquee preview if (mode === 'select' && marqueeActive) { ctx.save(); ctx.setLineDash([6 / view.s, 4 / view.s]); ctx.lineWidth = 1.5 / view.s; ctx.strokeStyle = 'rgba(59,130,246,0.8)'; ctx.fillStyle = 'rgba(59,130,246,0.12)'; const x = Math.min(marqueeStart.x, marqueeEnd.x); const y = Math.min(marqueeStart.y, marqueeEnd.y); const w = Math.abs(marqueeStart.x - marqueeEnd.x); const h = Math.abs(marqueeStart.y - marqueeEnd.y); ctx.strokeRect(x, y, w, h); ctx.fillRect(x, y, w, h); 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(); } // eyedropper preview if (mode === 'eyedropper' && mouseInside) { ctx.save(); ctx.beginPath(); ctx.arc(mousePos.x, mousePos.y, 10 / view.s, 0, Math.PI * 2); ctx.lineWidth = 2 / view.s; ctx.strokeStyle = '#fff'; ctx.stroke(); ctx.lineWidth = 1 / view.s; ctx.strokeStyle = '#000'; ctx.stroke(); ctx.restore(); } ctx.restore(); // Debug overlay const dbg = [ `mode:${mode}`, `balloons:${balloons.length}`, `garlandLen:${garlandPath.length}`, `pointerDown:${pointerDown}`, `lastCommit:${lastCommitMode || '-'}`, `lastAdd:${lastAddStatus || '-'}`, `dpr:${dpr.toFixed(2)} s:${view.s.toFixed(2)}`, `down:${evtStats.down} up:${evtStats.up} cancel:${evtStats.cancel} touchEnd:${evtStats.touchEnd} type:${evtStats.lastType}` ]; if (debugOverlay) debugOverlay.textContent = dbg.join(' | '); } 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, view, usedSortDesc, garlandDensity, garlandMainIdx, garlandAccentIdx, isBorderEnabled }; 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 (s.view && typeof s.view.s === 'number') { view = s.view; clampViewScale(); } if (typeof s.usedSortDesc === 'boolean') { usedSortDesc = s.usedSortDesc; if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most'; } if (typeof s.garlandDensity === 'number') { garlandDensity = clamp(s.garlandDensity, 0.6, 1.6); if (garlandDensityInput) garlandDensityInput.value = garlandDensity; if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1); } if (Array.isArray(s.garlandMainIdx)) { garlandMainIdx = s.garlandMainIdx.slice(0, 4).map(v => Number(v) || -1); while (garlandMainIdx.length < 4) garlandMainIdx.push(-1); } if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx; if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled; if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled; updateCurrentColorChip(); } catch {} } loadAppState(); resetHistory(); // establish initial history state for undo/redo controls // ====== 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('button'); sw.type = 'button'; sw.className = 'swatch'; sw.setAttribute('aria-label', c.name); 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(); updateCurrentColorChip(); 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 updateCurrentColorChip() { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; const updateChip = (chipId, labelId, { showLabel = true } = {}) => { const chip = document.getElementById(chipId); const label = document.getElementById(labelId); if (!chip || !meta) return; if (meta.image) { const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); chip.style.backgroundImage = `url("${meta.image}")`; chip.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`; chip.style.backgroundPosition = `${fx * 100}% ${fy * 100}%`; chip.style.backgroundColor = '#fff'; } else { chip.style.backgroundImage = 'none'; chip.style.backgroundColor = meta.hex || '#fff'; } if (label) { label.textContent = showLabel ? (meta.name || meta.hex || 'Current') : ''; label.title = meta.name || meta.hex || 'Current'; } chip.title = meta.name || meta.hex || 'Current'; }; updateChip('current-color-chip', 'current-color-label', { showLabel: true }); updateChip('current-color-chip-global', 'current-color-label-global', { showLabel: false }); } window.organic = { getColor: () => selectedColorIdx, updateCurrentColorChip, setColor: (idx) => { if (!Number.isInteger(idx)) return; selectedColorIdx = idx; renderAllowedPalette?.(); renderUsedPalette?.(); updateCurrentColorChip?.(); persist?.(); }, buildOrganicSvgPayload, }; 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('button'); sw.type = 'button'; sw.className = 'swatch'; const name = item.name || NAME_BY_HEX.get(item.hex) || item.hex; sw.setAttribute('aria-label', `${name} - Count: ${item.count}`); 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 = `${name} — ${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 buildBalloon(meta, x, y, radius) { return { x, y, radius, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: crypto.randomUUID() }; } function addBalloon(x, y) { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (balloons.length >= MAX_BALLOONS) { lastAddStatus = 'limit'; showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; } balloons.push(buildBalloon(meta, x, y, currentRadius)); lastAddStatus = 'balloon'; ensureVisibleAfterAdd(balloons[balloons.length - 1]); refreshAll(); pushHistory(); } function garlandSeed(path) { let h = 2166136261 >>> 0; path.forEach(p => { h ^= Math.round(p.x * 10) + 0x9e3779b9; h = Math.imul(h, 16777619); h ^= Math.round(p.y * 10); h = Math.imul(h, 16777619); }); return h >>> 0 || 1; } function computeGarlandNodes(path) { if (!Array.isArray(path) || path.length < 2) return []; const baseRadius = inchesToRadiusPx(GARLAND_BASE_DIAM); const fillerRadii = GARLAND_FILLER_DIAMS.map(inchesToRadiusPx); const accentRadius = inchesToRadiusPx(GARLAND_ACCENT_DIAM); const spacing = Math.max(10, baseRadius * (GARLAND_SPACING_RATIO / garlandDensity)); const nodes = []; let carry = 0; const rng = makeSeededRng(garlandSeed(path)); for (let i = 0; i < path.length - 1; i++) { const a = path[i]; const b = path[i + 1]; const dx = b.x - a.x; const dy = b.y - a.y; const segLen = Math.hypot(dx, dy); if (segLen < 1) continue; let dist = carry; const nx = segLen > 0 ? (dy / segLen) : 0; const ny = segLen > 0 ? (-dx / segLen) : 0; while (dist <= segLen) { const t = dist / segLen; const px = a.x + dx * t; const py = a.y + dy * t; const side = rng() > 0.5 ? 1 : -1; const wobble = (rng() * 2 - 1) * baseRadius * GARLAND_WOBBLE_RATIO; const sizeJitter = 1 + (rng() * 2 - 1) * GARLAND_SIZE_JITTER; const r = clamp(baseRadius * sizeJitter, baseRadius * 0.75, baseRadius * 1.35); const baseX = px + nx * wobble; const baseY = py + ny * wobble; nodes.push({ x: baseX, y: baseY, radius: r, type: 'base' }); // filler balloons hugging the base to thicken the line const fillerR = fillerRadii[Math.floor(rng() * fillerRadii.length)] || baseRadius * 0.7; const offset1 = r * 0.7; nodes.push({ x: baseX + nx * offset1 * side, y: baseY + ny * offset1 * side, radius: fillerR * (0.9 + rng() * 0.2), type: 'filler' }); const tangentSide = side * (rng() > 0.5 ? 1 : -1); const offset2 = r * 0.5; nodes.push({ x: baseX + (-ny) * offset2 * tangentSide, y: baseY + (nx) * offset2 * tangentSide, radius: fillerR * (0.8 + rng() * 0.25), type: 'filler' }); // Tight cluster of three 5" balloons, slightly more open { const clusterCenterX = baseX + nx * r * 0.25 * side; const clusterCenterY = baseY + ny * r * 0.25 * side; const baseAng = rng() * Math.PI * 2; const mags = [0.7, 0.95, 0.8].map(m => m * accentRadius); const angs = [baseAng, baseAng + (2 * Math.PI / 3), baseAng + (4 * Math.PI / 3)]; for (let c = 0; c < 3; c++) { const mag = mags[c] * (0.98 + rng() * 0.08); const jitterAng = angs[c] + (rng() * 0.25 - 0.125); nodes.push({ x: clusterCenterX + Math.cos(jitterAng) * mag, y: clusterCenterY + Math.sin(jitterAng) * mag, radius: accentRadius * (0.88 + rng() * 0.15), type: 'accent' }); } } dist += spacing; } carry = dist - segLen; } return nodes; } function addGarlandFromPath(path) { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (!meta) return; const nodes = computeGarlandNodes(path).sort((a, b) => b.radius - a.radius); // draw larger first so small accents sit on top if (!nodes.length) { lastAddStatus = 'garland:none'; return; } const available = Math.max(0, MAX_BALLOONS - balloons.length); const limitedNodes = available ? nodes.slice(0, available) : []; if (!limitedNodes.length) { lastAddStatus = 'limit'; showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; } const newIds = []; const rng = makeSeededRng(garlandSeed(path) + 101); const metaFromIdx = idx => { const m = FLAT_COLORS[idx]; return m ? m : meta; }; const pickMainMeta = () => { const choices = garlandMainIdx.filter(v => Number.isFinite(v) && v >= 0 && FLAT_COLORS[v]); if (!choices.length) return meta; const pick = choices.length === 1 ? choices[0] : choices[Math.floor(rng() * choices.length)]; return metaFromIdx(pick); }; const accentMeta = (garlandAccentIdx >= 0 && FLAT_COLORS[garlandAccentIdx]) ? FLAT_COLORS[garlandAccentIdx] : metaFromIdx(garlandMainIdx.find(v => v >= 0)); limitedNodes.forEach(n => { const m = n.type === 'accent' ? accentMeta : pickMainMeta(); const b = buildBalloon(m, n.x, n.y, n.radius); balloons.push(b); newIds.push(b.id); }); lastAddStatus = `garland:${newIds.length}`; if (newIds.length) { selectedIds.clear(); updateSelectButtons(); } refreshAll(); pushHistory(); } 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); selectedIds.clear(); if (i !== -1) selectedIds.add(balloons[i].id); updateSelectButtons(); draw(); } function moveSelected(dx, dy) { const sel = selectionBalloons(); if (!sel.length) return; sel.forEach(b => { b.x += dx; b.y += dy; }); refreshAll(); pushHistory(); } function resizeSelected(newDiamInches) { const sel = selectionBalloons(); if (!sel.length) return; const diam = clamp(newDiamInches, 5, 32); const newRadius = inchesToRadiusPx(diam); sel.forEach(b => { b.radius = newRadius; }); refreshAll(); updateSelectButtons(); resizeChanged = true; clearTimeout(resizeSaveTimer); resizeSaveTimer = setTimeout(() => { if (resizeChanged) { pushHistory(); resizeChanged = false; } }, 200); } function bringSelectedForward() { const sel = selectionArray(); if (!sel.length) return; const set = new Set(sel); const kept = balloons.filter(b => !set.has(b.id)); const moving = balloons.filter(b => set.has(b.id)); balloons = kept.concat(moving); refreshAll({ autoFit: true }); pushHistory(); } function sendSelectedBackward() { const sel = selectionArray(); if (!sel.length) return; const set = new Set(sel); const moving = balloons.filter(b => set.has(b.id)); const kept = balloons.filter(b => !set.has(b.id)); balloons = moving.concat(kept); refreshAll({ autoFit: true }); pushHistory(); } function applyColorToSelected() { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (!meta) return; let changed = false; selectionBalloons().forEach(b => { b.color = meta.hex; b.image = meta.image || null; b.colorIdx = meta._idx; changed = true; }); if (!changed) return; refreshAll(); pushHistory(); } function deleteSelected() { if (!selectedIds.size) return; balloons = balloons.filter(b => !selectedIds.has(b.id)); selectedIds.clear(); updateSelectButtons(); refreshAll({ autoFit: true }); pushHistory(); } function duplicateSelected() { const sel = selectionBalloons(); if (!sel.length) return; const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() })); copies.forEach(c => balloons.push(c)); selectedIds = new Set(copies.map(c => c.id)); refreshAll({ autoFit: true }); updateSelectButtons(); pushHistory(); } function eraseAt(x, y) { const before = balloons.length; balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius); const removed = balloons.length !== before; if (selectedIds.size) { const set = new Set(balloons.map(b => b.id)); let changed = false; selectedIds.forEach(id => { if (!set.has(id)) { selectedIds.delete(id); changed = true; } }); if (changed) updateSelectButtons(); } if (removed && !erasingActive) requestDraw(); return removed; } 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(); updateCurrentColorChip(); } } 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; sharedDownload?.(href, finalName); } 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); const loaded = 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() }; }) : []; balloons = loaded.slice(0, MAX_BALLOONS); selectedIds.clear(); updateSelectButtons(); refreshAll({ refit: true }); resetHistory(); persist(); if (loaded.length > MAX_BALLOONS) { showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`); } } catch { showModal('Error parsing JSON file.'); } }; reader.readAsText(file); } // ====== Export helpers ====== let lastActiveTab = '#tab-organic'; const getImageHref = getImageHrefShared; function setImageHref(el, val) { el.setAttribute('href', val); el.setAttributeNS(XLINK_NS, 'xlink:href', val); } async function embedImagesInSvg(svgEl) { const images = Array.from(svgEl.querySelectorAll('image')); const hrefs = [...new Set(images.map(getImageHref).filter(h => h && !h.startsWith('data:')))]; const urlMap = new Map(); await Promise.all(hrefs.map(async (href) => { urlMap.set(href, await imageUrlToDataUrl(href)); })); images.forEach(img => { const orig = getImageHref(img); const val = urlMap.get(orig); if (val) setImageHref(img, val); }); return svgEl; } async function buildOrganicSvgPayload() { if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.'); const uniqueImageUrls = [...new Set(balloons.map(b => b.image).filter(Boolean))]; const dataUrlMap = new Map(); await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url)))); const bounds = balloonsBounds(); const pad = 120; // extra room to avoid clipping drop-shadows and outlines const width = bounds.w + pad * 2; const height = bounds.h + pad * 2; const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' '); let defs = ''; let elements = ''; const patterns = new Map(); const shadowFilters = new Map(); const ensureShadowFilter = (dx, dy, blurPx, alpha) => { const key = `${dx}|${dy}|${blurPx}|${alpha}`; if (!shadowFilters.has(key)) { const id = `shadow-${shadowFilters.size}`; const stdDev = Math.max(0.01, blurPx * 0.5); const clampedAlpha = clamp01(alpha); const flood = ``; const blur = ``; const offset = ``; const composite = ``; const merge = ``; defs += `${flood}${blur}${offset}${composite}${merge}`; shadowFilters.set(key, id); } return shadowFilters.get(key); }; const shineShadowId = ensureShadowFilter(0, 0, 3, 0.1); balloons.forEach(b => { const meta = FLAT_COLORS[b.colorIdx] || {}; let fill = b.color; if (b.image) { const patternKey = `${b.colorIdx}|${b.image}`; const imageHref = dataUrlMap.get(b.image); if (!patterns.has(patternKey) && imageHref) { const patternId = `p${patterns.size}`; patterns.set(patternKey, 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); const imgW = zoom, imgH = zoom; const imgX = 0.5 - (fx * zoom); const imgY = 0.5 - (fy * zoom); defs += ` `; } if (patterns.has(patternKey)) fill = `url(#${patterns.get(patternKey)})`; } let filterAttr = ''; if (b.image) { const lum = luminance(meta.hex || b.color); if (lum > 0.6) { const strength = clamp01((lum - 0.6) / 0.4); const blur = 4 + 4 * strength; const offsetY = 1 + 2 * strength; const alpha = 0.05 + 0.07 * strength; const id = ensureShadowFilter(0, offsetY, blur, alpha); filterAttr = ` filter="url(#${id})"`; } } else { const id = ensureShadowFilter(0, 0, 10, 0.2); filterAttr = ` filter="url(#${id})"`; } const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`; elements += ``; 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 { fill: shineFill, stroke: shineStroke } = shineStyle(b.color); const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : ''; const shineFilter = shineShadowId ? ` filter="url(#${shineShadowId})"` : ''; elements += ``; } }); const svgString = ` ${defs} ${elements} `; return { svgString, width, height }; } 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); const loaded = 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); balloons = loaded.slice(0, MAX_BALLOONS); refreshAll({ refit: true }); resetHistory(); persist(); updateCurrentColorChip(); if (loaded.length > MAX_BALLOONS) { showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`); } else { 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; clampViewScale(); 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(VIEW_MAX_SCALE, isFinite(sFit) && sFit > 0 ? sFit : 1); clampViewScale(); 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(VIEW_MIN_SCALE, sNeeded); } clampViewScale(); 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; return (dx !== 0 || dy !== 0); } // ====== Refresh & Events ====== function refreshAll({ refit = false, autoFit = false } = {}) { if (refit) fitView(); else if (autoFit) fitView(); draw(); renderUsedPalette(); persist(); if(window.updateExportButtonVisibility) window.updateExportButtonVisibility(); } // --- UI bindings --- modalCloseBtn?.addEventListener('click', hideModal); toolDrawBtn?.addEventListener('click', () => setMode('draw')); toolGarlandBtn?.addEventListener('click', () => setMode('garland')); 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(); }); toggleBorderCheckbox?.addEventListener('change', e => { isBorderEnabled = !!e.target.checked; draw(); persist(); }); garlandDensityInput?.addEventListener('input', e => { garlandDensity = clamp(parseFloat(e.target.value) || 1, 0.6, 1.6); if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1); if (mode === 'garland') requestDraw(); persist(); }); const handleGarlandColorChange = () => { updateGarlandSwatches(); persist(); if (mode === 'garland') requestDraw(); }; garlandColorMain1Sel?.addEventListener('change', e => { garlandMainIdx[0] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorMain2Sel?.addEventListener('change', e => { garlandMainIdx[1] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorMain3Sel?.addEventListener('change', e => { garlandMainIdx[2] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorMain4Sel?.addEventListener('change', e => { garlandMainIdx[3] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorAccentSel?.addEventListener('change', e => { garlandAccentIdx = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); deleteSelectedBtn?.addEventListener('click', deleteSelected); duplicateSelectedBtn?.addEventListener('click', duplicateSelected); nudgeSelectedBtns.forEach(btn => btn.addEventListener('click', () => { const dx = Number(btn.dataset.dx || 0); const dy = Number(btn.dataset.dy || 0); moveSelected(dx, dy); })); selectedSizeInput?.addEventListener('input', e => { resizeSelected(parseFloat(e.target.value) || 0); }); selectedSizeInput?.addEventListener('pointerdown', () => { resizeChanged = false; clearTimeout(resizeSaveTimer); }); selectedSizeInput?.addEventListener('pointerup', () => { clearTimeout(resizeSaveTimer); if (resizeChanged) { pushHistory(); resizeChanged = false; } }); bringForwardBtn?.addEventListener('click', bringSelectedForward); sendBackwardBtn?.addEventListener('click', sendSelectedBackward); applyColorBtn?.addEventListener('click', applyColorToSelected); fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true })); 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 === 'g' || e.key === 'G') setMode('garland'); else if (e.key === 'Escape') { if (selectedIds.size) { clearSelection(); } else if (mode !== 'draw') { setMode('draw'); } } else if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedIds.size) { e.preventDefault(); deleteSelected(); } } else if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); undo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) { e.preventDefault(); redo(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') { e.preventDefault(); duplicateSelected(); } }); async function confirmAndClear() { let ok = true; if (window.Swal) { const res = await Swal.fire({ title: 'Start fresh?', text: 'This will remove all balloons from the canvas.', icon: 'warning', showCancelButton: true, confirmButtonText: 'Yes, clear', cancelButtonText: 'Cancel' }); ok = res.isConfirmed; } else { ok = window.confirm('Start fresh? This will remove all balloons from the canvas.'); } if (!ok) return; balloons = []; selectedIds.clear(); garlandPath = []; updateSelectButtons(); refreshAll({ refit: true }); pushHistory(); } clearCanvasBtn?.addEventListener('click', confirmAndClear); clearCanvasBtnTop?.addEventListener('click', confirmAndClear); 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); }); } function populateGarlandColorSelects() { const addOpts = sel => { if (!sel) return; sel.innerHTML = ''; const noneOpt = document.createElement('option'); noneOpt.value = '-1'; noneOpt.textContent = 'None (use active color)'; sel.appendChild(noneOpt); FLAT_COLORS.forEach((c, idx) => { const opt = document.createElement('option'); opt.value = String(idx); opt.textContent = c.name || c.hex; sel.appendChild(opt); }); }; addOpts(garlandColorMain1Sel); addOpts(garlandColorMain2Sel); addOpts(garlandColorMain3Sel); addOpts(garlandColorMain4Sel); addOpts(garlandColorAccentSel); if (garlandColorMain1Sel) garlandColorMain1Sel.value = String(garlandMainIdx[0] ?? -1); if (garlandColorMain2Sel) garlandColorMain2Sel.value = String(garlandMainIdx[1] ?? -1); if (garlandColorMain3Sel) garlandColorMain3Sel.value = String(garlandMainIdx[2] ?? -1); if (garlandColorMain4Sel) garlandColorMain4Sel.value = String(garlandMainIdx[3] ?? -1); if (garlandColorAccentSel) garlandColorAccentSel.value = String(garlandAccentIdx ?? -1); updateGarlandSwatches(); } function updateGarlandSwatches() { const setSw = (sw, idx) => { if (!sw) return; const meta = idx >= 0 ? FLAT_COLORS[idx] : null; if (meta?.image) { sw.style.backgroundImage = `url("${meta.image}")`; sw.style.backgroundColor = meta.hex || '#fff'; sw.style.backgroundSize = 'cover'; } else { sw.style.backgroundImage = 'none'; sw.style.backgroundColor = meta?.hex || '#f1f5f9'; } }; setSw(garlandSwatchMain1, garlandMainIdx[0]); setSw(garlandSwatchMain2, garlandMainIdx[1]); setSw(garlandSwatchMain3, garlandMainIdx[2]); setSw(garlandSwatchMain4, garlandMainIdx[3]); setSw(garlandSwatchAccent, garlandAccentIdx); } 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) { pushHistory(); 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); }); toggleShineCheckbox?.addEventListener('change', e => { const on = !!e.target.checked; window.syncAppShine(on); }); mode = 'draw'; // force default tool on load renderAllowedPalette(); resizeCanvas(); loadFromUrl(); renderUsedPalette(); // Initialize wall designer if available (wall.js sets this) window.WallDesigner?.init?.(); setMode('draw'); updateSelectButtons(); populateReplaceTo(); populateGarlandColorSelects(); // default to canvas-first on mobile; no expansion toggles remain // 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 (toggleShineCheckbox) toggleShineCheckbox.checked = isShineEnabled; // 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 wallSection = document.getElementById('tab-wall'); const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn'); const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1'; // Tab/mobile logic lives in script.js }); })();