// 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'; const BALLOON_MASK_URL = 'images/balloon-mask.svg'; const BALLOON_24_MASK_URL = 'images/24-balloon_mask.svg'; const WEIGHT_MASK_URL = 'images/weight-mask.svg'; const WEIGHT_IMAGE_URL = 'images/weight.webp'; const WEIGHT_VISUAL_SCALE = 0.56; const HELIUM_CUFT_BY_SIZE = { 11: 0.5, 18: 2, 24: 5 }; const HELIUM_CUFT_BASE_SIZE = 11; const HELIUM_CUFT_BASE_VALUE = 0.5; let balloonMaskPath = null; let balloonMaskPathData = ''; let balloonMaskViewBox = { x: 0, y: 0, w: 1, h: 1 }; let balloonMaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; let balloonMaskLoaded = false; let balloonMaskLoading = false; let balloon24MaskPath = null; let balloon24MaskPathData = ''; let balloon24MaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; let balloon24MaskLoaded = false; let balloon24MaskLoading = false; let balloonMaskDrawFailed = false; let weightMaskPath = null; let weightMaskPathData = ''; let weightMaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; let weightMaskLoaded = false; let weightMaskLoading = false; // ====== 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 rotateSelectedLeftBtn = document.getElementById('rotate-selected-left'); const rotateSelectedResetBtn = document.getElementById('rotate-selected-reset'); const rotateSelectedRightBtn = document.getElementById('rotate-selected-right'); const ribbonLengthDownBtn = document.getElementById('ribbon-length-down'); const ribbonLengthUpBtn = document.getElementById('ribbon-length-up'); const ribbonAttachWeightBtn = document.getElementById('ribbon-attach-weight'); 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 garlandMainChips = document.getElementById('garland-main-chips'); const garlandAddColorBtn = document.getElementById('garland-add-color'); const garlandAccentChip = document.getElementById('garland-accent-chip'); const garlandAccentClearBtn = document.getElementById('garland-accent-clear'); const garlandControls = document.getElementById('garland-controls'); // Optional dropdowns (may not be present in current layout) const garlandColorMain1Sel = document.getElementById('garland-color-main-1'); const garlandColorMain2Sel = document.getElementById('garland-color-main-2'); const garlandColorMain3Sel = document.getElementById('garland-color-main-3'); const garlandColorMain4Sel = document.getElementById('garland-color-main-4'); const garlandColorAccentSel = document.getElementById('garland-color-accent'); const updateGarlandSwatches = () => {}; // stub for layouts without dropdown swatches const sizePresetGroup = document.getElementById('size-preset-group'); const heliumPlacementRow = document.getElementById('helium-placement-row'); const heliumPlaceBalloonBtn = document.getElementById('helium-place-balloon'); const heliumPlaceCurlBtn = document.getElementById('helium-place-curl'); const heliumPlaceRibbonBtn = document.getElementById('helium-place-ribbon'); const heliumPlaceWeightBtn = document.getElementById('helium-place-weight'); 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'); const replaceFromChip = document.getElementById('replace-from-chip'); const replaceToChip = document.getElementById('replace-to-chip'); const replaceCountLabel = document.getElementById('replace-count'); // 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;display:none;'; 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 const loadBalloonMask = async () => { if (balloonMaskLoaded || balloonMaskLoading) return; balloonMaskLoading = true; try { const res = await fetch(BALLOON_MASK_URL, { cache: 'force-cache' }); const text = await res.text(); let pathD = ''; let viewBoxRaw = ''; try { const doc = new DOMParser().parseFromString(text, 'image/svg+xml'); const svgEl = doc.querySelector('svg'); const pathEl = doc.querySelector('path[d]'); pathD = pathEl?.getAttribute('d') || ''; viewBoxRaw = svgEl?.getAttribute('viewBox') || ''; } catch {} // Fallback if DOM parsing fails for any reason if (!pathD) { const dMatch = text.match(/]*\sd="([^"]+)"/i); pathD = dMatch?.[1] || ''; } if (!viewBoxRaw) { const vbMatch = text.match(/viewBox="([^"]+)"/i); viewBoxRaw = vbMatch?.[1] || ''; } if (pathD) { balloonMaskPath = new Path2D(pathD); balloonMaskPathData = pathD; } if (pathD) { try { const ns = 'http://www.w3.org/2000/svg'; const svgTmp = document.createElementNS(ns, 'svg'); const pTmp = document.createElementNS(ns, 'path'); pTmp.setAttribute('d', pathD); svgTmp.setAttribute('xmlns', ns); svgTmp.setAttribute('width', '0'); svgTmp.setAttribute('height', '0'); svgTmp.style.position = 'absolute'; svgTmp.style.left = '-9999px'; svgTmp.style.top = '-9999px'; svgTmp.style.opacity = '0'; svgTmp.appendChild(pTmp); document.body.appendChild(svgTmp); const bb = pTmp.getBBox(); svgTmp.remove(); if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) { balloonMaskBounds = { x: bb.x, y: bb.y, w: bb.width, h: bb.height, cx: bb.x + bb.width / 2, cy: bb.y + bb.height / 2 }; } } catch {} } if (viewBoxRaw) { const parts = viewBoxRaw.split(/\s+/).map(Number); if (parts.length === 4 && parts.every(Number.isFinite)) { balloonMaskViewBox = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }; } } balloonMaskLoaded = !!balloonMaskPath; if (balloonMaskLoaded) requestDraw(); } catch (err) { console.warn('Failed to load balloon mask:', err); } finally { balloonMaskLoading = false; } }; const loadBalloon24Mask = async () => { if (balloon24MaskLoaded || balloon24MaskLoading) return; balloon24MaskLoading = true; try { const res = await fetch(BALLOON_24_MASK_URL, { cache: 'force-cache' }); const text = await res.text(); let pathD = ''; try { const doc = new DOMParser().parseFromString(text, 'image/svg+xml'); const pathEl = doc.querySelector('path[d]'); pathD = pathEl?.getAttribute('d') || ''; } catch {} if (!pathD) { const dMatch = text.match(/]*\sd="([^"]+)"/i); pathD = dMatch?.[1] || ''; } if (pathD) { balloon24MaskPath = new Path2D(pathD); balloon24MaskPathData = pathD; } if (pathD) { try { const ns = 'http://www.w3.org/2000/svg'; const svgTmp = document.createElementNS(ns, 'svg'); const pTmp = document.createElementNS(ns, 'path'); pTmp.setAttribute('d', pathD); svgTmp.setAttribute('xmlns', ns); svgTmp.setAttribute('width', '0'); svgTmp.setAttribute('height', '0'); svgTmp.style.position = 'absolute'; svgTmp.style.left = '-9999px'; svgTmp.style.top = '-9999px'; svgTmp.style.opacity = '0'; svgTmp.appendChild(pTmp); document.body.appendChild(svgTmp); const bb = pTmp.getBBox(); svgTmp.remove(); if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) { balloon24MaskBounds = { x: bb.x, y: bb.y, w: bb.width, h: bb.height, cx: bb.x + bb.width / 2, cy: bb.y + bb.height / 2 }; } } catch {} } balloon24MaskLoaded = !!balloon24MaskPath; if (balloon24MaskLoaded) requestDraw(); } catch (err) { console.warn('Failed to load 24in balloon mask:', err); } finally { balloon24MaskLoading = false; } }; const loadWeightMask = async () => { if (weightMaskLoaded || weightMaskLoading) return; weightMaskLoading = true; try { const res = await fetch(WEIGHT_MASK_URL, { cache: 'force-cache' }); const text = await res.text(); let pathD = ''; try { const doc = new DOMParser().parseFromString(text, 'image/svg+xml'); const pathEl = doc.querySelector('path[d]'); pathD = pathEl?.getAttribute('d') || ''; } catch {} if (!pathD) { const dMatch = text.match(/]*\sd="([^"]+)"/i); pathD = dMatch?.[1] || ''; } if (pathD) { weightMaskPath = new Path2D(pathD); weightMaskPathData = pathD; } if (pathD) { try { const ns = 'http://www.w3.org/2000/svg'; const svgTmp = document.createElementNS(ns, 'svg'); const pTmp = document.createElementNS(ns, 'path'); pTmp.setAttribute('d', pathD); svgTmp.setAttribute('xmlns', ns); svgTmp.setAttribute('width', '0'); svgTmp.setAttribute('height', '0'); svgTmp.style.position = 'absolute'; svgTmp.style.left = '-9999px'; svgTmp.style.top = '-9999px'; svgTmp.style.opacity = '0'; svgTmp.appendChild(pTmp); document.body.appendChild(svgTmp); const bb = pTmp.getBBox(); svgTmp.remove(); if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) { weightMaskBounds = { x: bb.x, y: bb.y, w: bb.width, h: bb.height, cx: bb.x + bb.width / 2, cy: bb.y + bb.height / 2 }; } } catch {} } weightMaskLoaded = !!weightMaskPath; if (weightMaskLoaded) requestDraw(); } catch (err) { console.warn('Failed to load weight mask:', err); } finally { weightMaskLoading = false; } }; loadBalloonMask(); loadBalloon24Mask(); loadWeightMask(); // ====== 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]; let garlandAccentIdx = -1; let heliumPlacementType = 'balloon'; let ribbonDraftStart = null; let ribbonDraftMouse = null; let ribbonAttachMode = false; let lastCommitMode = ''; let lastAddStatus = ''; let evtStats = { down: 0, up: 0, cancel: 0, touchEnd: 0, addBalloon: 0, addGarland: 0, lastType: '' }; // 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' : 'Nothing to undo'; } if (toolRedoBtn) { toolRedoBtn.disabled = !canRedo; toolRedoBtn.title = canRedo ? 'Redo' : '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$/, '')}"`; } const makeId = (() => { let n = 0; return () => { try { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID(); } catch {} return `b-${Date.now().toString(36)}-${(n++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`; }; })(); 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 hashString32(str) { let h = 2166136261 >>> 0; const s = String(str || ''); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function normalizeRibbonStyle(v) { return v === 'spiral' ? 'spiral' : 'wave'; } function getObjectRotationRad(b) { const deg = Number(b?.rotationDeg) || 0; return (deg * Math.PI) / 180; } function normalizeRotationDeg(deg) { let d = Number(deg) || 0; while (d > 180) d -= 360; while (d <= -180) d += 360; return d; } function withObjectRotation(b, fn) { const ang = getObjectRotationRad(b); if (!ang) { fn(); return; } ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(ang); ctx.translate(-b.x, -b.y); fn(); ctx.restore(); } function rotatedAabb(minX, minY, maxX, maxY, cx, cy, ang) { if (!ang) return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }; const c = Math.cos(ang); const s = Math.sin(ang); const corners = [ [minX, minY], [maxX, minY], [minX, maxY], [maxX, maxY] ]; let rMinX = Infinity, rMinY = Infinity, rMaxX = -Infinity, rMaxY = -Infinity; corners.forEach(([x, y]) => { const dx = x - cx; const dy = y - cy; const rx = cx + dx * c - dy * s; const ry = cy + dx * s + dy * c; rMinX = Math.min(rMinX, rx); rMinY = Math.min(rMinY, ry); rMaxX = Math.max(rMaxX, rx); rMaxY = Math.max(rMaxY, ry); }); return { minX: rMinX, minY: rMinY, maxX: rMaxX, maxY: rMaxY, w: rMaxX - rMinX, h: rMaxY - rMinY }; } function getBalloonMaskShape(sizePreset, activeTab = getActiveOrganicTab()) { if (activeTab === '#tab-helium' && sizePreset === 24 && balloon24MaskPath) { return { path: balloon24MaskPath, bounds: balloon24MaskBounds }; } return { path: balloonMaskPath, bounds: balloonMaskBounds }; } function getHeliumVolumeVisualBoost(sizePreset, activeTab = getActiveOrganicTab()) { if (activeTab !== '#tab-helium') return 1; const cuft = HELIUM_CUFT_BY_SIZE[sizePreset]; if (!Number.isFinite(cuft) || cuft <= 0) return 1; const desiredRatio = Math.sqrt(cuft / HELIUM_CUFT_BASE_VALUE); const currentRatio = sizePreset / HELIUM_CUFT_BASE_SIZE; if (!Number.isFinite(currentRatio) || currentRatio <= 0) return 1; return desiredRatio / currentRatio; } function getBalloonVisualRadius(b, activeTab = getActiveOrganicTab()) { if (!b || b.kind !== 'balloon') return b?.radius || 0; const sizeIndex = radiusToSizeIndex(b.radius); const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11; return b.radius * getHeliumVolumeVisualBoost(sizePreset, activeTab); } function getWeightMaskTransform(b) { const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; const scale = (b.radius * 2 * WEIGHT_VISUAL_SCALE) / Math.max(1, mb.w); const minX = b.x - mb.cx * scale; const minY = b.y - mb.cy * scale; return { scale, minX, minY, maxX: minX + mb.w * scale, maxY: minY + mb.h * scale }; } function isPointInWeightMask(b, x, y) { if (!weightMaskPath) return false; const wt = getWeightMaskTransform(b); const mb = weightMaskBounds || { cx: 0.5, cy: 0.5 }; const ang = getObjectRotationRad(b); const c = Math.cos(-ang); const s = Math.sin(-ang); const dx = x - b.x; const dy = y - b.y; const ux = dx * c - dy * s; const uy = dx * s + dy * c; const localX = (ux / wt.scale) + mb.cx; const localY = (uy / wt.scale) + mb.cy; return !!ctx.isPointInPath(weightMaskPath, localX, localY); } function weightHitTest(b, x, y, pad = 0, { loose = false } = {}) { if (!weightMaskPath) { return Math.hypot(x - b.x, y - b.y) <= (b.radius * 1.4 + pad); } const wt = getWeightMaskTransform(b); if (x < wt.minX - pad || x > wt.maxX + pad || y < wt.minY - pad || y > wt.maxY + pad) return false; if (loose) return true; if (isPointInWeightMask(b, x, y)) return true; if (pad <= 0) return false; const sampleCount = 8; for (let i = 0; i < sampleCount; i++) { const a = (Math.PI * 2 * i) / sampleCount; if (isPointInWeightMask(b, x + Math.cos(a) * pad, y + Math.sin(a) * pad)) return true; } return false; } function getObjectBounds(b) { if (!b) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0 }; if (b.kind === 'ribbon') { const pts = getRibbonPoints(b); if (!pts || !pts.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0 }; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; pts.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }; } const ang = getObjectRotationRad(b); if (b.kind === 'curl260') { const r = b.radius * 1.35; return rotatedAabb(b.x - r, b.y - r, b.x + r, b.y + r * 2.4, b.x, b.y, ang); } if (b.kind === 'weight') { if (weightMaskPath) { const wt = getWeightMaskTransform(b); return rotatedAabb(wt.minX, wt.minY, wt.maxX, wt.maxY, b.x, b.y, ang); } const r = b.radius * WEIGHT_VISUAL_SCALE; return rotatedAabb(b.x - r * 1.2, b.y - r * 2.8, b.x + r * 1.2, b.y + r * 2.4, b.x, b.y, ang); } const vr = getBalloonVisualRadius(b, getActiveOrganicTab()); return { minX: b.x - vr, minY: b.y - vr, maxX: b.x + vr, maxY: b.y + vr, w: vr * 2, h: vr * 2 }; } function rotatePointAround(px, py, cx, cy, ang) { if (!ang) return { x: px, y: py }; const c = Math.cos(ang); const s = Math.sin(ang); const dx = px - cx; const dy = py - cy; return { x: cx + dx * c - dy * s, y: cy + dx * s + dy * c }; } function getRibbonNodePosition(b) { if (!b) return null; if (b.kind === 'weight') { let p; if (weightMaskPath) { const wt = getWeightMaskTransform(b); const mb = weightMaskBounds || { y: 0, h: 1, cy: 0.5 }; // Slightly below the very top so ribbons land on the weight tie point. const localY = mb.y + mb.h * 0.145; p = { x: b.x, y: b.y + (localY - mb.cy) * wt.scale }; } else { const bb = getObjectBounds(b); p = { x: b.x, y: bb.minY + Math.max(4, bb.h * 0.15) }; } const ang = getObjectRotationRad(b); return rotatePointAround(p.x, p.y, b.x, b.y, ang); } if (b.kind === 'balloon') { let p; const sizeIndex = radiusToSizeIndex(b.radius); const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11; const maskShape = getBalloonMaskShape(sizePreset, getActiveOrganicTab()); const useBalloonMaskNode = !!(maskShape.path && getActiveOrganicTab() === '#tab-helium'); if (useBalloonMaskNode) { const mb = maskShape.bounds || { y: 0, h: 1, cy: 0.5, w: 1 }; const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, getActiveOrganicTab()); const scaleY = ((b.radius * 2) / Math.max(1, mb.h)) * heliumBoost; const localY = mb.y + mb.h * 0.972; p = { x: b.x, y: b.y + (localY - mb.cy) * scaleY }; } else { p = { x: b.x, y: b.y + b.radius * 0.96 }; } const ang = getObjectRotationRad(b); return rotatePointAround(p.x, p.y, b.x, b.y, ang); } return null; } function findRibbonNodeAt(x, y) { const hitR = Math.max(8 / view.s, 5); for (let i = balloons.length - 1; i >= 0; i--) { const b = balloons[i]; if (b?.kind !== 'balloon' && b?.kind !== 'weight') continue; const p = getRibbonNodePosition(b); if (!p) continue; if (Math.hypot(x - p.x, y - p.y) <= hitR) { return { kind: b.kind, id: b.id, x: p.x, y: p.y }; } } return null; } function resolveRibbonEndpoint(endpoint) { if (!endpoint || !endpoint.id) return null; const b = balloons.find(obj => obj.id === endpoint.id); return getRibbonNodePosition(b); } function getRibbonPoints(ribbon) { const start = resolveRibbonEndpoint(ribbon.from); if (!start) return null; const rawEnd = ribbon.to ? resolveRibbonEndpoint(ribbon.to) : (ribbon.freeEnd ? { x: ribbon.freeEnd.x, y: ribbon.freeEnd.y } : null); const scale = clamp(Number(ribbon.lengthScale) || 1, 0.4, 2.2); const end = rawEnd ? { x: start.x + (rawEnd.x - start.x) * scale, y: start.y + (rawEnd.y - start.y) * scale } : null; if (!end) return null; const points = []; const tight = !!(ribbon.to && ((ribbon.from?.kind === 'balloon' && ribbon.to?.kind === 'weight') || (ribbon.from?.kind === 'weight' && ribbon.to?.kind === 'balloon'))); if (tight) return [start, end]; const dx = end.x - start.x; const dy = end.y - start.y; const len = Math.max(1, Math.hypot(dx, dy)); const nx = -dy / len; const ny = dx / len; const steps = 28; const amp = Math.max(4 / view.s, Math.min(16 / view.s, len * 0.085)) * (0.9 + scale * 0.25); const waves = 3; for (let i = 0; i <= steps; i++) { const t = i / steps; const sx = start.x + dx * t; const sy = start.y + dy * t; const off = Math.sin(t * Math.PI * 2 * waves) * amp * (0.35 + 0.65 * t); points.push({ x: sx + nx * off, y: sy + ny * off + (1 - t) * (2 / view.s) }); } return points; } function drawRibbonObject(ribbon, meta) { const pts = getRibbonPoints(ribbon); if (!pts || pts.length < 2) return; const rgb = hexToRgb(normalizeHex(meta?.hex || ribbon.color || '#999999')) || { r: 120, g: 120, b: 120 }; const w = Math.max(1.9, 2.2 / view.s); const trace = () => { ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); }; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (isBorderEnabled) { ctx.beginPath(); trace(); ctx.strokeStyle = '#111827'; ctx.lineWidth = w + Math.max(0.9, 1.2 / view.s); ctx.stroke(); } ctx.beginPath(); trace(); ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`; ctx.lineWidth = w; ctx.stroke(); // Small curled ribbon detail at balloon nozzle connection. if (ribbon?.from?.kind === 'balloon') { const p0 = pts[0]; const p1 = pts[Math.min(1, pts.length - 1)]; const vx = p1.x - p0.x; const vy = p1.y - p0.y; const vl = Math.max(1e-6, Math.hypot(vx, vy)); const ux = vx / vl; const uy = vy / vl; const nx = -uy; const ny = ux; const side = (hashString32(ribbon.id || '') & 1) ? 1 : -1; const amp = Math.max(3.2 / view.s, 2.3); const len = Math.max(12 / view.s, 8.5); const steps = 14; const drawCurl = (lineW, stroke) => { ctx.beginPath(); for (let i = 0; i <= steps; i++) { const t = i / steps; const d = len * t; const x = p0.x + ux * d + nx * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2); const y = p0.y + uy * d + ny * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = stroke; ctx.lineWidth = lineW; ctx.stroke(); }; if (isBorderEnabled) drawCurl(w + Math.max(0.9, 1.2 / view.s), '#111827'); drawCurl(w, `rgb(${rgb.r},${rgb.g},${rgb.b})`); } ctx.restore(); } function ribbonDistanceToPoint(ribbon, x, y) { const pts = getRibbonPoints(ribbon); if (!pts || pts.length < 2) return Infinity; let best = Infinity; for (let i = 1; i < pts.length; i++) { const a = pts[i - 1]; const b = pts[i]; const vx = b.x - a.x; const vy = b.y - a.y; const vv = vx * vx + vy * vy || 1; const t = clamp(((x - a.x) * vx + (y - a.y) * vy) / vv, 0, 1); const px = a.x + vx * t; const py = a.y + vy * t; best = Math.min(best, Math.hypot(x - px, y - py)); } return best; } function drawRibbonSelectionRing(ribbon) { const pts = getRibbonPoints(ribbon); if (!pts || pts.length < 2) return false; const trace = () => { ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); }; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); trace(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = Math.max(5 / view.s, 3); ctx.stroke(); ctx.beginPath(); trace(); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = Math.max(2.2 / view.s, 1.4); ctx.stroke(); ctx.restore(); return true; } function normalizeHeliumPlacementType(v) { return (v === 'curl260' || v === 'weight' || v === 'ribbon') ? v : 'balloon'; } function buildCurrentRibbonConfig() { return { enabled: true, style: 'wave', length: 0.7, turns: 3 }; } function getCurlObjectConfig(b) { if (!b || b.kind !== 'curl260' || !b.curl) return null; return { enabled: true, style: normalizeRibbonStyle(b.curl.style), length: clamp(Number(b.curl.length) || 0.7, 0.7, 2.4), turns: Math.max(1, Math.min(5, Math.round(Number(b.curl.turns) || 3))) }; } 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 }; } // ====== 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 (next === 'garland' && getActiveOrganicTab() === '#tab-helium') next = 'draw'; if (mode === 'garland' && next !== 'garland') { garlandPath = []; } if (next !== 'draw') resetRibbonDraft(); 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')); const mobileErase = document.getElementById('mobile-act-erase'); const mobilePick = document.getElementById('mobile-act-eyedrop'); const setActive = (el, on) => { if (!el) return; el.setAttribute('aria-pressed', String(on)); el.classList.toggle('active', !!on); }; setActive(mobileErase, mode === 'erase'); setActive(mobilePick, 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 syncHeliumToolUi() { const isHelium = getActiveOrganicTab() === '#tab-helium'; if (toolGarlandBtn) { toolGarlandBtn.classList.toggle('hidden', isHelium); toolGarlandBtn.disabled = isHelium; toolGarlandBtn.setAttribute('aria-hidden', String(isHelium)); toolGarlandBtn.tabIndex = isHelium ? -1 : 0; } if (isHelium && mode === 'garland') setMode('draw'); if (isHelium) garlandControls?.classList.add('hidden'); } function syncHeliumPlacementUi() { const isHelium = getActiveOrganicTab() === '#tab-helium'; heliumPlacementRow?.classList.toggle('hidden', !isHelium); const setBtn = (btn, active) => { if (!btn) return; btn.classList.toggle('tab-active', !!active); btn.classList.toggle('tab-idle', !active); btn.setAttribute('aria-pressed', String(!!active)); }; setBtn(heliumPlaceBalloonBtn, heliumPlacementType === 'balloon'); setBtn(heliumPlaceCurlBtn, heliumPlacementType === 'curl260'); setBtn(heliumPlaceRibbonBtn, heliumPlacementType === 'ribbon'); setBtn(heliumPlaceWeightBtn, heliumPlacementType === 'weight'); if (isHelium && sizePresetGroup) { const sizeBtns = Array.from(sizePresetGroup.querySelectorAll('button')); if (heliumPlacementType !== 'balloon') { sizeBtns.forEach(btn => btn.setAttribute('aria-pressed', 'false')); } else { sizeBtns.forEach(btn => { const isMatch = (btn.textContent || '').trim() === `${currentDiameterInches}"`; btn.setAttribute('aria-pressed', String(isMatch)); }); } } } 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(); ribbonAttachMode = false; 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 (rotateSelectedLeftBtn) rotateSelectedLeftBtn.disabled = !has; if (rotateSelectedResetBtn) rotateSelectedResetBtn.disabled = !has; if (rotateSelectedRightBtn) rotateSelectedRightBtn.disabled = !has; if (applyColorBtn) applyColorBtn.disabled = !has; const isHelium = getActiveOrganicTab() === '#tab-helium'; const selectedCurlCount = selectionBalloons().filter(b => b?.kind === 'curl260').length; const selectedRibbonCount = selectionBalloons().filter(b => b?.kind === 'ribbon').length; if (ribbonAttachMode && selectedRibbonCount === 0) ribbonAttachMode = false; if (ribbonLengthDownBtn) ribbonLengthDownBtn.disabled = selectedRibbonCount === 0; if (ribbonLengthUpBtn) ribbonLengthUpBtn.disabled = selectedRibbonCount === 0; if (ribbonAttachWeightBtn) { ribbonAttachWeightBtn.disabled = selectedRibbonCount === 0; ribbonAttachWeightBtn.textContent = ribbonAttachMode ? 'Click Weight…' : 'Attach to Weight'; } if (selectedSizeInput && selectedSizeLabel) { if (has) { const first = balloons.find(bb => selectedIds.has(bb.id) && Number.isFinite(bb.radius)); if (first) { const diam = radiusPxToInches(first.radius); selectedSizeInput.value = String(Math.min(32, Math.max(5, diam))); selectedSizeLabel.textContent = fmtInches(diam); selectedSizeInput.disabled = false; } else { selectedSizeInput.disabled = true; selectedSizeLabel.textContent = '—'; } } 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 }; let pointerEventsSeen = false; let touchFallbackHandled = false; function requestDraw() { if (drawPending) return; drawPending = true; requestAnimationFrame(() => { drawPending = false; draw(); }); } const pointerTypeOf = (evt, fromTouch) => fromTouch ? 'touch' : (evt.pointerType || ''); function handlePrimaryDown(evt, { fromTouch = false } = {}) { // If the canvas never got sized (some mobile browsers skip ResizeObserver early), size it now. if (canvas.width === 0 || canvas.height === 0) resizeCanvas(); mouseInside = true; mousePos = getMousePos(evt); evtStats.down += 1; evtStats.lastType = pointerTypeOf(evt, fromTouch); if (evt.altKey || mode === 'eyedropper') { pickColorAt(mousePos.x, mousePos.y); if (mode === 'eyedropper') setMode('draw'); 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; if (ribbonAttachMode) { const clickedIdx = findWeightIndexAt(mousePos.x, mousePos.y); const target = clickedIdx >= 0 ? balloons[clickedIdx] : null; if (target?.kind === 'weight') { attachSelectedRibbonsToWeight(target.id); } else { ribbonAttachMode = false; updateSelectButtons(); showModal('Attach mode canceled. Click "Attach to Weight" and then click a weight.'); } requestDraw(); pointerDown = false; return; } const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y); if (clickedIdx !== -1) { const b = balloons[clickedIdx]; if (evt.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 (!evt.shiftKey) selectedIds.clear(); updateSelectButtons(); marqueeActive = true; marqueeStart = { ...mousePos }; marqueeEnd = { ...mousePos }; requestDraw(); } return; } if (getActiveOrganicTab() === '#tab-helium' && heliumPlacementType === 'ribbon') { handleRibbonPlacementAt(mousePos.x, mousePos.y); pointerDown = false; return; } addBalloon(mousePos.x, mousePos.y); pointerDown = true; } function handlePrimaryMove(evt, { fromTouch = false } = {}) { mouseInside = true; mousePos = getMousePos(evt); 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 === 'draw' && getActiveOrganicTab() === '#tab-helium' && heliumPlacementType === 'ribbon' && ribbonDraftStart) { ribbonDraftMouse = { ...mousePos }; requestDraw(); } 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(); } } } function handlePrimaryUp(evt, { fromTouch = false } = {}) { pointerDown = false; isDragging = false; evtStats.up += 1; evtStats.lastType = pointerTypeOf(evt, fromTouch); if (fromTouch) evtStats.touchEnd += 1; if (mode === 'garland') { if (garlandPath.length > 1) addGarlandFromPath(garlandPath); garlandPath = []; requestDraw(); 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 (!evt.shiftKey) selectedIds.clear(); ids.forEach(id => selectedIds.add(id)); marqueeActive = false; updateSelectButtons(); requestDraw(); } if (mode === 'erase' && eraseChanged) { refreshAll(); pushHistory(); } erasingActive = false; dragMoved = false; eraseChanged = false; marqueeActive = false; } function handlePrimaryCancel(evt, { fromTouch = false } = {}) { pointerDown = false; evtStats.cancel += 1; evtStats.lastType = pointerTypeOf(evt, fromTouch); if (mode === 'garland') { garlandPath = []; requestDraw(); } if (mode === 'draw' && getActiveOrganicTab() === '#tab-helium' && heliumPlacementType !== 'ribbon') { resetRibbonDraft(); } } // Avoid touch scrolling stealing pointer events. canvas.style.touchAction = 'none'; canvas.addEventListener('pointerdown', e => { // If a touch fallback already handled this gesture, ignore the duplicate pointer event. if (touchFallbackHandled && e.pointerType === 'touch') return; pointerEventsSeen = true; touchFallbackHandled = false; e.preventDefault(); canvas.setPointerCapture?.(e.pointerId); handlePrimaryDown(e, { fromTouch: e.pointerType === 'touch' }); }, { passive: false }); canvas.addEventListener('pointermove', e => { if (touchFallbackHandled && e.pointerType === 'touch') return; handlePrimaryMove(e, { fromTouch: e.pointerType === 'touch' }); }, { passive: true }); canvas.addEventListener('pointerenter', () => { mouseInside = true; if (mode === 'erase') requestDraw(); }); canvas.addEventListener('pointerup', e => { if (touchFallbackHandled && e.pointerType === 'touch') { canvas.releasePointerCapture?.(e.pointerId); return; } handlePrimaryUp(e, { fromTouch: e.pointerType === 'touch' }); canvas.releasePointerCapture?.(e.pointerId); }, { passive: true }); canvas.addEventListener('pointerleave', () => { mouseInside = false; marqueeActive = false; if (mode === 'garland') { pointerDown = false; garlandPath = []; requestDraw(); } if (mode === 'erase') requestDraw(); }, { passive: true }); canvas.addEventListener('pointercancel', e => { if (touchFallbackHandled && e.pointerType === 'touch') return; handlePrimaryCancel(e, { fromTouch: e.pointerType === 'touch' }); }, { passive: true }); // Touch fallback for browsers where pointer events are not delivered. const touchToPointerLike = (e) => { const t = e.changedTouches && e.changedTouches[0]; if (!t) return null; return { clientX: t.clientX, clientY: t.clientY, pointerType: 'touch', altKey: e.altKey, shiftKey: e.shiftKey }; }; const shouldHandleTouchFallback = () => !pointerEventsSeen; canvas.addEventListener('touchstart', e => { if (!shouldHandleTouchFallback()) return; const fake = touchToPointerLike(e); if (!fake) return; touchFallbackHandled = true; e.preventDefault(); handlePrimaryDown(fake, { fromTouch: true }); }, { passive: false }); canvas.addEventListener('touchmove', e => { if (!touchFallbackHandled || !shouldHandleTouchFallback()) return; const fake = touchToPointerLike(e); if (!fake) return; e.preventDefault(); handlePrimaryMove(fake, { fromTouch: true }); }, { passive: false }); canvas.addEventListener('touchend', e => { if (!touchFallbackHandled || !shouldHandleTouchFallback()) return; const fake = touchToPointerLike(e) || { pointerType: 'touch', shiftKey: false, altKey: false, clientX: 0, clientY: 0 }; e.preventDefault(); handlePrimaryUp(fake, { fromTouch: true }); touchFallbackHandled = false; }, { passive: false }); canvas.addEventListener('touchcancel', e => { if (!touchFallbackHandled || !shouldHandleTouchFallback()) return; const fake = touchToPointerLike(e) || { pointerType: 'touch' }; handlePrimaryCancel(fake, { fromTouch: true }); touchFallbackHandled = false; }, { passive: true }); // No global pointer/touch commits; rely on canvas handlers (as in the working older version). // ====== 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); const activeOrgTab = getActiveOrganicTab(); const isHeliumTab = activeOrgTab === '#tab-helium'; const canRenderHeliumRibbons = isHeliumTab; const drawMaskedBalloon = (b, meta) => { const sizeIndex = radiusToSizeIndex(b.radius); const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11; const shape = getBalloonMaskShape(sizePreset, activeOrgTab); if (!shape.path) return; const mb = shape.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab); const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost; const strokeW = Math.max(0.35, 0.5 / view.s); const destX = b.x - (mb.cx - mb.x) * scale; const destY = b.y - (mb.cy - mb.y) * scale; const destW = Math.max(1, mb.w * scale); const destH = Math.max(1, mb.h * scale); const drawFill = () => { if (b.image) { const img = getImage(b.image); if (!img || !img.complete || img.naturalWidth === 0) return; 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.drawImage(img, srcX, srcY, srcW, srcH, destX, destY, destW, destH); } else { ctx.fillStyle = b.color; ctx.shadowColor = 'rgba(0,0,0,0.2)'; ctx.shadowBlur = 10; ctx.fillRect(destX, destY, destW, destH); ctx.shadowBlur = 0; } }; ctx.save(); ctx.translate(b.x, b.y); ctx.scale(scale, scale); ctx.translate(-mb.cx, -mb.cy); ctx.clip(shape.path); ctx.translate(mb.cx, mb.cy); ctx.scale(1 / scale, 1 / scale); ctx.translate(-b.x, -b.y); const lum = luminance(meta.hex || b.color); if (b.image && lum > 0.6) { const strength = clamp01((lum - 0.6) / 0.4); ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`; ctx.shadowBlur = 4 + 4 * strength; ctx.shadowOffsetY = 1 + 2 * strength; } drawFill(); ctx.restore(); if (isBorderEnabled) { ctx.save(); ctx.translate(b.x, b.y); ctx.scale(scale, scale); ctx.translate(-mb.cx, -mb.cy); ctx.strokeStyle = '#111827'; ctx.lineWidth = strokeW / scale; ctx.stroke(shape.path); ctx.restore(); } }; const tryDrawMaskedBalloon = (b, meta) => { try { drawMaskedBalloon(b, meta); return true; } catch (err) { if (!balloonMaskDrawFailed) { balloonMaskDrawFailed = true; console.warn('Masked balloon draw failed; falling back to circular balloons.', err); } return false; } }; const drawHeliumRibbon = (b, meta, ribbonCfg) => { if (!canRenderHeliumRibbons || !ribbonCfg) return; const seed = hashString32(b.id || `${b.x},${b.y}`); const baseColor = normalizeHex(meta?.hex || b.color || '#999999'); const rgb = hexToRgb(baseColor) || { r: 120, g: 120, b: 120 }; const side = (seed & 1) ? 1 : -1; const phase = ((seed >>> 3) % 628) / 100; const len = Math.max(20, b.radius * 2 * ribbonCfg.length); const stem = Math.max(6, b.radius * 0.28); const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012))); const turns = Math.max(1, Math.min(5, ribbonCfg.turns | 0)); const anchorX = (b.kind === 'curl260') ? b.x : (b.x + side * Math.max(1, b.radius * 0.06)); const anchorY = (b.kind === 'curl260') ? (b.y - b.radius * 0.55) : (b.y + b.radius * 0.54); const width = Math.max(2.6, (2.4 + b.radius * 0.14) / view.s); const isSpiralRibbon = ribbonCfg.style === 'spiral'; const steps = isSpiralRibbon ? 42 : 28; const traceRibbon = () => { ctx.moveTo(anchorX, anchorY); ctx.lineTo(anchorX, anchorY + stem); if (isSpiralRibbon) { const topLead = Math.max(8, len * 0.28); const topAmp = Math.max(6, b.radius * 0.95); // Lead-in swoop like a hand-curled ribbon before the tighter coils. for (let i = 1; i <= Math.floor(steps * 0.33); i++) { const t = i / Math.floor(steps * 0.33); const y = anchorY + stem + topLead * t; const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15); ctx.lineTo(x, y); } const startY = anchorY + stem + topLead; const remain = Math.max(8, len - topLead); const coilAmp = Math.max(5, b.radius * 0.5); const coilSteps = steps - Math.floor(steps * 0.33); for (let i = 1; i <= coilSteps; i++) { const t = i / coilSteps; const angle = (Math.PI * 2 * turns * t) + phase; const decay = 1 - t * 0.55; const x = anchorX + side * (coilAmp * decay) * Math.sin(angle); const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle); ctx.lineTo(x, y); } return; } for (let i = 1; i <= steps; i++) { const t = i / steps; const falloff = 1 - t * 0.25; const y = anchorY + stem + len * t; const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side; ctx.lineTo(x, y); } }; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (isBorderEnabled) { ctx.beginPath(); traceRibbon(); ctx.strokeStyle = '#111827'; ctx.lineWidth = width + Math.max(0.8, 1.2 / view.s); ctx.stroke(); } // ribbon (thicker 260-style) ctx.beginPath(); traceRibbon(); ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`; ctx.lineWidth = width; ctx.stroke(); // tiny tie under the neck only for attached balloon ribbons/curls if (b.kind !== 'curl260') { ctx.beginPath(); ctx.moveTo(anchorX - 2.2 / view.s, anchorY + 1.2 / view.s); ctx.lineTo(anchorX + 2.2 / view.s, anchorY + 1.2 / view.s); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(1.2, 1.8 / view.s); ctx.stroke(); ctx.beginPath(); ctx.moveTo(anchorX - 2.2 / view.s, anchorY + 1.2 / view.s); ctx.lineTo(anchorX + 2.2 / view.s, anchorY + 1.2 / view.s); } ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`; ctx.lineWidth = Math.max(0.6, 0.9 / view.s); ctx.stroke(); } ctx.restore(); }; const drawWeightObject = (b, meta) => { if (weightMaskPath) { const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 }; const wt = getWeightMaskTransform(b); const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; const weightImg = getImage(WEIGHT_IMAGE_URL); const hasWeightImg = !!(weightImg && weightImg.complete && weightImg.naturalWidth > 0); if (hasWeightImg) { const nw = Math.max(1, weightImg.naturalWidth || 1); const nh = Math.max(1, weightImg.naturalHeight || 1); const dstW = Math.max(1, mb.w); const dstH = Math.max(1, mb.h); const srcAspect = nw / nh; const dstAspect = dstW / dstH; let srcX = 0, srcY = 0, srcW = nw, srcH = nh; if (srcAspect > dstAspect) { srcW = nh * dstAspect; srcX = (nw - srcW) * 0.5; } else if (srcAspect < dstAspect) { srcH = nw / dstAspect; srcY = (nh - srcH) * 0.5; } const inset = Math.max(0.8, Math.min(dstW, dstH) * 0.018); ctx.save(); ctx.translate(b.x, b.y); ctx.scale(wt.scale, wt.scale); ctx.translate(-mb.cx, -mb.cy); ctx.save(); ctx.clip(weightMaskPath); // Base photo texture (cover fit so it doesn't squash/blur). ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.filter = 'saturate(1.15) contrast(1.04)'; ctx.drawImage( weightImg, srcX, srcY, srcW, srcH, mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2) ); ctx.filter = 'none'; // Tint only existing image pixels (no rectangular washout). ctx.globalCompositeOperation = 'source-atop'; ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.46)`; ctx.fillRect(mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2)); // Bring back a little foil highlight after tint. ctx.globalCompositeOperation = 'screen'; ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.fillRect(mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2)); ctx.globalCompositeOperation = 'source-over'; ctx.restore(); ctx.restore(); return; } const hi = { r: Math.round(clamp(rgb.r + 78, 0, 255)), g: Math.round(clamp(rgb.g + 78, 0, 255)), b: Math.round(clamp(rgb.b + 78, 0, 255)) }; const midHi = { r: Math.round(clamp(rgb.r + 38, 0, 255)), g: Math.round(clamp(rgb.g + 38, 0, 255)), b: Math.round(clamp(rgb.b + 38, 0, 255)) }; const lo = { r: Math.round(clamp(rgb.r - 52, 0, 255)), g: Math.round(clamp(rgb.g - 52, 0, 255)), b: Math.round(clamp(rgb.b - 52, 0, 255)) }; const midLo = { r: Math.round(clamp(rgb.r - 24, 0, 255)), g: Math.round(clamp(rgb.g - 24, 0, 255)), b: Math.round(clamp(rgb.b - 24, 0, 255)) }; const seed = hashString32(b.id || `${b.x},${b.y}`); ctx.save(); ctx.translate(b.x, b.y); ctx.scale(wt.scale, wt.scale); ctx.translate(-mb.cx, -mb.cy); // Base color ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`; ctx.fill(weightMaskPath); // Metallic foil shading and specular highlights. ctx.save(); ctx.clip(weightMaskPath); // 45deg, multi-stop metallic ramp inspired by ibelick's CSS metal effect. const foilGrad = ctx.createLinearGradient(mb.x, mb.y, mb.x + mb.w, mb.y + mb.h); foilGrad.addColorStop(0.05, `rgba(${lo.r},${lo.g},${lo.b},0.34)`); foilGrad.addColorStop(0.10, `rgba(${hi.r},${hi.g},${hi.b},0.42)`); foilGrad.addColorStop(0.30, `rgba(${midLo.r},${midLo.g},${midLo.b},0.26)`); foilGrad.addColorStop(0.50, `rgba(${midHi.r},${midHi.g},${midHi.b},0.22)`); foilGrad.addColorStop(0.70, `rgba(${midLo.r},${midLo.g},${midLo.b},0.26)`); foilGrad.addColorStop(0.80, `rgba(${hi.r},${hi.g},${hi.b},0.40)`); foilGrad.addColorStop(0.95, `rgba(${lo.r},${lo.g},${lo.b},0.34)`); ctx.fillStyle = foilGrad; ctx.fillRect(mb.x - 2, mb.y - 2, mb.w + 4, mb.h + 4); // Diagonal foil crinkle bands from the corner direction. ctx.save(); const bandCount = 8; const diag = Math.hypot(mb.w, mb.h); const cx = mb.x + mb.w * 0.16; const cy = mb.y + mb.h * 0.14; ctx.translate(cx, cy); ctx.rotate(-Math.PI / 4); for (let i = 0; i < bandCount; i++) { const t = (i + 0.5) / bandCount; const jitter = ((((seed >>> (i * 3)) & 7) - 3) / 7) * (diag * 0.03); const bx = -diag * 0.2 + diag * t + jitter; const bandW = diag * (0.05 + (i % 3) * 0.01); const band = ctx.createLinearGradient(bx - bandW, 0, bx + bandW, 0); band.addColorStop(0, `rgba(${hi.r},${hi.g},${hi.b},0.00)`); band.addColorStop(0.5, `rgba(${hi.r},${hi.g},${hi.b},0.22)`); band.addColorStop(1, `rgba(${lo.r},${lo.g},${lo.b},0.00)`); ctx.fillStyle = band; ctx.fillRect(bx - bandW, -diag * 0.7, bandW * 2, diag * 1.8); } ctx.restore(); // Corner-entry sheen (top-left toward center) for a directional metallic hit. const cornerX = mb.x + mb.w * 0.14; const cornerY = mb.y + mb.h * 0.16; const cornerSpec = ctx.createRadialGradient(cornerX, cornerY, mb.w * 0.03, cornerX, cornerY, mb.w * 0.72); cornerSpec.addColorStop(0.0, 'rgba(255,255,255,0.34)'); cornerSpec.addColorStop(0.28, 'rgba(255,255,255,0.16)'); cornerSpec.addColorStop(1.0, 'rgba(255,255,255,0)'); ctx.fillStyle = cornerSpec; ctx.fillRect(mb.x, mb.y, mb.w, mb.h); ctx.restore(); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.lineWidth = (0.7 / view.s) / wt.scale; ctx.stroke(weightMaskPath); } ctx.restore(); return; } const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 }; const base = b.radius; const bagW = Math.max(18, base * 1.6); const bagH = Math.max(20, base * 2.2); const knotW = bagW * 0.36; const knotH = Math.max(5, bagH * 0.14); const topY = b.y - bagH * 0.95; const bagTopY = topY + knotH + 2; ctx.save(); // Tinsel burst const burstCount = 26; for (let i = 0; i < burstCount; i++) { const a = (Math.PI * 2 * i) / burstCount; const len = bagW * (0.45 + ((i % 5) / 8)); const x1 = b.x + Math.cos(a) * 3; const y1 = topY + Math.sin(a) * 3; const x2 = b.x + Math.cos(a) * len; const y2 = topY + Math.sin(a) * len * 0.7; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`; ctx.lineWidth = Math.max(1, 1.8 / view.s); ctx.stroke(); if (isBorderEnabled) { ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.6, 0.9 / view.s); ctx.stroke(); } } // top loop ctx.beginPath(); ctx.ellipse(b.x, topY - bagH * 0.2, bagW * 0.18, bagW * 0.24, 0, 0, Math.PI * 2); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(1.2, 1.8 / view.s); ctx.stroke(); } ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`; ctx.lineWidth = Math.max(2, 3 / view.s); ctx.stroke(); // knot ctx.beginPath(); ctx.roundRect?.(b.x - knotW/2, topY, knotW, knotH, Math.max(2, knotH * 0.35)); if (!ctx.roundRect) { ctx.rect(b.x - knotW/2, topY, knotW, knotH); } ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`; ctx.fill(); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.9, 1.3 / view.s); ctx.stroke(); } // bag body ctx.beginPath(); ctx.moveTo(b.x - bagW * 0.42, bagTopY); ctx.quadraticCurveTo(b.x - bagW * 0.62, b.y + bagH * 0.05, b.x - bagW * 0.34, b.y + bagH * 0.78); ctx.quadraticCurveTo(b.x, b.y + bagH * 0.98, b.x + bagW * 0.34, b.y + bagH * 0.78); ctx.quadraticCurveTo(b.x + bagW * 0.62, b.y + bagH * 0.05, b.x + bagW * 0.42, bagTopY); ctx.closePath(); ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`; ctx.fill(); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(1, 1.4 / view.s); ctx.stroke(); } ctx.restore(); }; const drawMaskedSelectionRing = (b) => { const sizeIndex = radiusToSizeIndex(b.radius); const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11; const shape = getBalloonMaskShape(sizePreset, activeOrgTab); if (!shape.path) return false; const mb = shape.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab); const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost; ctx.save(); ctx.translate(b.x, b.y); ctx.scale(scale, scale); ctx.translate(-mb.cx, -mb.cy); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = (4 / view.s) / scale; ctx.stroke(shape.path); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = (2 / view.s) / scale; ctx.stroke(shape.path); ctx.restore(); return true; }; const drawCurlSelectionRing = (b) => { const ribbonCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig(); const meta = FLAT_COLORS[b.colorIdx] || {}; const seed = hashString32(b.id || `${b.x},${b.y}`); const side = (seed & 1) ? 1 : -1; const phase = ((seed >>> 3) % 628) / 100; const len = Math.max(20, b.radius * 2 * ribbonCfg.length); const stem = Math.max(6, b.radius * 0.28); const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012))); const turns = Math.max(1, Math.min(5, ribbonCfg.turns | 0)); const anchorX = b.x; const anchorY = b.y - b.radius * 0.55; const width = Math.max(2.6, (2.4 + b.radius * 0.14) / view.s); const isSpiralRibbon = ribbonCfg.style === 'spiral'; const steps = isSpiralRibbon ? 42 : 28; const traceCurl = () => { ctx.moveTo(anchorX, anchorY); ctx.lineTo(anchorX, anchorY + stem); if (isSpiralRibbon) { const topLead = Math.max(8, len * 0.28); const topAmp = Math.max(6, b.radius * 0.95); for (let i = 1; i <= Math.floor(steps * 0.33); i++) { const t = i / Math.floor(steps * 0.33); const y = anchorY + stem + topLead * t; const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15); ctx.lineTo(x, y); } const startY = anchorY + stem + topLead; const remain = Math.max(8, len - topLead); const coilAmp = Math.max(5, b.radius * 0.5); const coilSteps = steps - Math.floor(steps * 0.33); for (let i = 1; i <= coilSteps; i++) { const t = i / coilSteps; const angle = (Math.PI * 2 * turns * t) + phase; const decay = 1 - t * 0.55; const x = anchorX + side * (coilAmp * decay) * Math.sin(angle); const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle); ctx.lineTo(x, y); } return; } for (let i = 1; i <= steps; i++) { const t = i / steps; const falloff = 1 - t * 0.25; const y = anchorY + stem + len * t; const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side; ctx.lineTo(x, y); } }; ctx.save(); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); traceCurl(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = width + Math.max(6 / view.s, 3); ctx.stroke(); ctx.beginPath(); traceCurl(); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = width + Math.max(3 / view.s, 1.6); ctx.stroke(); ctx.restore(); // Redraw curl so the selection halo does not visually tint/fill the curl body. drawHeliumRibbon(b, meta, ribbonCfg); return true; }; const drawWeightSelectionRing = (b) => { if (weightMaskPath) { const wt = getWeightMaskTransform(b); ctx.save(); ctx.translate(b.x, b.y); ctx.scale(wt.scale, wt.scale); ctx.translate(-weightMaskBounds.cx, -weightMaskBounds.cy); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = (4 / view.s) / wt.scale; ctx.stroke(weightMaskPath); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = (2 / view.s) / wt.scale; ctx.stroke(weightMaskPath); ctx.restore(); return true; } const base = b.radius; const bagW = Math.max(18, base * 1.6); const bagH = Math.max(20, base * 2.2); const knotH = Math.max(5, bagH * 0.14); const topY = b.y - bagH * 0.95; const bagTopY = topY + knotH + 2; const pathWeight = () => { // loop ctx.moveTo(b.x + bagW * 0.18, topY - bagH * 0.2); ctx.ellipse(b.x, topY - bagH * 0.2, bagW * 0.18, bagW * 0.24, 0, 0, Math.PI * 2); // bag ctx.moveTo(b.x - bagW * 0.42, bagTopY); ctx.quadraticCurveTo(b.x - bagW * 0.62, b.y + bagH * 0.05, b.x - bagW * 0.34, b.y + bagH * 0.78); ctx.quadraticCurveTo(b.x, b.y + bagH * 0.98, b.x + bagW * 0.34, b.y + bagH * 0.78); ctx.quadraticCurveTo(b.x + bagW * 0.62, b.y + bagH * 0.05, b.x + bagW * 0.42, bagTopY); ctx.closePath(); }; ctx.save(); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); pathWeight(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = Math.max(4 / view.s, 2.4); ctx.stroke(); ctx.beginPath(); pathWeight(); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = Math.max(2 / view.s, 1.3); ctx.stroke(); ctx.restore(); return true; }; balloons.forEach(b => { if (b?.kind === 'ribbon') { const meta = FLAT_COLORS[b.colorIdx] || {}; drawRibbonObject(b, meta); return; } withObjectRotation(b, () => { if (b?.kind === 'curl260') { const meta = FLAT_COLORS[b.colorIdx] || {}; const curlCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig(); drawHeliumRibbon(b, meta, curlCfg); return; } if (b?.kind === 'weight') { const meta = FLAT_COLORS[b.colorIdx] || {}; drawWeightObject(b, meta); return; } const sizeIndex = radiusToSizeIndex(b.radius); const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11; const maskShape = getBalloonMaskShape(sizePreset, activeOrgTab); const useMask = !!(maskShape.path && activeOrgTab === '#tab-helium'); const meta = FLAT_COLORS[b.colorIdx] || {}; if (b.image) { const img = getImage(b.image); if (img && img.complete && img.naturalWidth > 0) { 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); if (useMask && tryDrawMaskedBalloon(b, meta)) { // masked draw succeeded } else { 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 if (useMask && tryDrawMaskedBalloon(b, { hex: b.color })) { // masked draw succeeded } else { 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 visualRadius = getBalloonVisualRadius(b, activeOrgTab); const shineScale = useMask ? ((sizePreset === 11) ? 0.68 : 0.8) : 1; const shineOffsetScale = useMask ? 0.78 : 1; const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color); const sx = b.x - visualRadius * SHINE_OFFSET * shineOffsetScale; const sy = b.y - visualRadius * SHINE_OFFSET * shineOffsetScale; const rx = visualRadius * SHINE_RX * shineScale; const ry = visualRadius * SHINE_RY * shineScale; 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; if (b.kind === 'ribbon') { if (drawRibbonSelectionRing(b)) return; } withObjectRotation(b, () => { if (b.kind === 'curl260') { if (drawCurlSelectionRing(b)) return; } else if (b.kind === 'weight') { if (drawWeightSelectionRing(b)) return; } else { const sizeIndex = radiusToSizeIndex(b.radius); const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11; const maskShape = getBalloonMaskShape(sizePreset, activeOrgTab); const useMask = !!(maskShape.path && activeOrgTab === '#tab-helium'); if (useMask && drawMaskedSelectionRing(b)) return; } ctx.beginPath(); const visualRadius = getBalloonVisualRadius(b, activeOrgTab); ctx.arc(b.x, b.y, visualRadius + 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(); } if (activeOrgTab === '#tab-helium' && mode === 'draw' && heliumPlacementType === 'ribbon') { // Show allowed connection nodes (balloon nozzles + weights) while in ribbon mode. ctx.save(); const nodeR = Math.max(3, 4 / view.s); balloons.forEach(b => { if (b?.kind !== 'balloon' && b?.kind !== 'weight') return; const p = getRibbonNodePosition(b); if (!p) return; ctx.beginPath(); ctx.arc(p.x, p.y, nodeR, 0, Math.PI * 2); ctx.fillStyle = 'rgba(59,130,246,0.65)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = Math.max(1 / view.s, 0.7); ctx.stroke(); }); if (ribbonDraftStart) { const draftStartPos = resolveRibbonEndpoint(ribbonDraftStart); const draftEndPos = ribbonDraftMouse || mousePos; if (draftStartPos && draftEndPos) { const draftRibbon = { kind: 'ribbon', from: ribbonDraftStart, to: null, freeEnd: { x: draftEndPos.x, y: draftEndPos.y }, color: '#60a5fa', colorIdx: selectedColorIdx }; ctx.globalAlpha = 0.9; drawRibbonObject(draftRibbon, FLAT_COLORS[selectedColorIdx] || {}); ctx.globalAlpha = 1; } } 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)} canvas:${canvas.width}x${canvas.height}`, `down:${evtStats.down} up:${evtStats.up} cancel:${evtStats.cancel} touchEnd:${evtStats.touchEnd} add:${evtStats.addBalloon} 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'; const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1'; const getActiveOrganicTab = () => { const active = document.body?.dataset?.activeTab || localStorage.getItem(ACTIVE_TAB_KEY) || '#tab-organic'; return (active === '#tab-helium') ? '#tab-helium' : '#tab-organic'; }; const getAppStateKey = (tabId) => (tabId === '#tab-helium') ? `${APP_STATE_KEY}:helium` : `${APP_STATE_KEY}:organic`; function saveAppState(tabId = getActiveOrganicTab()) { // Note: isShineEnabled is managed globally. const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, view, usedSortDesc, garlandDensity, garlandMainIdx, garlandAccentIdx, isBorderEnabled, heliumPlacementType }; try { localStorage.setItem(getAppStateKey(tabId), JSON.stringify(state)); } catch {} } const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })(); function resetAppState() { balloons = []; selectedColorIdx = 0; currentDiameterInches = 11; currentRadius = inchesToRadiusPx(currentDiameterInches); eraserRadius = parseInt(eraserSizeInput?.value || '40', 10); view = { s: 1, tx: 0, ty: 0 }; usedSortDesc = true; garlandPath = []; garlandDensity = 1; garlandMainIdx = [selectedColorIdx]; garlandAccentIdx = -1; isBorderEnabled = true; heliumPlacementType = 'balloon'; if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled; selectedIds.clear(); setMode('draw'); } function loadAppState(tabId = getActiveOrganicTab()) { try { const raw = localStorage.getItem(getAppStateKey(tabId)); if (!raw) { resetAppState(); updateCurrentColorChip(); return; } const s = JSON.parse(raw || '{}'); if (Array.isArray(s.balloons)) balloons = s.balloons; if (Array.isArray(balloons)) { balloons.forEach(b => { if (!b || typeof b !== 'object') return; b.rotationDeg = normalizeRotationDeg(b.rotationDeg); if (b.kind === 'curl260') { const cfg = getCurlObjectConfig(b); if (cfg) b.curl = cfg; } }); } 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, 10).map(v => Number.isInteger(v) ? v : -1).filter((v, i) => i < 10); if (!garlandMainIdx.length) garlandMainIdx = [selectedColorIdx]; } if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx; if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled; if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled; if (s.heliumPlacementType === 'balloon' || s.heliumPlacementType === 'curl260' || s.heliumPlacementType === 'weight' || s.heliumPlacementType === 'ribbon') { heliumPlacementType = s.heliumPlacementType; } syncHeliumPlacementUi(); updateCurrentColorChip(); updateSelectButtons(); } catch {} } loadAppState(); resetHistory(); // establish initial history state for undo/redo controls // ====== Garland color UI (dynamic chips) ====== const styleChip = (el, meta) => { if (!el || !meta) return; if (meta.image) { el.style.backgroundImage = `url("${meta.image}")`; el.style.backgroundColor = meta.hex || '#fff'; el.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`; el.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`; } else { el.style.backgroundImage = 'none'; el.style.backgroundColor = meta.hex || '#f1f5f9'; } }; const garlandMaxColors = 10; function renderGarlandMainChips() { if (!garlandMainChips) return; garlandMainChips.innerHTML = ''; const items = garlandMainIdx.length ? garlandMainIdx : [selectedColorIdx]; items.forEach((idx, i) => { const wrap = document.createElement('div'); wrap.className = 'flex items-center gap-1'; const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'replace-chip garland-chip'; const meta = FLAT_COLORS[idx] || FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; styleChip(chip, meta); chip.title = meta?.name || meta?.hex || 'Color'; chip.addEventListener('click', () => { if (!window.openColorPicker) return; window.openColorPicker({ title: 'Path color', subtitle: 'Pick a main color', items: (FLAT_COLORS || []).map((c, ci) => ({ label: c.name || c.hex, metaText: c.family || '', idx: ci })), onSelect: (item) => { garlandMainIdx[i] = item.idx; renderGarlandMainChips(); if (mode === 'garland') requestDraw(); persist(); } }); }); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'btn-yellow text-xs px-2 py-1'; removeBtn.textContent = '×'; removeBtn.title = 'Remove color'; removeBtn.addEventListener('click', (e) => { e.stopPropagation(); garlandMainIdx.splice(i, 1); if (!garlandMainIdx.length) garlandMainIdx.push(selectedColorIdx); renderGarlandMainChips(); if (mode === 'garland') requestDraw(); persist(); }); wrap.appendChild(chip); wrap.appendChild(removeBtn); garlandMainChips.appendChild(wrap); }); } garlandAddColorBtn?.addEventListener('click', () => { if (garlandMainIdx.length >= garlandMaxColors) { showModal(`Max ${garlandMaxColors} colors.`); return; } if (!window.openColorPicker) return; window.openColorPicker({ title: 'Add path color', subtitle: 'Choose a main color', items: (FLAT_COLORS || []).map((c, ci) => ({ label: c.name || c.hex, metaText: c.family || '', idx: ci })), onSelect: (item) => { garlandMainIdx.push(item.idx); renderGarlandMainChips(); if (mode === 'garland') requestDraw(); persist(); } }); }); const updateAccentChip = () => { if (!garlandAccentChip) return; const meta = garlandAccentIdx >= 0 ? FLAT_COLORS[garlandAccentIdx] : null; styleChip(garlandAccentChip, meta || { hex: '#f8fafc' }); }; garlandAccentChip?.addEventListener('click', () => { if (!window.openColorPicker) return; window.openColorPicker({ title: 'Accent color', subtitle: 'Choose a 5" accent color', items: (FLAT_COLORS || []).map((c, ci) => ({ label: c.name || c.hex, metaText: c.family || '', idx: ci })), onSelect: (item) => { garlandAccentIdx = item.idx; updateAccentChip(); if (mode === 'garland') requestDraw(); persist(); } }); }); garlandAccentClearBtn?.addEventListener('click', () => { garlandAccentIdx = -1; updateAccentChip(); if (mode === 'garland') requestDraw(); persist(); }); bindActiveChipPicker(); // ====== UI Rendering (Palettes) ====== function renderAllowedPalette() { if (!paletteBox) return; paletteBox.innerHTML = ''; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn-dark w-full'; btn.textContent = 'Choose color'; btn.addEventListener('click', () => { if (!window.openColorPicker) return; window.openColorPicker({ title: 'Choose active color', subtitle: 'Applies to drawing and path tools', items: (FLAT_COLORS || []).map((c, idx) => ({ label: c.name || c.hex, metaText: c.family || '', idx })), onSelect: (item) => { if (!Number.isInteger(item.idx)) return; selectedColorIdx = item.idx; updateCurrentColorChip(); persist(); } }); }); paletteBox.appendChild(btn); } 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 = labelId ? document.getElementById(labelId) : null; 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 }); updateChip('mobile-active-color-chip', null, { showLabel: false }); updateChip('quick-color-chip', null, { showLabel: false }); } function bindActiveChipPicker() { const chips = ['current-color-chip', 'mobile-active-color-chip', 'quick-color-chip', 'quick-color-btn']; chips.forEach(id => { const el = document.getElementById(id); if (!el) return; el.style.cursor = 'pointer'; el.addEventListener('click', () => { if (!window.openColorPicker) return; window.openColorPicker({ title: 'Choose active color', subtitle: 'Applies to drawing and path tools', items: (FLAT_COLORS || []).map((c, idx) => ({ label: c.name || c.hex, metaText: c.family || '', idx })), onSelect: (item) => { if (!Number.isInteger(item.idx)) return; selectedColorIdx = item.idx; updateCurrentColorChip(); persist(); } }); }); }); } 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 = '
Palette opens in modal.
'; if (replaceFromSel) replaceFromSel.innerHTML = ''; } // ====== Balloon Ops & Data/Export ====== function buildBalloon(meta, x, y, radius) { const b = { kind: 'balloon', x, y, radius, rotationDeg: 0, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: makeId() }; return b; } function buildCurlObject(meta, x, y, radius) { return { kind: 'curl260', x, y, radius, rotationDeg: 0, color: meta.hex, image: null, colorIdx: meta._idx, id: makeId(), curl: buildCurrentRibbonConfig() }; } function buildWeightObject(meta, x, y, radius) { return { kind: 'weight', x, y, radius, rotationDeg: 0, color: meta.hex, image: null, colorIdx: meta._idx, id: makeId() }; } function buildRibbonObject(meta, fromNode, toNode, freeEnd) { return { kind: 'ribbon', color: meta.hex, image: null, colorIdx: meta._idx, id: makeId(), from: fromNode ? { kind: fromNode.kind, id: fromNode.id } : null, to: toNode ? { kind: toNode.kind, id: toNode.id } : null, freeEnd: freeEnd ? { x: freeEnd.x, y: freeEnd.y } : null, lengthScale: 1, rotationDeg: 0 }; } function resetRibbonDraft() { ribbonDraftStart = null; ribbonDraftMouse = null; } function handleRibbonPlacementAt(x, y) { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (!meta) return; const node = findRibbonNodeAt(x, y); if (!ribbonDraftStart) { if (!node || node.kind !== 'balloon') { showModal('Start ribbon on a balloon nozzle.'); return; } ribbonDraftStart = { kind: node.kind, id: node.id }; ribbonDraftMouse = { x, y }; requestDraw(); return; } const startObj = balloons.find(b => b.id === ribbonDraftStart.id); if (!startObj) { resetRibbonDraft(); return; } const targetNode = node && node.id !== ribbonDraftStart.id ? node : null; if (targetNode && targetNode.kind !== 'weight') { showModal('Ribbon can connect from balloon nozzle to a weight.'); return; } if (balloons.length >= MAX_BALLOONS) { showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); resetRibbonDraft(); return; } const ribbon = buildRibbonObject(meta, ribbonDraftStart, targetNode, targetNode ? null : { x, y }); balloons.push(ribbon); resetRibbonDraft(); refreshAll(); pushHistory(); } function scaleSelectedRibbons(multiplier) { const sel = selectionBalloons().filter(b => b?.kind === 'ribbon'); if (!sel.length) return; sel.forEach(r => { const base = Number(r.lengthScale) || 1; r.lengthScale = clamp(base * multiplier, 0.4, 2.2); }); refreshAll(); pushHistory(); } function startAttachSelectedRibbonsToWeight() { const sel = selectionBalloons().filter(b => b?.kind === 'ribbon'); if (!sel.length) return; ribbonAttachMode = true; showModal('Attach mode: click a weight to connect selected ribbon(s).'); } function attachSelectedRibbonsToWeight(weightId) { const sel = selectionBalloons().filter(b => b?.kind === 'ribbon'); if (!sel.length) return false; let changed = false; sel.forEach(r => { r.to = { kind: 'weight', id: weightId }; r.freeEnd = null; changed = true; }); ribbonAttachMode = false; if (changed) { refreshAll(); pushHistory(); } return changed; } 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; } const isHelium = getActiveOrganicTab() === '#tab-helium'; const placingCurl = isHelium && heliumPlacementType === 'curl260'; const placingWeight = isHelium && heliumPlacementType === 'weight'; balloons.push( placingCurl ? buildCurlObject(meta, x, y, currentRadius) : (placingWeight ? buildWeightObject(meta, x, y, currentRadius) : buildBalloon(meta, x, y, currentRadius)) ); lastAddStatus = 'balloon'; evtStats.addBalloon += 1; 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 (b?.kind === 'ribbon') { const hit = ribbonDistanceToPoint(b, x, y); if (hit <= Math.max(8 / view.s, 5)) return i; continue; } if (b?.kind === 'weight') { const grabR = Math.max(b.radius * 2.1, 22); if (Math.hypot(x - b.x, y - b.y) <= grabR) return i; if (weightHitTest(b, x, y, 0, { loose: true })) return i; continue; } const hitR = (b?.kind === 'curl260') ? (b.radius * 1.35) : getBalloonVisualRadius(b, getActiveOrganicTab()); if (Math.hypot(x - b.x, y - b.y) <= hitR) return i; } return -1; } function findWeightIndexAt(x, y) { for (let i = balloons.length - 1; i >= 0; i--) { const b = balloons[i]; if (b?.kind !== 'weight') continue; const grabR = Math.max(b.radius * 2.1, 22); if (Math.hypot(x - b.x, y - b.y) <= grabR) return i; if (weightHitTest(b, x, y, 0, { loose: true })) 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 => { if (b?.kind === 'ribbon') { if (b.freeEnd) { b.freeEnd.x += dx; b.freeEnd.y += dy; } return; } 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); let changed = false; sel.forEach(b => { if (!Number.isFinite(b.radius)) return; b.radius = newRadius; changed = true; }); if (!changed) return; refreshAll(); updateSelectButtons(); resizeChanged = true; clearTimeout(resizeSaveTimer); resizeSaveTimer = setTimeout(() => { if (resizeChanged) { pushHistory(); resizeChanged = false; } }, 200); } function rotateSelected(deltaDeg, { absolute = false } = {}) { const sel = selectionBalloons(); if (!sel.length) return; sel.forEach(b => { if (b?.kind === 'ribbon') return; const next = absolute ? Number(deltaDeg || 0) : ((Number(b.rotationDeg) || 0) + Number(deltaDeg || 0)); b.rotationDeg = normalizeRotationDeg(next); }); refreshAll(); pushHistory(); } 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; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; sel.forEach(b => { const bb = getObjectBounds(b); minX = Math.min(minX, bb.minX); maxX = Math.max(maxX, bb.maxX); minY = Math.min(minY, bb.minY); maxY = Math.max(maxY, bb.maxY); }); const width = Math.max(1, maxX - minX); const dx = width + 18; const dy = 0; const copies = sel.map(b => { const c = { ...b, id: makeId() }; if (Number.isFinite(c.x) && Number.isFinite(c.y)) { c.x += dx; c.y += dy; } if (c.freeEnd && Number.isFinite(c.freeEnd.x) && Number.isFinite(c.freeEnd.y)) { c.freeEnd = { x: c.freeEnd.x + dx, y: c.freeEnd.y + dy }; } return c; }); 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 => { if (b?.kind === 'ribbon') return ribbonDistanceToPoint(b, x, y) > Math.max(eraserRadius * 0.5, 6 / view.s); if (b?.kind === 'weight') return !weightHitTest(b, x, y, eraserRadius * 0.8); const hitR = (b?.kind === 'curl260') ? (b.radius * 1.35) : getBalloonVisualRadius(b, getActiveOrganicTab()); return Math.hypot(x - b.x, y - b.y) > (eraserRadius + hitR * 0.15); }); 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: makeId() }; }) : []; 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 = getActiveOrganicTab(); 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; } function pointsToPathD(points) { if (!Array.isArray(points) || points.length < 2) return ''; let d = `M ${points[0].x} ${points[0].y}`; for (let i = 1; i < points.length; i++) d += ` L ${points[i].x} ${points[i].y}`; return d; } function buildCurlPathPoints(b, ribbonCfg) { const cfg = ribbonCfg || buildCurrentRibbonConfig(); const seed = hashString32(b.id || `${b.x},${b.y}`); const side = (seed & 1) ? 1 : -1; const phase = ((seed >>> 3) % 628) / 100; const len = Math.max(20, b.radius * 2 * cfg.length); const stem = Math.max(6, b.radius * 0.28); const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012))); const turns = Math.max(1, Math.min(5, cfg.turns | 0)); const anchorX = b.x; const anchorY = b.y - b.radius * 0.55; const isSpiral = cfg.style === 'spiral'; const steps = isSpiral ? 42 : 28; const pts = [{ x: anchorX, y: anchorY }, { x: anchorX, y: anchorY + stem }]; if (isSpiral) { const leadSteps = Math.floor(steps * 0.33); const topLead = Math.max(8, len * 0.28); const topAmp = Math.max(6, b.radius * 0.95); for (let i = 1; i <= leadSteps; i++) { const t = i / leadSteps; const y = anchorY + stem + topLead * t; const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15); pts.push({ x, y }); } const startY = anchorY + stem + topLead; const remain = Math.max(8, len - topLead); const coilAmp = Math.max(5, b.radius * 0.5); const coilSteps = Math.max(1, steps - leadSteps); for (let i = 1; i <= coilSteps; i++) { const t = i / coilSteps; const angle = (Math.PI * 2 * turns * t) + phase; const decay = 1 - t * 0.55; const x = anchorX + side * (coilAmp * decay) * Math.sin(angle); const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle); pts.push({ x, y }); } } else { for (let i = 1; i <= steps; i++) { const t = i / steps; const falloff = 1 - t * 0.25; const y = anchorY + stem + len * t; const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side; pts.push({ x, y }); } } return pts; } async function buildOrganicSvgPayload() { if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.'); const activeOrgTab = getActiveOrganicTab(); const needsWeightImage = balloons.some(b => b?.kind === 'weight'); const uniqueImageUrls = [...new Set([ ...balloons.map(b => b.image).filter(Boolean), ...(needsWeightImage ? [WEIGHT_IMAGE_URL] : []) ])]; 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(); let clipCounter = 0; 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); const weightImageHref = dataUrlMap.get(WEIGHT_IMAGE_URL) || WEIGHT_IMAGE_URL; const ensureClipPath = (pathD, transform = '') => { const id = `clip-${clipCounter++}`; const tAttr = transform ? ` transform="${transform}"` : ''; defs += ``; return id; }; const wrapWithRotation = (markup, b) => { const deg = Number(b?.rotationDeg) || 0; if (!deg) return markup; return `${markup}`; }; balloons.forEach(b => { const kind = b?.kind || 'balloon'; const meta = FLAT_COLORS[b.colorIdx] || {}; if (kind === 'ribbon') { const pts = getRibbonPoints(b); if (!pts || pts.length < 2) return; const d = pointsToPathD(pts); const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#999999')) || { r: 120, g: 120, b: 120 }; const w = Math.max(1.9, 2.2); if (isBorderEnabled) { elements += ``; } elements += ``; if (b?.from?.kind === 'balloon') { const p0 = pts[0]; const p1 = pts[Math.min(1, pts.length - 1)]; const vx = p1.x - p0.x; const vy = p1.y - p0.y; const vl = Math.max(1e-6, Math.hypot(vx, vy)); const ux = vx / vl; const uy = vy / vl; const nx = -uy; const ny = ux; const side = (hashString32(b.id || '') & 1) ? 1 : -1; const amp = Math.max(3.2, 2.3); const len = Math.max(12, 8.5); const steps = 14; const curlPts = []; for (let i = 0; i <= steps; i++) { const t = i / steps; const dd = len * t; const x = p0.x + ux * dd + nx * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2); const y = p0.y + uy * dd + ny * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2); curlPts.push({ x, y }); } const curlD = pointsToPathD(curlPts); if (curlD) { if (isBorderEnabled) { elements += ``; } elements += ``; } } return; } if (kind === 'curl260') { const ribbonCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig(); const pts = buildCurlPathPoints(b, ribbonCfg); const d = pointsToPathD(pts); if (!d) return; const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#999999')) || { r: 120, g: 120, b: 120 }; const w = Math.max(2.6, 2.4 + b.radius * 0.14); let markup = ''; if (isBorderEnabled) { markup += ``; } markup += ``; elements += wrapWithRotation(markup, b); return; } if (kind === 'weight') { const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 }; if (weightMaskPathData) { const wt = getWeightMaskTransform(b); const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; const pathTx = `translate(${b.x} ${b.y}) scale(${wt.scale}) translate(${-mb.cx} ${-mb.cy})`; const clipId = ensureClipPath(weightMaskPathData, pathTx); let markup = ''; if (weightImageHref) { markup += ``; markup += ``; } else { markup += ``; } if (isBorderEnabled) { markup += ``; } elements += wrapWithRotation(markup, b); return; } const base = b.radius; const bagW = Math.max(18, base * 1.6); const bagH = Math.max(20, base * 2.2); const bagTopY = b.y - bagH * 0.95 + Math.max(5, bagH * 0.14) + 2; const bagD = `M ${b.x - bagW * 0.42} ${bagTopY} Q ${b.x - bagW * 0.62} ${b.y + bagH * 0.05} ${b.x - bagW * 0.34} ${b.y + bagH * 0.78} Q ${b.x} ${b.y + bagH * 0.98} ${b.x + bagW * 0.34} ${b.y + bagH * 0.78} Q ${b.x + bagW * 0.62} ${b.y + bagH * 0.05} ${b.x + bagW * 0.42} ${bagTopY} Z`; let markup = ``; if (isBorderEnabled) markup += ``; elements += wrapWithRotation(markup, b); return; } 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 sizeIndex = radiusToSizeIndex(b.radius); const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11; const maskInfo = getBalloonMaskShape(sizePreset, activeOrgTab); const hasMask = activeOrgTab === '#tab-helium' && !!maskInfo.path; const maskPathD = (activeOrgTab === '#tab-helium' && sizePreset === 24) ? balloon24MaskPathData : balloonMaskPathData; if (hasMask && maskPathD) { const mb = maskInfo.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 }; const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab); const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost; const pathTx = `translate(${b.x} ${b.y}) scale(${scale}) translate(${-mb.cx} ${-mb.cy})`; const clipId = ensureClipPath(maskPathD, pathTx); const destX = b.x - (mb.cx - mb.x) * scale; const destY = b.y - (mb.cy - mb.y) * scale; const destW = Math.max(1, mb.w * scale); const destH = Math.max(1, mb.h * scale); let markup = ''; if (b.image) { const imageHref = dataUrlMap.get(b.image) || b.image; markup += ``; } else { markup += ``; } if (isBorderEnabled) { markup += ``; } elements += wrapWithRotation(markup, b); } else { const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`; elements += wrapWithRotation(``, b); } if (isShineEnabled) { const visualRadius = getBalloonVisualRadius(b, activeOrgTab); const shineScale = hasMask ? ((sizePreset === 11) ? 0.68 : 0.8) : 1; const shineOffsetScale = hasMask ? 0.78 : 1; const sx = b.x - visualRadius * SHINE_OFFSET * shineOffsetScale; const sy = b.y - visualRadius * SHINE_OFFSET * shineOffsetScale; const rx = visualRadius * SHINE_RX * shineScale; const ry = visualRadius * SHINE_RY * shineScale; const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color); const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : ''; const shineFilter = shineShadowId ? ` filter="url(#${shineShadowId})"` : ''; const shineMarkup = ``; elements += wrapWithRotation(shineMarkup, b); } }); 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: makeId() }; }); } 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: makeId() }; }) : 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) { const bb = getObjectBounds(b); minX = Math.min(minX, bb.minX); minY = Math.min(minY, bb.minY); maxX = Math.max(maxX, bb.maxX); maxY = Math.max(maxY, bb.maxY); } 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 bb = getObjectBounds(b); const left = (bb.minX + view.tx) * view.s; const right = (bb.maxX + view.tx) * view.s; const top = (bb.minY + view.ty) * view.s; const bottom = (bb.maxY + 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 bb = getObjectBounds(b); const needSx = (cw - 2*pad) / Math.max(1, bb.w); const needSy = (ch - 2*pad) / Math.max(1, bb.h); 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 refreshGarlandColors = () => { renderGarlandMainChips(); updateAccentChip(); if (mode === 'garland') requestDraw(); persist(); }; refreshGarlandColors(); 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); rotateSelectedLeftBtn?.addEventListener('click', () => rotateSelected(-15)); rotateSelectedResetBtn?.addEventListener('click', () => rotateSelected(0, { absolute: true })); rotateSelectedRightBtn?.addEventListener('click', () => rotateSelected(15)); ribbonLengthDownBtn?.addEventListener('click', () => scaleSelectedRibbons(0.9)); ribbonLengthUpBtn?.addEventListener('click', () => scaleSelectedRibbons(1.1)); ribbonAttachWeightBtn?.addEventListener('click', startAttachSelectedRibbonsToWeight); applyColorBtn?.addEventListener('click', applyColorToSelected); fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true })); heliumPlaceBalloonBtn?.addEventListener('click', () => { heliumPlacementType = 'balloon'; resetRibbonDraft(); syncHeliumPlacementUi(); persist(); }); heliumPlaceCurlBtn?.addEventListener('click', () => { heliumPlacementType = 'curl260'; resetRibbonDraft(); syncHeliumPlacementUi(); persist(); }); heliumPlaceRibbonBtn?.addEventListener('click', () => { heliumPlacementType = 'ribbon'; resetRibbonDraft(); syncHeliumPlacementUi(); persist(); }); heliumPlaceWeightBtn?.addEventListener('click', () => { heliumPlacementType = 'weight'; resetRibbonDraft(); syncHeliumPlacementUi(); persist(); }); 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') && getActiveOrganicTab() !== '#tab-helium') setMode('garland'); else if (e.key === 'Escape') { if (ribbonAttachMode) { ribbonAttachMode = false; updateSelectButtons(); requestDraw(); return; } 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(); } else if (e.key === '[') { if (selectedIds.size) { e.preventDefault(); rotateSelected(-15); } } else if (e.key === ']') { if (selectedIds.size) { e.preventDefault(); rotateSelected(15); } } }); 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); }); updateReplaceChips(); } 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(); } const updateReplaceChips = () => { const fromHex = replaceFromSel?.value; const toIdx = parseInt(replaceToSel?.value || '-1', 10); const setChip = (chip, hex, meta = null) => { if (!chip) return; if (meta?.image) { chip.style.backgroundImage = `url("${meta.image}")`; chip.style.backgroundColor = meta.hex || '#fff'; chip.style.backgroundSize = 'cover'; } else { chip.style.backgroundImage = 'none'; chip.style.backgroundColor = hex || '#f1f5f9'; } }; const toMeta = Number.isInteger(toIdx) && toIdx >= 0 ? FLAT_COLORS[toIdx] : null; setChip(replaceFromChip, fromHex || '#f8fafc', null); setChip(replaceToChip, toMeta?.hex || '#f8fafc', toMeta); // count matches const targetHex = normalizeHex(fromHex || ''); let count = 0; if (targetHex) { balloons.forEach(b => { if (normalizeHex(b.color) === targetHex) count++; }); } if (replaceCountLabel) replaceCountLabel.textContent = count ? `${count} match${count === 1 ? '' : 'es'}` : '0 matches'; return count; }; const openReplacePicker = (mode = 'from') => { if (!window.openColorPicker) return; if (mode === 'from') { const used = getUsedColors(); const items = used.map(u => ({ label: u.name || NAME_BY_HEX.get(u.hex) || u.hex, metaText: `${u.count} in design`, hex: u.hex })); window.openColorPicker({ title: 'Replace: From color', subtitle: 'Pick a color that already exists on canvas', items, onSelect: (item) => { if (!replaceFromSel) return; replaceFromSel.value = item.hex; updateReplaceChips(); } }); } else { const items = (FLAT_COLORS || []).map((c, idx) => ({ label: c.name || c.hex, metaText: c.family || '', idx })); window.openColorPicker({ title: 'Replace: To color', subtitle: 'Choose any color from the library', items, onSelect: (item) => { if (!replaceToSel) return; replaceToSel.value = String(item.idx); updateReplaceChips(); } }); } }; replaceFromChip?.addEventListener('click', () => openReplacePicker('from')); replaceToChip?.addEventListener('click', () => openReplacePicker('to')); replaceFromSel?.addEventListener('change', updateReplaceChips); replaceToSel?.addEventListener('change', updateReplaceChips); replaceBtn?.addEventListener('click', () => { const fromHex = replaceFromSel?.value; const toIdx = parseInt(replaceToSel?.value || '', 10); if (!fromHex || Number.isNaN(toIdx)) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; } let count = 0; balloons.forEach(b => { if (normalizeHex(b.color) === normalizeHex(fromHex)) { const toMeta = FLAT_COLORS[toIdx]; b.color = toMeta.hex; b.image = toMeta.image || null; b.colorIdx = toMeta._idx; count++; } }); if (count > 0) { if (count > 80) { const ok = window.confirm(`Replace ${count} balloons? This cannot be undone except via Undo.`); if (!ok) return; } 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.'; } updateReplaceChips(); }); // ====== 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); if (getActiveOrganicTab() === '#tab-helium') heliumPlacementType = 'balloon'; [...sizePresetGroup.querySelectorAll('button')].forEach(b => b.setAttribute('aria-pressed', 'false')); btn.setAttribute('aria-pressed', 'true'); syncHeliumPlacementUi(); 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(); syncHeliumPlacementUi(); 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 handleOrganicTabChange = (nextTab) => { if (nextTab === lastActiveTab) return; saveAppState(lastActiveTab); lastActiveTab = nextTab; loadAppState(lastActiveTab); resetHistory(); refreshAll({ refit: true }); renderUsedPalette(); updateSelectButtons(); renderGarlandMainChips(); updateAccentChip(); syncHeliumToolUi(); syncHeliumPlacementUi(); }; const observer = new MutationObserver(() => { const next = getActiveOrganicTab(); handleOrganicTabChange(next); }); if (document.body) observer.observe(document.body, { attributes: true, attributeFilter: ['data-active-tab'] }); syncHeliumToolUi(); syncHeliumPlacementUi(); // Tab/mobile logic lives in script.js }); })();