// 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 = '