balloonDesign/organic.js

2206 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 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');
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];
let garlandAccentIdx = -1;
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 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, 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;
updateCurrentColorChip();
} 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();
});
// ====== 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 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);
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();
}
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
});
})();