balloonDesign/organic.js

2147 lines
79 KiB
JavaScript

// script.js
(() => {
'use strict';
// -----------------------------
// Organic app logic
// -----------------------------
document.addEventListener('DOMContentLoaded', () => {
// Shared values
const {
PX_PER_INCH,
SIZE_PRESETS,
TEXTURE_ZOOM_DEFAULT,
TEXTURE_FOCUS_DEFAULT,
SWATCH_TEXTURE_ZOOM,
PNG_EXPORT_SCALE,
clamp,
clamp01,
normalizeHex,
hexToRgb,
shineStyle,
luminance,
FLAT_COLORS,
NAME_BY_HEX,
HEX_TO_FIRST_IDX,
allowedSet,
getImage: sharedGetImage,
imageUrlToDataUrl,
download: sharedDownload,
XLINK_NS,
blobToDataUrl,
imageToDataUrl,
DATA_URL_CACHE,
} = window.shared || {};
if (!window.shared) return;
const getImageHrefShared = (el) => el.getAttribute('href') || el.getAttributeNS(window.shared.XLINK_NS, 'href');
const getImage = (path) => sharedGetImage(path, () => draw());
// ====== Shine ellipse tuning ======
const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.20; // ROT is now in degrees
let view = { s: 1, tx: 0, ty: 0 };
const FIT_PADDING_PX = 30;
const VIEW_MIN_SCALE = 0.12;
const VIEW_MAX_SCALE = 1.05;
const MAX_BALLOONS = 800;
// ====== Garland path defaults ======
const GARLAND_POINT_STEP = 8;
const GARLAND_BASE_DIAM = 18;
const GARLAND_FILLER_DIAMS = [11, 9];
const GARLAND_ACCENT_DIAM = 5;
const GARLAND_SPACING_RATIO = 0.85; // spacing along path vs base diameter
const GARLAND_WOBBLE_RATIO = 0.35;
const GARLAND_SIZE_JITTER = 0.14;
// Make sure shared palette is populated (fallback in case shared init missed)
if (Array.isArray(window.PALETTE) && FLAT_COLORS.length === 0) {
window.PALETTE.forEach(group => {
(group.colors || []).forEach(c => {
if (!c?.hex) return;
const item = { ...c, family: group.family };
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
item.imageFocus = {
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
};
item._idx = FLAT_COLORS.length;
FLAT_COLORS.push(item);
const key = (c.hex || '').toLowerCase();
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
allowedSet.add(key);
});
});
}
// Ensure palette exists if shared initialization was skipped
if (Array.isArray(window.PALETTE) && FLAT_COLORS.length === 0) {
window.PALETTE.forEach(group => {
(group.colors || []).forEach(c => {
if (!c?.hex) return;
const item = { ...c, family: group.family };
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
item.imageFocus = {
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
};
item._idx = FLAT_COLORS.length;
FLAT_COLORS.push(item);
const key = (c.hex || '').toLowerCase();
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
allowedSet.add(key);
});
});
}
const makeSeededRng = seed => {
let s = seed || 1;
return () => {
s ^= s << 13;
s ^= s >>> 17;
s ^= s << 5;
return (s >>> 0) / 4294967296;
};
};
const QUERY_KEY = 'd';
// ====== DOM ======
const canvas = document.getElementById('balloon-canvas');
const ctx = canvas?.getContext('2d');
const orgSheet = document.getElementById('controls-panel');
const claSheet = document.getElementById('classic-controls-panel');
const wallSheet = document.getElementById('wall-controls-panel');
// tool buttons
const toolDrawBtn = document.getElementById('tool-draw');
const toolGarlandBtn = document.getElementById('tool-garland');
const toolEraseBtn = document.getElementById('tool-erase');
const toolSelectBtn = document.getElementById('tool-select');
const toolUndoBtn = document.getElementById('tool-undo');
const toolRedoBtn = document.getElementById('tool-redo');
// panels/controls
const eraserControls = document.getElementById('eraser-controls');
const selectControls = document.getElementById('select-controls');
const eraserSizeInput = document.getElementById('eraser-size');
const eraserSizeLabel = document.getElementById('eraser-size-label');
const deleteSelectedBtn = document.getElementById('delete-selected');
const duplicateSelectedBtn = document.getElementById('duplicate-selected');
const selectedSizeInput = document.getElementById('selected-size');
const selectedSizeLabel = document.getElementById('selected-size-label');
const nudgeSelectedBtns = Array.from(document.querySelectorAll('.nudge-selected'));
const bringForwardBtn = document.getElementById('bring-forward');
const sendBackwardBtn = document.getElementById('send-backward');
const applyColorBtn = document.getElementById('apply-selected-color');
const fitViewBtn = document.getElementById('fit-view-btn');
const garlandDensityInput = document.getElementById('garland-density');
const garlandDensityLabel = document.getElementById('garland-density-label');
const garlandColorMain1Sel = document.getElementById('garland-color-main1');
const garlandColorMain2Sel = document.getElementById('garland-color-main2');
const garlandColorMain3Sel = document.getElementById('garland-color-main3');
const garlandColorMain4Sel = document.getElementById('garland-color-main4');
const garlandColorAccentSel = document.getElementById('garland-color-accent');
const garlandSwatchMain1 = document.getElementById('garland-swatch-main1');
const garlandSwatchMain2 = document.getElementById('garland-swatch-main2');
const garlandSwatchMain3 = document.getElementById('garland-swatch-main3');
const garlandSwatchMain4 = document.getElementById('garland-swatch-main4');
const garlandSwatchAccent = document.getElementById('garland-swatch-accent');
const garlandControls = document.getElementById('garland-controls');
const sizePresetGroup = document.getElementById('size-preset-group');
const toggleShineBtn = null;
const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox');
const toggleBorderCheckbox = document.getElementById('toggle-border-checkbox');
const paletteBox = document.getElementById('color-palette');
const usedPaletteBox = document.getElementById('used-palette');
const sortUsedToggle = document.getElementById('sort-used-toggle');
// replace colors panel
const replaceFromSel = document.getElementById('replace-from');
const replaceToSel = document.getElementById('replace-to');
const replaceBtn = document.getElementById('replace-btn');
const replaceMsg = document.getElementById('replace-msg');
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
// ====== State ======
let balloons = [];
let selectedColorIdx = 0;
let currentDiameterInches = 11;
let currentRadius = inchesToRadiusPx(currentDiameterInches);
let isShineEnabled = true; // will be initialized from localStorage
let isBorderEnabled = true;
let dpr = 1;
let mode = 'draw';
let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
let mouseInside = false;
let mousePos = { x: 0, y: 0 };
let selectedIds = new Set();
let usedSortDesc = true;
let garlandPath = [];
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
let garlandMainIdx = [0, 0, 0, 0];
let garlandAccentIdx = 0;
let lastCommitMode = '';
let lastAddStatus = '';
let evtStats = { down: 0, up: 0, cancel: 0, touchEnd: 0, addBalloon: 0, addGarland: 0, lastType: '' };
// History for Undo/Redo
const historyStack = [];
let historyPointer = -1;
function resetHistory() {
historyStack.length = 0;
historyPointer = -1;
pushHistory();
}
function updateHistoryUi() {
const canUndo = historyPointer > 0;
const canRedo = historyPointer < historyStack.length - 1;
if (toolUndoBtn) {
toolUndoBtn.disabled = !canUndo;
toolUndoBtn.title = canUndo ? 'Undo (Ctrl+Z)' : 'Nothing to undo';
}
if (toolRedoBtn) {
toolRedoBtn.disabled = !canRedo;
toolRedoBtn.title = canRedo ? 'Redo (Ctrl+Y)' : 'Nothing to redo';
}
}
function pushHistory() {
// Remove any future history if we are in the middle of the stack
if (historyPointer < historyStack.length - 1) {
historyStack.splice(historyPointer + 1);
}
// Deep clone balloons array
const snapshot = JSON.parse(JSON.stringify(balloons));
historyStack.push(snapshot);
historyPointer++;
// Limit stack size
if (historyStack.length > 50) {
historyStack.shift();
historyPointer--;
}
updateHistoryUi();
}
function undo() {
if (historyPointer > 0) {
historyPointer--;
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
selectedIds.clear(); // clear selection on undo to avoid issues
updateSelectButtons();
draw();
renderUsedPalette();
persist();
}
updateHistoryUi();
}
function redo() {
if (historyPointer < historyStack.length - 1) {
historyPointer++;
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
selectedIds.clear();
updateSelectButtons();
draw();
renderUsedPalette();
persist();
}
updateHistoryUi();
}
// Bind Undo/Redo Buttons
toolUndoBtn?.addEventListener('click', undo);
toolRedoBtn?.addEventListener('click', redo);
// Eyedropper Tool
const toolEyedropperBtn = document.getElementById('tool-eyedropper');
toolEyedropperBtn?.addEventListener('click', () => {
// Toggle eyedropper mode
if (mode === 'eyedropper') {
setMode('draw'); // toggle off
} else {
setMode('eyedropper');
}
});
function clampViewScale() {
view.s = clamp(view.s, VIEW_MIN_SCALE, VIEW_MAX_SCALE);
}
function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; }
function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; }
function fmtInches(val) {
const v = Math.round(val * 10) / 10;
return `${String(v).replace(/\.0$/, '')}"`;
}
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 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 (mode === 'garland' && next !== 'garland') {
garlandPath = [];
}
mode = next;
toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw'));
toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland'));
toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase'));
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
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 selectionArray() { return Array.from(selectedIds); }
function selectionBalloons() {
const set = new Set(selectedIds);
return balloons.filter(b => set.has(b.id));
}
function setSelection(ids, { additive = false } = {}) {
if (!additive) selectedIds.clear();
ids.forEach(id => selectedIds.add(id));
updateSelectButtons();
draw();
}
function primarySelection() {
const first = selectedIds.values().next();
return first.done ? null : first.value;
}
function clearSelection() { selectedIds.clear(); updateSelectButtons(); draw(); }
function updateSelectButtons() {
const has = selectedIds.size > 0;
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has;
if (selectedSizeInput) {
selectedSizeInput.disabled = !has;
selectedSizeInput.min = '5';
selectedSizeInput.max = '32';
selectedSizeInput.step = '0.5';
}
if (bringForwardBtn) bringForwardBtn.disabled = !has;
if (sendBackwardBtn) sendBackwardBtn.disabled = !has;
if (applyColorBtn) applyColorBtn.disabled = !has;
if (selectedSizeInput && selectedSizeLabel) {
if (has) {
const first = balloons.find(bb => selectedIds.has(bb.id));
if (first) {
const diam = radiusPxToInches(first.radius);
selectedSizeInput.value = String(Math.min(32, Math.max(5, diam)));
selectedSizeLabel.textContent = fmtInches(diam);
}
} else {
selectedSizeLabel.textContent = '0"';
}
}
}
// ====== Pointer Events ======
let pointerDown = false;
let isDragging = false;
let dragStartPos = { x: 0, y: 0 };
let initialBalloonPos = { x: 0, y: 0 };
let eraseChanged = false;
let dragMoved = false;
let resizeChanged = false;
let resizeSaveTimer = null;
let erasingActive = false;
let drawPending = false;
let dragOffsets = [];
let marqueeActive = false;
let marqueeStart = { x: 0, y: 0 };
let marqueeEnd = { x: 0, y: 0 };
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;
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;
}
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 === '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();
}
}
// 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);
balloons.forEach(b => {
if (b.image) {
const img = getImage(b.image);
if (img && img.complete && img.naturalWidth > 0) {
const meta = FLAT_COLORS[b.colorIdx] || {};
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
const srcW = img.naturalWidth / zoom;
const srcH = img.naturalHeight / zoom;
const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW);
const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
ctx.save();
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.clip();
const lum = luminance(meta.hex || b.color);
if (lum > 0.6) {
const strength = clamp01((lum - 0.6) / 0.4); // more shadow for lighter colors
ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`;
ctx.shadowBlur = 4 + 4 * strength;
ctx.shadowOffsetY = 1 + 2 * strength;
}
ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
ctx.stroke();
}
ctx.restore();
} else {
// fallback solid
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 10;
ctx.fill();
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
ctx.stroke();
}
ctx.shadowBlur = 0;
}
} else {
// solid fill
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 10;
ctx.fill();
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
ctx.stroke();
}
ctx.shadowBlur = 0;
}
if (isShineEnabled) {
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const sx = b.x - b.radius * SHINE_OFFSET;
const sy = b.y - b.radius * SHINE_OFFSET;
const rx = b.radius * SHINE_RX;
const ry = b.radius * SHINE_RY;
const rotRad = SHINE_ROT * Math.PI / 180;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 3; // SHINE_BLUR
ctx.beginPath();
if (ctx.ellipse) {
ctx.ellipse(sx, sy, rx, ry, rotRad, 0, Math.PI * 2);
} else {
ctx.translate(sx, sy);
ctx.rotate(rotRad);
ctx.scale(rx / ry, 1);
ctx.arc(0, 0, ry, 0, Math.PI * 2);
}
ctx.fillStyle = shineFill;
if (shineStroke) {
ctx.strokeStyle = shineStroke;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.fill();
ctx.restore();
}
});
// garland path preview
if (mode === 'garland' && garlandPath.length) {
ctx.save();
ctx.lineWidth = 1.5 / view.s;
ctx.strokeStyle = 'rgba(59,130,246,0.7)';
ctx.setLineDash([8 / view.s, 6 / view.s]);
ctx.beginPath();
garlandPath.forEach((p, idx) => {
if (idx === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
ctx.setLineDash([]);
const previewNodes = computeGarlandNodes(garlandPath).sort((a, b) => b.radius - a.radius);
ctx.strokeStyle = 'rgba(59,130,246,0.28)';
previewNodes.forEach(n => {
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
ctx.stroke();
});
ctx.restore();
}
// selection ring(s)
if (selectedIds.size) {
ctx.save();
selectedIds.forEach(id => {
const b = balloons.find(bb => bb.id === id);
if (!b) return;
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2);
ctx.lineWidth = 4 / view.s;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.stroke();
ctx.lineWidth = 2 / view.s;
ctx.strokeStyle = '#3b82f6';
ctx.stroke();
});
ctx.restore();
}
// marquee preview
if (mode === 'select' && marqueeActive) {
ctx.save();
ctx.setLineDash([6 / view.s, 4 / view.s]);
ctx.lineWidth = 1.5 / view.s;
ctx.strokeStyle = 'rgba(59,130,246,0.8)';
ctx.fillStyle = 'rgba(59,130,246,0.12)';
const x = Math.min(marqueeStart.x, marqueeEnd.x);
const y = Math.min(marqueeStart.y, marqueeEnd.y);
const w = Math.abs(marqueeStart.x - marqueeEnd.x);
const h = Math.abs(marqueeStart.y - marqueeEnd.y);
ctx.strokeRect(x, y, w, h);
ctx.fillRect(x, y, w, h);
ctx.restore();
}
// eraser preview
if (mode === 'erase' && mouseInside) {
ctx.save();
ctx.beginPath();
ctx.arc(mousePos.x, mousePos.y, eraserRadius, 0, Math.PI * 2);
ctx.lineWidth = 1.5 / view.s;
ctx.strokeStyle = 'rgba(31,41,55,0.8)';
ctx.setLineDash([4 / view.s, 4 / view.s]);
ctx.stroke();
ctx.restore();
}
// eyedropper preview
if (mode === 'eyedropper' && mouseInside) {
ctx.save();
ctx.beginPath();
ctx.arc(mousePos.x, mousePos.y, 10 / view.s, 0, Math.PI * 2);
ctx.lineWidth = 2 / view.s;
ctx.strokeStyle = '#fff';
ctx.stroke();
ctx.lineWidth = 1 / view.s;
ctx.strokeStyle = '#000';
ctx.stroke();
ctx.restore();
}
ctx.restore();
// Debug overlay
const dbg = [
`mode:${mode}`,
`balloons:${balloons.length}`,
`garlandLen:${garlandPath.length}`,
`pointerDown:${pointerDown}`,
`lastCommit:${lastCommitMode || '-'}`,
`lastAdd:${lastAddStatus || '-'}`,
`dpr:${dpr.toFixed(2)} s:${view.s.toFixed(2)} 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';
function saveAppState() {
// Note: isShineEnabled is managed globally.
const state = {
balloons,
selectedColorIdx,
currentDiameterInches,
eraserRadius,
view,
usedSortDesc,
garlandDensity,
garlandMainIdx,
garlandAccentIdx,
isBorderEnabled
};
try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {}
}
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
function loadAppState() {
try {
const s = JSON.parse(localStorage.getItem(APP_STATE_KEY) || '{}');
if (Array.isArray(s.balloons)) balloons = s.balloons;
if (typeof s.selectedColorIdx === 'number') selectedColorIdx = s.selectedColorIdx;
if (typeof s.currentDiameterInches === 'number') {
currentDiameterInches = s.currentDiameterInches;
currentRadius = inchesToRadiusPx(currentDiameterInches);
}
if (typeof s.eraserRadius === 'number') {
eraserRadius = s.eraserRadius;
if (eraserSizeInput) eraserSizeInput.value = eraserRadius;
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
}
if (s.view && typeof s.view.s === 'number') { view = s.view; clampViewScale(); }
if (typeof s.usedSortDesc === 'boolean') {
usedSortDesc = s.usedSortDesc;
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
}
if (typeof s.garlandDensity === 'number') {
garlandDensity = clamp(s.garlandDensity, 0.6, 1.6);
if (garlandDensityInput) garlandDensityInput.value = garlandDensity;
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
}
if (Array.isArray(s.garlandMainIdx)) {
garlandMainIdx = s.garlandMainIdx.slice(0, 4).map(v => Number(v) || -1);
while (garlandMainIdx.length < 4) garlandMainIdx.push(-1);
}
if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx;
if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled;
if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled;
updateCurrentColorChip();
} catch {}
}
loadAppState();
resetHistory(); // establish initial history state for undo/redo controls
// ====== UI Rendering (Palettes) ======
function renderAllowedPalette() {
if (!paletteBox) return;
paletteBox.innerHTML = '';
(window.PALETTE || []).forEach(group => {
const title = document.createElement('div');
title.className = 'family-title';
title.textContent = group.family;
paletteBox.appendChild(title);
const row = document.createElement('div');
row.className = 'swatch-row';
(group.colors || []).forEach(c => {
const idx =
FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx
?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex));
const sw = document.createElement('button');
sw.type = 'button';
sw.className = 'swatch';
sw.setAttribute('aria-label', c.name);
if (c.image) {
const meta = FLAT_COLORS[idx] || {};
sw.style.backgroundImage = `url("${c.image}")`;
sw.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`;
sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
} else {
sw.style.backgroundColor = c.hex;
}
if (idx === selectedColorIdx) sw.classList.add('active');
sw.title = c.name;
sw.addEventListener('click', () => {
selectedColorIdx = idx ?? 0;
renderAllowedPalette();
updateCurrentColorChip();
persist();
});
row.appendChild(sw);
});
paletteBox.appendChild(row);
});
}
function getUsedColors() {
const map = new Map();
balloons.forEach(b => {
const key = normalizeHex(b.color);
if (!allowedSet.has(key)) return;
if (!map.has(key)) {
const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(key)] || {};
map.set(key, { hex: key, count: 0, image: meta.image, name: meta.name });
}
map.get(key).count++;
});
const arr = [...map.values()];
arr.sort((a, b) => (usedSortDesc ? (b.count - a.count) : (a.count - b.count)));
return arr;
}
function updateCurrentColorChip() {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
const updateChip = (chipId, labelId, { showLabel = true } = {}) => {
const chip = document.getElementById(chipId);
const label = 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 });
}
window.organic = {
getColor: () => selectedColorIdx,
updateCurrentColorChip,
setColor: (idx) => {
if (!Number.isInteger(idx)) return;
selectedColorIdx = idx;
renderAllowedPalette?.();
renderUsedPalette?.();
updateCurrentColorChip?.();
persist?.();
},
buildOrganicSvgPayload,
};
function renderUsedPalette() {
if (!usedPaletteBox) return;
usedPaletteBox.innerHTML = '';
const used = getUsedColors();
if (used.length === 0) {
usedPaletteBox.innerHTML = '<div class="hint">No colors yet.</div>';
if (replaceFromSel) replaceFromSel.innerHTML = '';
return;
}
const row = document.createElement('div');
row.className = 'swatch-row';
used.forEach(item => {
const sw = document.createElement('button');
sw.type = 'button';
sw.className = 'swatch';
const name = item.name || NAME_BY_HEX.get(item.hex) || item.hex;
sw.setAttribute('aria-label', `${name} - Count: ${item.count}`);
if (item.image) {
const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(item.hex)] || {};
sw.style.backgroundImage = `url("${item.image}")`;
sw.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`;
sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
} else {
sw.style.backgroundColor = item.hex;
}
if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === item.hex) sw.classList.add('active');
sw.title = `${name}${item.count}`;
sw.addEventListener('click', () => {
selectedColorIdx = HEX_TO_FIRST_IDX.get(item.hex) ?? 0;
renderAllowedPalette();
renderUsedPalette();
});
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = String(item.count);
sw.appendChild(badge);
row.appendChild(sw);
});
usedPaletteBox.appendChild(row);
// fill "replace from"
if (replaceFromSel) {
replaceFromSel.innerHTML = '';
used.forEach(item => {
const opt = document.createElement('option');
const name = item.name || NAME_BY_HEX.get(item.hex) || item.hex;
opt.value = item.hex;
opt.textContent = `${name} (${item.count})`;
replaceFromSel.appendChild(opt);
});
updateReplaceChips();
}
}
// ====== Balloon Ops & Data/Export ======
function buildBalloon(meta, x, y, radius) {
return {
x, y,
radius,
color: meta.hex,
image: meta.image || null,
colorIdx: meta._idx,
id: makeId()
};
}
function addBalloon(x, y) {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
if (balloons.length >= MAX_BALLOONS) {
lastAddStatus = 'limit';
showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`);
return;
}
balloons.push(buildBalloon(meta, x, y, currentRadius));
lastAddStatus = 'balloon';
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 (Math.hypot(x - b.x, y - b.y) <= b.radius) return i;
}
return -1;
}
function selectAt(x, y) {
const i = findBalloonIndexAt(x, y);
selectedIds.clear();
if (i !== -1) selectedIds.add(balloons[i].id);
updateSelectButtons();
draw();
}
function moveSelected(dx, dy) {
const sel = selectionBalloons();
if (!sel.length) return;
sel.forEach(b => { b.x += dx; b.y += dy; });
refreshAll();
pushHistory();
}
function resizeSelected(newDiamInches) {
const sel = selectionBalloons();
if (!sel.length) return;
const diam = clamp(newDiamInches, 5, 32);
const newRadius = inchesToRadiusPx(diam);
sel.forEach(b => { b.radius = newRadius; });
refreshAll();
updateSelectButtons();
resizeChanged = true;
clearTimeout(resizeSaveTimer);
resizeSaveTimer = setTimeout(() => {
if (resizeChanged) {
pushHistory();
resizeChanged = false;
}
}, 200);
}
function bringSelectedForward() {
const sel = selectionArray();
if (!sel.length) return;
const set = new Set(sel);
const kept = balloons.filter(b => !set.has(b.id));
const moving = balloons.filter(b => set.has(b.id));
balloons = kept.concat(moving);
refreshAll({ autoFit: true });
pushHistory();
}
function sendSelectedBackward() {
const sel = selectionArray();
if (!sel.length) return;
const set = new Set(sel);
const moving = balloons.filter(b => set.has(b.id));
const kept = balloons.filter(b => !set.has(b.id));
balloons = moving.concat(kept);
refreshAll({ autoFit: true });
pushHistory();
}
function applyColorToSelected() {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
if (!meta) return;
let changed = false;
selectionBalloons().forEach(b => {
b.color = meta.hex;
b.image = meta.image || null;
b.colorIdx = meta._idx;
changed = true;
});
if (!changed) return;
refreshAll();
pushHistory();
}
function deleteSelected() {
if (!selectedIds.size) return;
balloons = balloons.filter(b => !selectedIds.has(b.id));
selectedIds.clear();
updateSelectButtons();
refreshAll({ autoFit: true });
pushHistory();
}
function duplicateSelected() {
const sel = selectionBalloons();
if (!sel.length) return;
const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: makeId() }));
copies.forEach(c => balloons.push(c));
selectedIds = new Set(copies.map(c => c.id));
refreshAll({ autoFit: true });
updateSelectButtons();
pushHistory();
}
function eraseAt(x, y) {
const before = balloons.length;
balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius);
const removed = balloons.length !== before;
if (selectedIds.size) {
const set = new Set(balloons.map(b => b.id));
let changed = false;
selectedIds.forEach(id => { if (!set.has(id)) { selectedIds.delete(id); changed = true; } });
if (changed) updateSelectButtons();
}
if (removed && !erasingActive) requestDraw();
return removed;
}
function pickColorAt(x, y) {
const i = findBalloonIndexAt(x, y);
if (i !== -1) {
selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0;
renderAllowedPalette();
renderUsedPalette();
updateCurrentColorChip();
}
}
function promptForFilename(suggested) {
const m = suggested.match(/\.([a-z0-9]+)$/i);
const ext = m ? m[1].toLowerCase() : '';
const defaultBase = suggested.replace(/\.[^.]+$/, '');
const lsKey = ext ? `lastFilenameBase.${ext}` : `lastFilenameBase`;
const last = localStorage.getItem(lsKey) || defaultBase;
const input = window.prompt(ext ? `File name (.${ext} will be added)` : 'File name', last);
if (input === null) return null;
let base = (input.trim() || defaultBase)
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
.replace(/\.+$/, '')
.replace(/\.[^.]+$/, '');
try { localStorage.setItem(lsKey, base); } catch {}
return ext ? `${base}.${ext}` : base;
}
function download(href, suggestedFilename) {
const finalName = promptForFilename(suggestedFilename);
if (!finalName) return;
sharedDownload?.(href, finalName);
}
function saveJson() {
download('data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify({ balloons })), 'balloon_design.json');
}
function loadJson(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
const loaded = Array.isArray(data.balloons)
? data.balloons.map(b => {
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
const meta = FLAT_COLORS[idx] || {};
return {
x: b.x, y: b.y, radius: b.radius,
color: meta.hex || b.color,
image: meta.image || null,
colorIdx: idx,
id: 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 = '#tab-organic';
const getImageHref = getImageHrefShared;
function setImageHref(el, val) {
el.setAttribute('href', val);
el.setAttributeNS(XLINK_NS, 'xlink:href', val);
}
async function embedImagesInSvg(svgEl) {
const images = Array.from(svgEl.querySelectorAll('image'));
const hrefs = [...new Set(images.map(getImageHref).filter(h => h && !h.startsWith('data:')))];
const urlMap = new Map();
await Promise.all(hrefs.map(async (href) => {
urlMap.set(href, await imageUrlToDataUrl(href));
}));
images.forEach(img => {
const orig = getImageHref(img);
const val = urlMap.get(orig);
if (val) setImageHref(img, val);
});
return svgEl;
}
async function buildOrganicSvgPayload() {
if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.');
const uniqueImageUrls = [...new Set(balloons.map(b => b.image).filter(Boolean))];
const dataUrlMap = new Map();
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
const bounds = balloonsBounds();
const pad = 120; // extra room to avoid clipping drop-shadows and outlines
const width = bounds.w + pad * 2;
const height = bounds.h + pad * 2;
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');
let defs = '';
let elements = '';
const patterns = new Map();
const shadowFilters = new Map();
const ensureShadowFilter = (dx, dy, blurPx, alpha) => {
const key = `${dx}|${dy}|${blurPx}|${alpha}`;
if (!shadowFilters.has(key)) {
const id = `shadow-${shadowFilters.size}`;
const stdDev = Math.max(0.01, blurPx * 0.5);
const clampedAlpha = clamp01(alpha);
const flood = `<feFlood flood-color="#000000" flood-opacity="${clampedAlpha}" />`;
const blur = `<feGaussianBlur in="SourceAlpha" stdDeviation="${stdDev}" result="blur" />`;
const offset = `<feOffset dx="${dx}" dy="${dy}" in="blur" result="shadow" />`;
const composite = `<feComposite in="shadow" in2="SourceAlpha" operator="in" result="shadow" />`;
const merge = `<feMerge><feMergeNode in="shadow" /><feMergeNode in="SourceGraphic" /></feMerge>`;
defs += `<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${flood}${blur}${offset}${composite}${merge}</filter>`;
shadowFilters.set(key, id);
}
return shadowFilters.get(key);
};
const shineShadowId = ensureShadowFilter(0, 0, 3, 0.1);
balloons.forEach(b => {
const meta = FLAT_COLORS[b.colorIdx] || {};
let fill = b.color;
if (b.image) {
const patternKey = `${b.colorIdx}|${b.image}`;
const imageHref = dataUrlMap.get(b.image);
if (!patterns.has(patternKey) && imageHref) {
const patternId = `p${patterns.size}`;
patterns.set(patternKey, patternId);
const meta = FLAT_COLORS[b.colorIdx] || {};
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
const imgW = zoom, imgH = zoom;
const imgX = 0.5 - (fx * zoom);
const imgY = 0.5 - (fy * zoom);
defs += `<pattern id="${patternId}" patternContentUnits="objectBoundingBox" width="1" height="1">
<image href="${imageHref}" x="${imgX}" y="${imgY}" width="${imgW}" height="${imgH}" preserveAspectRatio="xMidYMid slice" />
</pattern>`;
}
if (patterns.has(patternKey)) fill = `url(#${patterns.get(patternKey)})`;
}
let filterAttr = '';
if (b.image) {
const lum = luminance(meta.hex || b.color);
if (lum > 0.6) {
const strength = clamp01((lum - 0.6) / 0.4);
const blur = 4 + 4 * strength;
const offsetY = 1 + 2 * strength;
const alpha = 0.05 + 0.07 * strength;
const id = ensureShadowFilter(0, offsetY, blur, alpha);
filterAttr = ` filter="url(#${id})"`;
}
} else {
const id = ensureShadowFilter(0, 0, 10, 0.2);
filterAttr = ` filter="url(#${id})"`;
}
const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
elements += `<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}"${strokeAttr}${filterAttr} />`;
if (isShineEnabled) {
const sx = b.x - b.radius * SHINE_OFFSET;
const sy = b.y - b.radius * SHINE_OFFSET;
const rx = b.radius * SHINE_RX;
const ry = b.radius * SHINE_RY;
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : '';
const shineFilter = shineShadowId ? ` filter="url(#${shineShadowId})"` : '';
elements += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke}${shineFilter} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
}
});
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="${XLINK_NS}" viewBox="${vb}" width="${width}" height="${height}">
<defs>${defs}</defs>
${elements}
</svg>`;
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) {
minX = Math.min(minX, b.x - b.radius);
minY = Math.min(minY, b.y - b.radius);
maxX = Math.max(maxX, b.x + b.radius);
maxY = Math.max(maxY, b.y + b.radius);
}
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
}
function fitView() {
const box = balloonsBounds();
const cw = canvas.width / dpr; // CSS px
const ch = canvas.height / dpr;
if (balloons.length === 0) { view.s = 1; clampViewScale(); view.tx = 0; view.ty = 0; return; }
const pad = FIT_PADDING_PX;
const w = Math.max(1, box.w);
const h = Math.max(1, box.h);
const sFit = Math.min((cw - 2*pad) / w, (ch - 2*pad) / h);
view.s = Math.min(VIEW_MAX_SCALE, isFinite(sFit) && sFit > 0 ? sFit : 1);
clampViewScale();
const worldW = cw / view.s;
const worldH = ch / view.s;
view.tx = (worldW - w) * 0.5 - box.minX;
view.ty = (worldH - h) * 0.5 - box.minY;
}
function balloonScreenBounds(b) {
const left = (b.x - b.radius + view.tx) * view.s;
const right = (b.x + b.radius + view.tx) * view.s;
const top = (b.y - b.radius + view.ty) * view.s;
const bottom = (b.y + b.radius + view.ty) * view.s;
return { left, right, top, bottom };
}
function ensureVisibleAfterAdd(b) {
const pad = FIT_PADDING_PX;
const cw = canvas.width / dpr;
const ch = canvas.height / dpr;
// zoom out only if needed to keep the new balloon visible
const needSx = (cw - 2*pad) / (2*b.radius);
const needSy = (ch - 2*pad) / (2*b.radius);
const sNeeded = Math.min(needSx, needSy);
if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) {
view.s = Math.max(VIEW_MIN_SCALE, sNeeded);
}
clampViewScale();
const r = balloonScreenBounds(b);
let dx = 0, dy = 0;
if (r.left < pad) dx = (pad - r.left) / view.s;
else if (r.right > cw-pad) dx = ((cw - pad) - r.right) / view.s;
if (r.top < pad) dy = (pad - r.top) / view.s;
else if (r.bottom > ch-pad) dy = ((ch - pad) - r.bottom) / view.s;
view.tx += dx;
view.ty += dy;
return (dx !== 0 || dy !== 0);
}
// ====== Refresh & Events ======
function refreshAll({ refit = false, autoFit = false } = {}) {
if (refit) fitView();
else if (autoFit) fitView();
draw();
renderUsedPalette();
persist();
if(window.updateExportButtonVisibility) window.updateExportButtonVisibility();
}
// --- UI bindings ---
modalCloseBtn?.addEventListener('click', hideModal);
toolDrawBtn?.addEventListener('click', () => setMode('draw'));
toolGarlandBtn?.addEventListener('click', () => setMode('garland'));
toolEraseBtn?.addEventListener('click', () => setMode('erase'));
toolSelectBtn?.addEventListener('click', () => setMode('select'));
eraserSizeInput?.addEventListener('input', e => {
eraserRadius = parseInt(e.target.value, 10);
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
if (mode === 'erase') draw();
persist();
});
toggleBorderCheckbox?.addEventListener('change', e => {
isBorderEnabled = !!e.target.checked;
draw();
persist();
});
garlandDensityInput?.addEventListener('input', e => {
garlandDensity = clamp(parseFloat(e.target.value) || 1, 0.6, 1.6);
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
if (mode === 'garland') requestDraw();
persist();
});
const handleGarlandColorChange = () => {
updateGarlandSwatches();
persist();
if (mode === 'garland') requestDraw();
};
garlandColorMain1Sel?.addEventListener('change', e => {
garlandMainIdx[0] = parseInt(e.target.value, 10) || -1;
handleGarlandColorChange();
});
garlandColorMain2Sel?.addEventListener('change', e => {
garlandMainIdx[1] = parseInt(e.target.value, 10) || -1;
handleGarlandColorChange();
});
garlandColorMain3Sel?.addEventListener('change', e => {
garlandMainIdx[2] = parseInt(e.target.value, 10) || -1;
handleGarlandColorChange();
});
garlandColorMain4Sel?.addEventListener('change', e => {
garlandMainIdx[3] = parseInt(e.target.value, 10) || -1;
handleGarlandColorChange();
});
garlandColorAccentSel?.addEventListener('change', e => {
garlandAccentIdx = parseInt(e.target.value, 10) || -1;
handleGarlandColorChange();
});
deleteSelectedBtn?.addEventListener('click', deleteSelected);
duplicateSelectedBtn?.addEventListener('click', duplicateSelected);
nudgeSelectedBtns.forEach(btn => btn.addEventListener('click', () => {
const dx = Number(btn.dataset.dx || 0);
const dy = Number(btn.dataset.dy || 0);
moveSelected(dx, dy);
}));
selectedSizeInput?.addEventListener('input', e => {
resizeSelected(parseFloat(e.target.value) || 0);
});
selectedSizeInput?.addEventListener('pointerdown', () => {
resizeChanged = false;
clearTimeout(resizeSaveTimer);
});
selectedSizeInput?.addEventListener('pointerup', () => {
clearTimeout(resizeSaveTimer);
if (resizeChanged) {
pushHistory();
resizeChanged = false;
}
});
bringForwardBtn?.addEventListener('click', bringSelectedForward);
sendBackwardBtn?.addEventListener('click', sendSelectedBackward);
applyColorBtn?.addEventListener('click', applyColorToSelected);
fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true }));
document.addEventListener('keydown', e => {
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
if (e.key === 'e' || e.key === 'E') setMode('erase');
else if (e.key === 'v' || e.key === 'V') setMode('draw');
else if (e.key === 's' || e.key === 'S') setMode('select');
else if (e.key === 'g' || e.key === 'G') setMode('garland');
else if (e.key === 'Escape') {
if (selectedIds.size) {
clearSelection();
} else if (mode !== 'draw') {
setMode('draw');
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.size) { e.preventDefault(); deleteSelected(); }
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault();
undo();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
e.preventDefault();
redo();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') {
e.preventDefault();
duplicateSelected();
}
});
async function confirmAndClear() {
let ok = true;
if (window.Swal) {
const res = await Swal.fire({
title: 'Start fresh?',
text: 'This will remove all balloons from the canvas.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, clear',
cancelButtonText: 'Cancel'
});
ok = res.isConfirmed;
} else {
ok = window.confirm('Start fresh? This will remove all balloons from the canvas.');
}
if (!ok) return;
balloons = [];
selectedIds.clear();
garlandPath = [];
updateSelectButtons();
refreshAll({ refit: true });
pushHistory();
}
clearCanvasBtn?.addEventListener('click', confirmAndClear);
clearCanvasBtnTop?.addEventListener('click', confirmAndClear);
saveJsonBtn?.addEventListener('click', saveJson);
loadJsonInput?.addEventListener('change', loadJson);
generateLinkBtn?.addEventListener('click', generateShareLink);
sortUsedToggle?.addEventListener('click', () => {
usedSortDesc = !usedSortDesc;
sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
renderUsedPalette();
persist();
});
function populateReplaceTo() {
if (!replaceToSel) return;
replaceToSel.innerHTML = '';
(window.PALETTE || []).forEach(group => {
const og = document.createElement('optgroup');
og.label = group.family;
(group.colors || []).forEach(c => {
const idx =
FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx
?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)) ?? 0;
const opt = document.createElement('option');
opt.value = String(idx);
opt.textContent = c.name + (c.image ? ' (image)' : '');
og.appendChild(opt);
});
replaceToSel.appendChild(og);
});
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();
}
function updateGarlandSwatches() {
const setSw = (sw, idx) => {
if (!sw) return;
const meta = idx >= 0 ? FLAT_COLORS[idx] : null;
if (meta?.image) {
sw.style.backgroundImage = `url("${meta.image}")`;
sw.style.backgroundColor = meta.hex || '#fff';
sw.style.backgroundSize = 'cover';
} else {
sw.style.backgroundImage = 'none';
sw.style.backgroundColor = meta?.hex || '#f1f5f9';
}
};
setSw(garlandSwatchMain1, garlandMainIdx[0]);
setSw(garlandSwatchMain2, garlandMainIdx[1]);
setSw(garlandSwatchMain3, garlandMainIdx[2]);
setSw(garlandSwatchMain4, garlandMainIdx[3]);
setSw(garlandSwatchAccent, garlandAccentIdx);
}
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);
[...sizePresetGroup.querySelectorAll('button')].forEach(b => b.setAttribute('aria-pressed', 'false'));
btn.setAttribute('aria-pressed', 'true');
persist();
});
sizePresetGroup?.appendChild(btn);
});
toggleShineCheckbox?.addEventListener('change', e => {
const on = !!e.target.checked;
window.syncAppShine(on);
});
mode = 'draw'; // force default tool on load
renderAllowedPalette();
resizeCanvas();
loadFromUrl();
renderUsedPalette();
// Initialize wall designer if available (wall.js sets this)
window.WallDesigner?.init?.();
setMode('draw');
updateSelectButtons();
populateReplaceTo();
populateGarlandColorSelects();
// default to canvas-first on mobile; no expansion toggles remain
// Initialize shine state from localStorage for both panels
let initialShineState = true;
try {
const saved = localStorage.getItem('app:shineEnabled:v1');
if (saved !== null) initialShineState = JSON.parse(saved);
} catch {}
// Set Organic panel's internal state and UI
isShineEnabled = initialShineState;
if (toggleShineCheckbox) toggleShineCheckbox.checked = isShineEnabled;
// Set Classic panel's UI checkbox (its script will read this too)
const classicCb = document.getElementById('classic-shine-enabled');
if (classicCb) classicCb.checked = isShineEnabled;
// ===============================
// ===== TAB SWITCHING (UI) ======
// ===============================
const orgSection = document.getElementById('tab-organic');
const claSection = document.getElementById('tab-classic');
const wallSection = document.getElementById('tab-wall');
const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn');
const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1';
// Tab/mobile logic lives in script.js
});
})();