balloonDesign/organic.js

3940 lines
154 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';
const BALLOON_MASK_URL = 'images/balloon-mask.svg';
const BALLOON_24_MASK_URL = 'images/24-balloon_mask.svg';
const WEIGHT_MASK_URL = 'images/weight-mask.svg';
const WEIGHT_IMAGE_URL = 'images/weight.webp';
const WEIGHT_VISUAL_SCALE = 0.56;
const HELIUM_CUFT_BY_SIZE = { 11: 0.5, 18: 2, 24: 5 };
const HELIUM_CUFT_BASE_SIZE = 11;
const HELIUM_CUFT_BASE_VALUE = 0.5;
let balloonMaskPath = null;
let balloonMaskPathData = '';
let balloonMaskViewBox = { x: 0, y: 0, w: 1, h: 1 };
let balloonMaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
let balloonMaskLoaded = false;
let balloonMaskLoading = false;
let balloon24MaskPath = null;
let balloon24MaskPathData = '';
let balloon24MaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
let balloon24MaskLoaded = false;
let balloon24MaskLoading = false;
let balloonMaskDrawFailed = false;
let weightMaskPath = null;
let weightMaskPathData = '';
let weightMaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
let weightMaskLoaded = false;
let weightMaskLoading = false;
// ====== DOM ======
const canvas = document.getElementById('balloon-canvas');
const ctx = canvas?.getContext('2d');
const orgSheet = document.getElementById('controls-panel');
const claSheet = document.getElementById('classic-controls-panel');
const wallSheet = document.getElementById('wall-controls-panel');
// tool buttons
const toolDrawBtn = document.getElementById('tool-draw');
const toolGarlandBtn = document.getElementById('tool-garland');
const toolEraseBtn = document.getElementById('tool-erase');
const toolSelectBtn = document.getElementById('tool-select');
const toolUndoBtn = document.getElementById('tool-undo');
const toolRedoBtn = document.getElementById('tool-redo');
// panels/controls
const eraserControls = document.getElementById('eraser-controls');
const selectControls = document.getElementById('select-controls');
const eraserSizeInput = document.getElementById('eraser-size');
const eraserSizeLabel = document.getElementById('eraser-size-label');
const deleteSelectedBtn = document.getElementById('delete-selected');
const duplicateSelectedBtn = document.getElementById('duplicate-selected');
const selectedSizeInput = document.getElementById('selected-size');
const selectedSizeLabel = document.getElementById('selected-size-label');
const nudgeSelectedBtns = Array.from(document.querySelectorAll('.nudge-selected'));
const bringForwardBtn = document.getElementById('bring-forward');
const sendBackwardBtn = document.getElementById('send-backward');
const rotateSelectedLeftBtn = document.getElementById('rotate-selected-left');
const rotateSelectedResetBtn = document.getElementById('rotate-selected-reset');
const rotateSelectedRightBtn = document.getElementById('rotate-selected-right');
const ribbonLengthDownBtn = document.getElementById('ribbon-length-down');
const ribbonLengthUpBtn = document.getElementById('ribbon-length-up');
const ribbonAttachWeightBtn = document.getElementById('ribbon-attach-weight');
const applyColorBtn = document.getElementById('apply-selected-color');
const fitViewBtn = document.getElementById('fit-view-btn');
const garlandDensityInput = document.getElementById('garland-density');
const garlandDensityLabel = document.getElementById('garland-density-label');
const garlandMainChips = document.getElementById('garland-main-chips');
const garlandAddColorBtn = document.getElementById('garland-add-color');
const garlandAccentChip = document.getElementById('garland-accent-chip');
const garlandAccentClearBtn = document.getElementById('garland-accent-clear');
const garlandControls = document.getElementById('garland-controls');
// Optional dropdowns (may not be present in current layout)
const garlandColorMain1Sel = document.getElementById('garland-color-main-1');
const garlandColorMain2Sel = document.getElementById('garland-color-main-2');
const garlandColorMain3Sel = document.getElementById('garland-color-main-3');
const garlandColorMain4Sel = document.getElementById('garland-color-main-4');
const garlandColorAccentSel = document.getElementById('garland-color-accent');
const updateGarlandSwatches = () => {}; // stub for layouts without dropdown swatches
const sizePresetGroup = document.getElementById('size-preset-group');
const heliumPlacementRow = document.getElementById('helium-placement-row');
const heliumPlaceBalloonBtn = document.getElementById('helium-place-balloon');
const heliumPlaceCurlBtn = document.getElementById('helium-place-curl');
const heliumPlaceRibbonBtn = document.getElementById('helium-place-ribbon');
const heliumPlaceWeightBtn = document.getElementById('helium-place-weight');
const toggleShineBtn = null;
const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox');
const toggleBorderCheckbox = document.getElementById('toggle-border-checkbox');
const paletteBox = document.getElementById('color-palette');
const usedPaletteBox = document.getElementById('used-palette');
const sortUsedToggle = document.getElementById('sort-used-toggle');
// replace colors panel
const replaceFromSel = document.getElementById('replace-from');
const replaceToSel = document.getElementById('replace-to');
const replaceBtn = document.getElementById('replace-btn');
const replaceMsg = document.getElementById('replace-msg');
const replaceFromChip = document.getElementById('replace-from-chip');
const replaceToChip = document.getElementById('replace-to-chip');
const replaceCountLabel = document.getElementById('replace-count');
// IO
const clearCanvasBtn = document.getElementById('clear-canvas-btn');
const saveJsonBtn = document.getElementById('save-json-btn');
const loadJsonInput = document.getElementById('load-json-input');
const generateLinkBtn = document.getElementById('generate-link-btn');
const shareLinkOutput = document.getElementById('share-link-output');
const copyMessage = document.getElementById('copy-message');
const clearCanvasBtnTop = document.getElementById('clear-canvas-btn-top');
// Debug overlay to diagnose mobile input issues
const debugOverlay = document.createElement('div');
debugOverlay.id = 'organic-debug-overlay';
debugOverlay.style.cssText = 'position:fixed;bottom:8px;right:8px;z-index:9999;background:rgba(0,0,0,0.7);color:#fff;padding:6px 8px;border-radius:8px;font-size:10px;font-family:monospace;pointer-events:none;opacity:0.9;line-height:1.3;display:none;';
debugOverlay.textContent = 'organic debug';
document.body.appendChild(debugOverlay);
// messages
const messageModal = document.getElementById('message-modal');
const modalText = document.getElementById('modal-text');
const modalCloseBtn = document.getElementById('modal-close-btn');
// layout
const controlsPanel = document.getElementById('controls-panel');
const canvasPanel = document.getElementById('canvas-panel');
const expandBtn = null;
const fullscreenBtn = null;
if (!canvas || !ctx) return; // nothing to do if organic UI isn't on page
const loadBalloonMask = async () => {
if (balloonMaskLoaded || balloonMaskLoading) return;
balloonMaskLoading = true;
try {
const res = await fetch(BALLOON_MASK_URL, { cache: 'force-cache' });
const text = await res.text();
let pathD = '';
let viewBoxRaw = '';
try {
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
const svgEl = doc.querySelector('svg');
const pathEl = doc.querySelector('path[d]');
pathD = pathEl?.getAttribute('d') || '';
viewBoxRaw = svgEl?.getAttribute('viewBox') || '';
} catch {}
// Fallback if DOM parsing fails for any reason
if (!pathD) {
const dMatch = text.match(/<path\b[^>]*\sd="([^"]+)"/i);
pathD = dMatch?.[1] || '';
}
if (!viewBoxRaw) {
const vbMatch = text.match(/viewBox="([^"]+)"/i);
viewBoxRaw = vbMatch?.[1] || '';
}
if (pathD) {
balloonMaskPath = new Path2D(pathD);
balloonMaskPathData = pathD;
}
if (pathD) {
try {
const ns = 'http://www.w3.org/2000/svg';
const svgTmp = document.createElementNS(ns, 'svg');
const pTmp = document.createElementNS(ns, 'path');
pTmp.setAttribute('d', pathD);
svgTmp.setAttribute('xmlns', ns);
svgTmp.setAttribute('width', '0');
svgTmp.setAttribute('height', '0');
svgTmp.style.position = 'absolute';
svgTmp.style.left = '-9999px';
svgTmp.style.top = '-9999px';
svgTmp.style.opacity = '0';
svgTmp.appendChild(pTmp);
document.body.appendChild(svgTmp);
const bb = pTmp.getBBox();
svgTmp.remove();
if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) {
balloonMaskBounds = {
x: bb.x,
y: bb.y,
w: bb.width,
h: bb.height,
cx: bb.x + bb.width / 2,
cy: bb.y + bb.height / 2
};
}
} catch {}
}
if (viewBoxRaw) {
const parts = viewBoxRaw.split(/\s+/).map(Number);
if (parts.length === 4 && parts.every(Number.isFinite)) {
balloonMaskViewBox = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
}
}
balloonMaskLoaded = !!balloonMaskPath;
if (balloonMaskLoaded) requestDraw();
} catch (err) {
console.warn('Failed to load balloon mask:', err);
} finally {
balloonMaskLoading = false;
}
};
const loadBalloon24Mask = async () => {
if (balloon24MaskLoaded || balloon24MaskLoading) return;
balloon24MaskLoading = true;
try {
const res = await fetch(BALLOON_24_MASK_URL, { cache: 'force-cache' });
const text = await res.text();
let pathD = '';
try {
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
const pathEl = doc.querySelector('path[d]');
pathD = pathEl?.getAttribute('d') || '';
} catch {}
if (!pathD) {
const dMatch = text.match(/<path\b[^>]*\sd="([^"]+)"/i);
pathD = dMatch?.[1] || '';
}
if (pathD) {
balloon24MaskPath = new Path2D(pathD);
balloon24MaskPathData = pathD;
}
if (pathD) {
try {
const ns = 'http://www.w3.org/2000/svg';
const svgTmp = document.createElementNS(ns, 'svg');
const pTmp = document.createElementNS(ns, 'path');
pTmp.setAttribute('d', pathD);
svgTmp.setAttribute('xmlns', ns);
svgTmp.setAttribute('width', '0');
svgTmp.setAttribute('height', '0');
svgTmp.style.position = 'absolute';
svgTmp.style.left = '-9999px';
svgTmp.style.top = '-9999px';
svgTmp.style.opacity = '0';
svgTmp.appendChild(pTmp);
document.body.appendChild(svgTmp);
const bb = pTmp.getBBox();
svgTmp.remove();
if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) {
balloon24MaskBounds = {
x: bb.x,
y: bb.y,
w: bb.width,
h: bb.height,
cx: bb.x + bb.width / 2,
cy: bb.y + bb.height / 2
};
}
} catch {}
}
balloon24MaskLoaded = !!balloon24MaskPath;
if (balloon24MaskLoaded) requestDraw();
} catch (err) {
console.warn('Failed to load 24in balloon mask:', err);
} finally {
balloon24MaskLoading = false;
}
};
const loadWeightMask = async () => {
if (weightMaskLoaded || weightMaskLoading) return;
weightMaskLoading = true;
try {
const res = await fetch(WEIGHT_MASK_URL, { cache: 'force-cache' });
const text = await res.text();
let pathD = '';
try {
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
const pathEl = doc.querySelector('path[d]');
pathD = pathEl?.getAttribute('d') || '';
} catch {}
if (!pathD) {
const dMatch = text.match(/<path\b[^>]*\sd="([^"]+)"/i);
pathD = dMatch?.[1] || '';
}
if (pathD) {
weightMaskPath = new Path2D(pathD);
weightMaskPathData = pathD;
}
if (pathD) {
try {
const ns = 'http://www.w3.org/2000/svg';
const svgTmp = document.createElementNS(ns, 'svg');
const pTmp = document.createElementNS(ns, 'path');
pTmp.setAttribute('d', pathD);
svgTmp.setAttribute('xmlns', ns);
svgTmp.setAttribute('width', '0');
svgTmp.setAttribute('height', '0');
svgTmp.style.position = 'absolute';
svgTmp.style.left = '-9999px';
svgTmp.style.top = '-9999px';
svgTmp.style.opacity = '0';
svgTmp.appendChild(pTmp);
document.body.appendChild(svgTmp);
const bb = pTmp.getBBox();
svgTmp.remove();
if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) {
weightMaskBounds = {
x: bb.x,
y: bb.y,
w: bb.width,
h: bb.height,
cx: bb.x + bb.width / 2,
cy: bb.y + bb.height / 2
};
}
} catch {}
}
weightMaskLoaded = !!weightMaskPath;
if (weightMaskLoaded) requestDraw();
} catch (err) {
console.warn('Failed to load weight mask:', err);
} finally {
weightMaskLoading = false;
}
};
loadBalloonMask();
loadBalloon24Mask();
loadWeightMask();
// ====== State ======
let balloons = [];
let selectedColorIdx = 0;
let currentDiameterInches = 11;
let currentRadius = inchesToRadiusPx(currentDiameterInches);
let isShineEnabled = true; // will be initialized from localStorage
let isBorderEnabled = true;
let dpr = 1;
let mode = 'draw';
let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
let mouseInside = false;
let mousePos = { x: 0, y: 0 };
let selectedIds = new Set();
let usedSortDesc = true;
let garlandPath = [];
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
let garlandMainIdx = [0];
let garlandAccentIdx = -1;
let heliumPlacementType = 'balloon';
let ribbonDraftStart = null;
let ribbonDraftMouse = null;
let ribbonAttachMode = false;
let lastCommitMode = '';
let lastAddStatus = '';
let evtStats = { down: 0, up: 0, cancel: 0, touchEnd: 0, addBalloon: 0, addGarland: 0, lastType: '' };
// History for Undo/Redo
const historyStack = [];
let historyPointer = -1;
function resetHistory() {
historyStack.length = 0;
historyPointer = -1;
pushHistory();
}
function updateHistoryUi() {
const canUndo = historyPointer > 0;
const canRedo = historyPointer < historyStack.length - 1;
if (toolUndoBtn) {
toolUndoBtn.disabled = !canUndo;
toolUndoBtn.title = canUndo ? 'Undo' : 'Nothing to undo';
}
if (toolRedoBtn) {
toolRedoBtn.disabled = !canRedo;
toolRedoBtn.title = canRedo ? 'Redo' : 'Nothing to redo';
}
}
function pushHistory() {
// Remove any future history if we are in the middle of the stack
if (historyPointer < historyStack.length - 1) {
historyStack.splice(historyPointer + 1);
}
// Deep clone balloons array
const snapshot = JSON.parse(JSON.stringify(balloons));
historyStack.push(snapshot);
historyPointer++;
// Limit stack size
if (historyStack.length > 50) {
historyStack.shift();
historyPointer--;
}
updateHistoryUi();
}
function undo() {
if (historyPointer > 0) {
historyPointer--;
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
selectedIds.clear(); // clear selection on undo to avoid issues
updateSelectButtons();
draw();
renderUsedPalette();
persist();
}
updateHistoryUi();
}
function redo() {
if (historyPointer < historyStack.length - 1) {
historyPointer++;
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
selectedIds.clear();
updateSelectButtons();
draw();
renderUsedPalette();
persist();
}
updateHistoryUi();
}
// Bind Undo/Redo Buttons
toolUndoBtn?.addEventListener('click', undo);
toolRedoBtn?.addEventListener('click', redo);
// Eyedropper Tool
const toolEyedropperBtn = document.getElementById('tool-eyedropper');
toolEyedropperBtn?.addEventListener('click', () => {
// Toggle eyedropper mode
if (mode === 'eyedropper') {
setMode('draw'); // toggle off
} else {
setMode('eyedropper');
}
});
function clampViewScale() {
view.s = clamp(view.s, VIEW_MIN_SCALE, VIEW_MAX_SCALE);
}
function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; }
function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; }
function fmtInches(val) {
const v = Math.round(val * 10) / 10;
return `${String(v).replace(/\.0$/, '')}"`;
}
const makeId = (() => {
let n = 0;
return () => {
try { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID(); } catch {}
return `b-${Date.now().toString(36)}-${(n++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
};
})();
function radiusToSizeIndex(r) {
let best = 0, bestDiff = Infinity;
for (let i = 0; i < SIZE_PRESETS.length; i++) {
const diff = Math.abs(inchesToRadiusPx(SIZE_PRESETS[i]) - r);
if (diff < bestDiff) { best = i; bestDiff = diff; }
}
return best;
}
function hashString32(str) {
let h = 2166136261 >>> 0;
const s = String(str || '');
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function normalizeRibbonStyle(v) {
return v === 'spiral' ? 'spiral' : 'wave';
}
function getObjectRotationRad(b) {
const deg = Number(b?.rotationDeg) || 0;
return (deg * Math.PI) / 180;
}
function normalizeRotationDeg(deg) {
let d = Number(deg) || 0;
while (d > 180) d -= 360;
while (d <= -180) d += 360;
return d;
}
function withObjectRotation(b, fn) {
const ang = getObjectRotationRad(b);
if (!ang) { fn(); return; }
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.translate(-b.x, -b.y);
fn();
ctx.restore();
}
function rotatedAabb(minX, minY, maxX, maxY, cx, cy, ang) {
if (!ang) return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
const c = Math.cos(ang);
const s = Math.sin(ang);
const corners = [
[minX, minY], [maxX, minY], [minX, maxY], [maxX, maxY]
];
let rMinX = Infinity, rMinY = Infinity, rMaxX = -Infinity, rMaxY = -Infinity;
corners.forEach(([x, y]) => {
const dx = x - cx;
const dy = y - cy;
const rx = cx + dx * c - dy * s;
const ry = cy + dx * s + dy * c;
rMinX = Math.min(rMinX, rx);
rMinY = Math.min(rMinY, ry);
rMaxX = Math.max(rMaxX, rx);
rMaxY = Math.max(rMaxY, ry);
});
return { minX: rMinX, minY: rMinY, maxX: rMaxX, maxY: rMaxY, w: rMaxX - rMinX, h: rMaxY - rMinY };
}
function getBalloonMaskShape(sizePreset, activeTab = getActiveOrganicTab()) {
if (activeTab === '#tab-helium' && sizePreset === 24 && balloon24MaskPath) {
return { path: balloon24MaskPath, bounds: balloon24MaskBounds };
}
return { path: balloonMaskPath, bounds: balloonMaskBounds };
}
function getHeliumVolumeVisualBoost(sizePreset, activeTab = getActiveOrganicTab()) {
if (activeTab !== '#tab-helium') return 1;
const cuft = HELIUM_CUFT_BY_SIZE[sizePreset];
if (!Number.isFinite(cuft) || cuft <= 0) return 1;
const desiredRatio = Math.sqrt(cuft / HELIUM_CUFT_BASE_VALUE);
const currentRatio = sizePreset / HELIUM_CUFT_BASE_SIZE;
if (!Number.isFinite(currentRatio) || currentRatio <= 0) return 1;
return desiredRatio / currentRatio;
}
function getBalloonVisualRadius(b, activeTab = getActiveOrganicTab()) {
if (!b || b.kind !== 'balloon') return b?.radius || 0;
const sizeIndex = radiusToSizeIndex(b.radius);
const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
return b.radius * getHeliumVolumeVisualBoost(sizePreset, activeTab);
}
function getWeightMaskTransform(b) {
const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
const scale = (b.radius * 2 * WEIGHT_VISUAL_SCALE) / Math.max(1, mb.w);
const minX = b.x - mb.cx * scale;
const minY = b.y - mb.cy * scale;
return {
scale,
minX,
minY,
maxX: minX + mb.w * scale,
maxY: minY + mb.h * scale
};
}
function isPointInWeightMask(b, x, y) {
if (!weightMaskPath) return false;
const wt = getWeightMaskTransform(b);
const mb = weightMaskBounds || { cx: 0.5, cy: 0.5 };
const ang = getObjectRotationRad(b);
const c = Math.cos(-ang);
const s = Math.sin(-ang);
const dx = x - b.x;
const dy = y - b.y;
const ux = dx * c - dy * s;
const uy = dx * s + dy * c;
const localX = (ux / wt.scale) + mb.cx;
const localY = (uy / wt.scale) + mb.cy;
return !!ctx.isPointInPath(weightMaskPath, localX, localY);
}
function weightHitTest(b, x, y, pad = 0, { loose = false } = {}) {
if (!weightMaskPath) {
return Math.hypot(x - b.x, y - b.y) <= (b.radius * 1.4 + pad);
}
const wt = getWeightMaskTransform(b);
if (x < wt.minX - pad || x > wt.maxX + pad || y < wt.minY - pad || y > wt.maxY + pad) return false;
if (loose) return true;
if (isPointInWeightMask(b, x, y)) return true;
if (pad <= 0) return false;
const sampleCount = 8;
for (let i = 0; i < sampleCount; i++) {
const a = (Math.PI * 2 * i) / sampleCount;
if (isPointInWeightMask(b, x + Math.cos(a) * pad, y + Math.sin(a) * pad)) return true;
}
return false;
}
function getObjectBounds(b) {
if (!b) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0 };
if (b.kind === 'ribbon') {
const pts = getRibbonPoints(b);
if (!pts || !pts.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0 };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
pts.forEach(p => {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
});
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
}
const ang = getObjectRotationRad(b);
if (b.kind === 'curl260') {
const r = b.radius * 1.35;
return rotatedAabb(b.x - r, b.y - r, b.x + r, b.y + r * 2.4, b.x, b.y, ang);
}
if (b.kind === 'weight') {
if (weightMaskPath) {
const wt = getWeightMaskTransform(b);
return rotatedAabb(wt.minX, wt.minY, wt.maxX, wt.maxY, b.x, b.y, ang);
}
const r = b.radius * WEIGHT_VISUAL_SCALE;
return rotatedAabb(b.x - r * 1.2, b.y - r * 2.8, b.x + r * 1.2, b.y + r * 2.4, b.x, b.y, ang);
}
const vr = getBalloonVisualRadius(b, getActiveOrganicTab());
return { minX: b.x - vr, minY: b.y - vr, maxX: b.x + vr, maxY: b.y + vr, w: vr * 2, h: vr * 2 };
}
function rotatePointAround(px, py, cx, cy, ang) {
if (!ang) return { x: px, y: py };
const c = Math.cos(ang);
const s = Math.sin(ang);
const dx = px - cx;
const dy = py - cy;
return { x: cx + dx * c - dy * s, y: cy + dx * s + dy * c };
}
function getRibbonNodePosition(b) {
if (!b) return null;
if (b.kind === 'weight') {
let p;
if (weightMaskPath) {
const wt = getWeightMaskTransform(b);
const mb = weightMaskBounds || { y: 0, h: 1, cy: 0.5 };
// Slightly below the very top so ribbons land on the weight tie point.
const localY = mb.y + mb.h * 0.145;
p = { x: b.x, y: b.y + (localY - mb.cy) * wt.scale };
} else {
const bb = getObjectBounds(b);
p = { x: b.x, y: bb.minY + Math.max(4, bb.h * 0.15) };
}
const ang = getObjectRotationRad(b);
return rotatePointAround(p.x, p.y, b.x, b.y, ang);
}
if (b.kind === 'balloon') {
let p;
const sizeIndex = radiusToSizeIndex(b.radius);
const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
const maskShape = getBalloonMaskShape(sizePreset, getActiveOrganicTab());
const useBalloonMaskNode = !!(maskShape.path && getActiveOrganicTab() === '#tab-helium');
if (useBalloonMaskNode) {
const mb = maskShape.bounds || { y: 0, h: 1, cy: 0.5, w: 1 };
const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, getActiveOrganicTab());
const scaleY = ((b.radius * 2) / Math.max(1, mb.h)) * heliumBoost;
const localY = mb.y + mb.h * 0.972;
p = { x: b.x, y: b.y + (localY - mb.cy) * scaleY };
} else {
p = { x: b.x, y: b.y + b.radius * 0.96 };
}
const ang = getObjectRotationRad(b);
return rotatePointAround(p.x, p.y, b.x, b.y, ang);
}
return null;
}
function findRibbonNodeAt(x, y) {
const hitR = Math.max(8 / view.s, 5);
for (let i = balloons.length - 1; i >= 0; i--) {
const b = balloons[i];
if (b?.kind !== 'balloon' && b?.kind !== 'weight') continue;
const p = getRibbonNodePosition(b);
if (!p) continue;
if (Math.hypot(x - p.x, y - p.y) <= hitR) {
return { kind: b.kind, id: b.id, x: p.x, y: p.y };
}
}
return null;
}
function resolveRibbonEndpoint(endpoint) {
if (!endpoint || !endpoint.id) return null;
const b = balloons.find(obj => obj.id === endpoint.id);
return getRibbonNodePosition(b);
}
function getRibbonPoints(ribbon) {
const start = resolveRibbonEndpoint(ribbon.from);
if (!start) return null;
const rawEnd = ribbon.to ? resolveRibbonEndpoint(ribbon.to) : (ribbon.freeEnd ? { x: ribbon.freeEnd.x, y: ribbon.freeEnd.y } : null);
const scale = clamp(Number(ribbon.lengthScale) || 1, 0.4, 2.2);
const end = rawEnd ? {
x: start.x + (rawEnd.x - start.x) * scale,
y: start.y + (rawEnd.y - start.y) * scale
} : null;
if (!end) return null;
const points = [];
const tight = !!(ribbon.to && ((ribbon.from?.kind === 'balloon' && ribbon.to?.kind === 'weight') || (ribbon.from?.kind === 'weight' && ribbon.to?.kind === 'balloon')));
if (tight) return [start, end];
const dx = end.x - start.x;
const dy = end.y - start.y;
const len = Math.max(1, Math.hypot(dx, dy));
const nx = -dy / len;
const ny = dx / len;
const steps = 28;
const amp = Math.max(4 / view.s, Math.min(16 / view.s, len * 0.085)) * (0.9 + scale * 0.25);
const waves = 3;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const sx = start.x + dx * t;
const sy = start.y + dy * t;
const off = Math.sin(t * Math.PI * 2 * waves) * amp * (0.35 + 0.65 * t);
points.push({ x: sx + nx * off, y: sy + ny * off + (1 - t) * (2 / view.s) });
}
return points;
}
function drawRibbonObject(ribbon, meta) {
const pts = getRibbonPoints(ribbon);
if (!pts || pts.length < 2) return;
const rgb = hexToRgb(normalizeHex(meta?.hex || ribbon.color || '#999999')) || { r: 120, g: 120, b: 120 };
const w = Math.max(1.9, 2.2 / view.s);
const trace = () => {
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
};
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (isBorderEnabled) {
ctx.beginPath();
trace();
ctx.strokeStyle = '#111827';
ctx.lineWidth = w + Math.max(0.9, 1.2 / view.s);
ctx.stroke();
}
ctx.beginPath();
trace();
ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.lineWidth = w;
ctx.stroke();
// Small curled ribbon detail at balloon nozzle connection.
if (ribbon?.from?.kind === 'balloon') {
const p0 = pts[0];
const p1 = pts[Math.min(1, pts.length - 1)];
const vx = p1.x - p0.x;
const vy = p1.y - p0.y;
const vl = Math.max(1e-6, Math.hypot(vx, vy));
const ux = vx / vl;
const uy = vy / vl;
const nx = -uy;
const ny = ux;
const side = (hashString32(ribbon.id || '') & 1) ? 1 : -1;
const amp = Math.max(3.2 / view.s, 2.3);
const len = Math.max(12 / view.s, 8.5);
const steps = 14;
const drawCurl = (lineW, stroke) => {
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const d = len * t;
const x = p0.x + ux * d + nx * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
const y = p0.y + uy * d + ny * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.strokeStyle = stroke;
ctx.lineWidth = lineW;
ctx.stroke();
};
if (isBorderEnabled) drawCurl(w + Math.max(0.9, 1.2 / view.s), '#111827');
drawCurl(w, `rgb(${rgb.r},${rgb.g},${rgb.b})`);
}
ctx.restore();
}
function ribbonDistanceToPoint(ribbon, x, y) {
const pts = getRibbonPoints(ribbon);
if (!pts || pts.length < 2) return Infinity;
let best = Infinity;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
const vx = b.x - a.x;
const vy = b.y - a.y;
const vv = vx * vx + vy * vy || 1;
const t = clamp(((x - a.x) * vx + (y - a.y) * vy) / vv, 0, 1);
const px = a.x + vx * t;
const py = a.y + vy * t;
best = Math.min(best, Math.hypot(x - px, y - py));
}
return best;
}
function drawRibbonSelectionRing(ribbon) {
const pts = getRibbonPoints(ribbon);
if (!pts || pts.length < 2) return false;
const trace = () => {
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
};
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
trace();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = Math.max(5 / view.s, 3);
ctx.stroke();
ctx.beginPath();
trace();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = Math.max(2.2 / view.s, 1.4);
ctx.stroke();
ctx.restore();
return true;
}
function normalizeHeliumPlacementType(v) {
return (v === 'curl260' || v === 'weight' || v === 'ribbon') ? v : 'balloon';
}
function buildCurrentRibbonConfig() {
return {
enabled: true,
style: 'wave',
length: 0.7,
turns: 3
};
}
function getCurlObjectConfig(b) {
if (!b || b.kind !== 'curl260' || !b.curl) return null;
return {
enabled: true,
style: normalizeRibbonStyle(b.curl.style),
length: clamp(Number(b.curl.length) || 0.7, 0.7, 2.4),
turns: Math.max(1, Math.min(5, Math.round(Number(b.curl.turns) || 3)))
};
}
function showModal(msg, opts = {}) {
if (window.Swal) {
Swal.fire({
title: opts.title || 'Notice',
text: msg,
icon: opts.icon || 'info',
confirmButtonText: opts.confirmText || 'OK'
});
return;
}
if (!messageModal || !modalText) { window.alert?.(msg); return; }
modalText.textContent = msg;
messageModal.classList.remove('hidden');
}
function hideModal() {
if (window.Swal) { Swal.close?.(); return; }
if (!messageModal) return;
messageModal.classList.add('hidden');
}
function showCopyMessage() { if (!copyMessage) return; copyMessage.classList.add('show'); setTimeout(() => copyMessage.classList.remove('show'), 2000); }
function getMousePos(e) {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left) / view.s - view.tx,
y: (e.clientY - r.top) / view.s - view.ty
};
}
// ====== Global shine sync (shared with Classic)
window.syncAppShine = function(isEnabled) {
isShineEnabled = isEnabled;
// mirror both UIs
const organicBtn = document.getElementById('toggle-shine-btn');
const classicCb = document.getElementById('classic-shine-enabled');
if (organicBtn) organicBtn.textContent = isEnabled ? 'Turn Off Shine' : 'Turn On Shine';
if (classicCb) classicCb.checked = isEnabled;
try { localStorage.setItem('app:shineEnabled:v1', JSON.stringify(isEnabled)); } catch {}
// push into Classic engine if available
if (window.ClassicDesigner?.api?.setShineEnabled) {
window.ClassicDesigner.api.setShineEnabled(isEnabled);
}
// redraw both tabs (cheap + robust)
try { draw?.(); } catch {}
try { window.ClassicDesigner?.redraw?.(); } catch {}
};
function setMode(next) {
if (next === 'garland' && getActiveOrganicTab() === '#tab-helium') next = 'draw';
if (mode === 'garland' && next !== 'garland') {
garlandPath = [];
}
if (next !== 'draw') resetRibbonDraft();
mode = next;
toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw'));
toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland'));
toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase'));
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
const mobileErase = document.getElementById('mobile-act-erase');
const mobilePick = document.getElementById('mobile-act-eyedrop');
const setActive = (el, on) => {
if (!el) return;
el.setAttribute('aria-pressed', String(on));
el.classList.toggle('active', !!on);
};
setActive(mobileErase, mode === 'erase');
setActive(mobilePick, mode === 'eyedropper');
eraserControls?.classList.toggle('hidden', mode !== 'erase');
selectControls?.classList.toggle('hidden', mode !== 'select');
garlandControls?.classList.toggle('hidden', mode !== 'garland');
if (mode === 'erase') canvas.style.cursor = 'none';
else if (mode === 'select') canvas.style.cursor = 'default'; // will be move over items
else if (mode === 'garland') canvas.style.cursor = 'crosshair';
else if (mode === 'eyedropper') canvas.style.cursor = 'cell';
else canvas.style.cursor = 'crosshair';
draw();
persist();
}
function syncHeliumToolUi() {
const isHelium = getActiveOrganicTab() === '#tab-helium';
if (toolGarlandBtn) {
toolGarlandBtn.classList.toggle('hidden', isHelium);
toolGarlandBtn.disabled = isHelium;
toolGarlandBtn.setAttribute('aria-hidden', String(isHelium));
toolGarlandBtn.tabIndex = isHelium ? -1 : 0;
}
if (isHelium && mode === 'garland') setMode('draw');
if (isHelium) garlandControls?.classList.add('hidden');
}
function syncHeliumPlacementUi() {
const isHelium = getActiveOrganicTab() === '#tab-helium';
heliumPlacementRow?.classList.toggle('hidden', !isHelium);
const setBtn = (btn, active) => {
if (!btn) return;
btn.classList.toggle('tab-active', !!active);
btn.classList.toggle('tab-idle', !active);
btn.setAttribute('aria-pressed', String(!!active));
};
setBtn(heliumPlaceBalloonBtn, heliumPlacementType === 'balloon');
setBtn(heliumPlaceCurlBtn, heliumPlacementType === 'curl260');
setBtn(heliumPlaceRibbonBtn, heliumPlacementType === 'ribbon');
setBtn(heliumPlaceWeightBtn, heliumPlacementType === 'weight');
if (isHelium && sizePresetGroup) {
const sizeBtns = Array.from(sizePresetGroup.querySelectorAll('button'));
if (heliumPlacementType !== 'balloon') {
sizeBtns.forEach(btn => btn.setAttribute('aria-pressed', 'false'));
} else {
sizeBtns.forEach(btn => {
const isMatch = (btn.textContent || '').trim() === `${currentDiameterInches}"`;
btn.setAttribute('aria-pressed', String(isMatch));
});
}
}
}
function selectionArray() { return Array.from(selectedIds); }
function selectionBalloons() {
const set = new Set(selectedIds);
return balloons.filter(b => set.has(b.id));
}
function setSelection(ids, { additive = false } = {}) {
if (!additive) selectedIds.clear();
ids.forEach(id => selectedIds.add(id));
updateSelectButtons();
draw();
}
function primarySelection() {
const first = selectedIds.values().next();
return first.done ? null : first.value;
}
function clearSelection() {
selectedIds.clear();
ribbonAttachMode = false;
updateSelectButtons();
draw();
}
function updateSelectButtons() {
const has = selectedIds.size > 0;
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has;
if (selectedSizeInput) {
selectedSizeInput.disabled = !has;
selectedSizeInput.min = '5';
selectedSizeInput.max = '32';
selectedSizeInput.step = '0.5';
}
if (bringForwardBtn) bringForwardBtn.disabled = !has;
if (sendBackwardBtn) sendBackwardBtn.disabled = !has;
if (rotateSelectedLeftBtn) rotateSelectedLeftBtn.disabled = !has;
if (rotateSelectedResetBtn) rotateSelectedResetBtn.disabled = !has;
if (rotateSelectedRightBtn) rotateSelectedRightBtn.disabled = !has;
if (applyColorBtn) applyColorBtn.disabled = !has;
const isHelium = getActiveOrganicTab() === '#tab-helium';
const selectedCurlCount = selectionBalloons().filter(b => b?.kind === 'curl260').length;
const selectedRibbonCount = selectionBalloons().filter(b => b?.kind === 'ribbon').length;
if (ribbonAttachMode && selectedRibbonCount === 0) ribbonAttachMode = false;
if (ribbonLengthDownBtn) ribbonLengthDownBtn.disabled = selectedRibbonCount === 0;
if (ribbonLengthUpBtn) ribbonLengthUpBtn.disabled = selectedRibbonCount === 0;
if (ribbonAttachWeightBtn) {
ribbonAttachWeightBtn.disabled = selectedRibbonCount === 0;
ribbonAttachWeightBtn.textContent = ribbonAttachMode ? 'Click Weight…' : 'Attach to Weight';
}
if (selectedSizeInput && selectedSizeLabel) {
if (has) {
const first = balloons.find(bb => selectedIds.has(bb.id) && Number.isFinite(bb.radius));
if (first) {
const diam = radiusPxToInches(first.radius);
selectedSizeInput.value = String(Math.min(32, Math.max(5, diam)));
selectedSizeLabel.textContent = fmtInches(diam);
selectedSizeInput.disabled = false;
} else {
selectedSizeInput.disabled = true;
selectedSizeLabel.textContent = '—';
}
} else {
selectedSizeLabel.textContent = '0"';
}
}
}
// ====== Pointer Events ======
let pointerDown = false;
let isDragging = false;
let dragStartPos = { x: 0, y: 0 };
let initialBalloonPos = { x: 0, y: 0 };
let eraseChanged = false;
let dragMoved = false;
let resizeChanged = false;
let resizeSaveTimer = null;
let erasingActive = false;
let drawPending = false;
let dragOffsets = [];
let marqueeActive = false;
let marqueeStart = { x: 0, y: 0 };
let marqueeEnd = { x: 0, y: 0 };
let pointerEventsSeen = false;
let touchFallbackHandled = false;
function requestDraw() {
if (drawPending) return;
drawPending = true;
requestAnimationFrame(() => {
drawPending = false;
draw();
});
}
const pointerTypeOf = (evt, fromTouch) => fromTouch ? 'touch' : (evt.pointerType || '');
function handlePrimaryDown(evt, { fromTouch = false } = {}) {
// If the canvas never got sized (some mobile browsers skip ResizeObserver early), size it now.
if (canvas.width === 0 || canvas.height === 0) resizeCanvas();
mouseInside = true;
mousePos = getMousePos(evt);
evtStats.down += 1;
evtStats.lastType = pointerTypeOf(evt, fromTouch);
if (evt.altKey || mode === 'eyedropper') {
pickColorAt(mousePos.x, mousePos.y);
if (mode === 'eyedropper') setMode('draw');
return;
}
if (mode === 'erase') {
pointerDown = true;
erasingActive = true;
eraseChanged = eraseAt(mousePos.x, mousePos.y);
return;
}
if (mode === 'garland') {
pointerDown = true;
garlandPath = [{ ...mousePos }];
requestDraw();
return;
}
if (mode === 'select') {
pointerDown = true;
if (ribbonAttachMode) {
const clickedIdx = findWeightIndexAt(mousePos.x, mousePos.y);
const target = clickedIdx >= 0 ? balloons[clickedIdx] : null;
if (target?.kind === 'weight') {
attachSelectedRibbonsToWeight(target.id);
} else {
ribbonAttachMode = false;
updateSelectButtons();
showModal('Attach mode canceled. Click "Attach to Weight" and then click a weight.');
}
requestDraw();
pointerDown = false;
return;
}
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
if (clickedIdx !== -1) {
const b = balloons[clickedIdx];
if (evt.shiftKey) {
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
else selectedIds.add(b.id);
} else if (!selectedIds.has(b.id)) {
selectedIds.clear();
selectedIds.add(b.id);
}
updateSelectButtons();
draw();
isDragging = true;
dragStartPos = { ...mousePos };
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
dragMoved = false;
} else {
if (!evt.shiftKey) selectedIds.clear();
updateSelectButtons();
marqueeActive = true;
marqueeStart = { ...mousePos };
marqueeEnd = { ...mousePos };
requestDraw();
}
return;
}
if (getActiveOrganicTab() === '#tab-helium' && heliumPlacementType === 'ribbon') {
handleRibbonPlacementAt(mousePos.x, mousePos.y);
pointerDown = false;
return;
}
addBalloon(mousePos.x, mousePos.y);
pointerDown = true;
}
function handlePrimaryMove(evt, { fromTouch = false } = {}) {
mouseInside = true;
mousePos = getMousePos(evt);
if (mode === 'select') {
if (isDragging && selectedIds.size) {
const dx = mousePos.x - dragStartPos.x;
const dy = mousePos.y - dragStartPos.y;
dragOffsets.forEach(off => {
const b = balloons.find(bb => bb.id === off.id);
if (b) {
b.x = mousePos.x + off.dx;
b.y = mousePos.y + off.dy;
}
});
requestDraw();
dragMoved = true;
} else if (marqueeActive) {
marqueeEnd = { ...mousePos };
requestDraw();
} else {
const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
}
}
if (mode === 'draw' && getActiveOrganicTab() === '#tab-helium' && heliumPlacementType === 'ribbon' && ribbonDraftStart) {
ribbonDraftMouse = { ...mousePos };
requestDraw();
}
if (mode === 'garland') {
if (pointerDown) {
const last = garlandPath[garlandPath.length - 1];
if (!last || Math.hypot(mousePos.x - last.x, mousePos.y - last.y) >= GARLAND_POINT_STEP) {
garlandPath.push({ ...mousePos });
requestDraw();
}
}
return;
}
if (mode === 'erase') {
if (pointerDown) {
eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged;
if (eraseChanged) requestDraw();
} else {
requestDraw();
}
}
}
function handlePrimaryUp(evt, { fromTouch = false } = {}) {
pointerDown = false;
isDragging = false;
evtStats.up += 1;
evtStats.lastType = pointerTypeOf(evt, fromTouch);
if (fromTouch) evtStats.touchEnd += 1;
if (mode === 'garland') {
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
garlandPath = [];
requestDraw();
return;
}
if (mode === 'select' && dragMoved) {
refreshAll();
pushHistory();
}
if (mode === 'select' && marqueeActive) {
const minX = Math.min(marqueeStart.x, marqueeEnd.x);
const maxX = Math.max(marqueeStart.x, marqueeEnd.x);
const minY = Math.min(marqueeStart.y, marqueeEnd.y);
const maxY = Math.max(marqueeStart.y, marqueeEnd.y);
const ids = balloons.filter(b => b.x >= minX && b.x <= maxX && b.y >= minY && b.y <= maxY).map(b => b.id);
if (!evt.shiftKey) selectedIds.clear();
ids.forEach(id => selectedIds.add(id));
marqueeActive = false;
updateSelectButtons();
requestDraw();
}
if (mode === 'erase' && eraseChanged) {
refreshAll();
pushHistory();
}
erasingActive = false;
dragMoved = false;
eraseChanged = false;
marqueeActive = false;
}
function handlePrimaryCancel(evt, { fromTouch = false } = {}) {
pointerDown = false;
evtStats.cancel += 1;
evtStats.lastType = pointerTypeOf(evt, fromTouch);
if (mode === 'garland') {
garlandPath = [];
requestDraw();
}
if (mode === 'draw' && getActiveOrganicTab() === '#tab-helium' && heliumPlacementType !== 'ribbon') {
resetRibbonDraft();
}
}
// Avoid touch scrolling stealing pointer events.
canvas.style.touchAction = 'none';
canvas.addEventListener('pointerdown', e => {
// If a touch fallback already handled this gesture, ignore the duplicate pointer event.
if (touchFallbackHandled && e.pointerType === 'touch') return;
pointerEventsSeen = true;
touchFallbackHandled = false;
e.preventDefault();
canvas.setPointerCapture?.(e.pointerId);
handlePrimaryDown(e, { fromTouch: e.pointerType === 'touch' });
}, { passive: false });
canvas.addEventListener('pointermove', e => {
if (touchFallbackHandled && e.pointerType === 'touch') return;
handlePrimaryMove(e, { fromTouch: e.pointerType === 'touch' });
}, { passive: true });
canvas.addEventListener('pointerenter', () => {
mouseInside = true;
if (mode === 'erase') requestDraw();
});
canvas.addEventListener('pointerup', e => {
if (touchFallbackHandled && e.pointerType === 'touch') {
canvas.releasePointerCapture?.(e.pointerId);
return;
}
handlePrimaryUp(e, { fromTouch: e.pointerType === 'touch' });
canvas.releasePointerCapture?.(e.pointerId);
}, { passive: true });
canvas.addEventListener('pointerleave', () => {
mouseInside = false;
marqueeActive = false;
if (mode === 'garland') {
pointerDown = false;
garlandPath = [];
requestDraw();
}
if (mode === 'erase') requestDraw();
}, { passive: true });
canvas.addEventListener('pointercancel', e => {
if (touchFallbackHandled && e.pointerType === 'touch') return;
handlePrimaryCancel(e, { fromTouch: e.pointerType === 'touch' });
}, { passive: true });
// Touch fallback for browsers where pointer events are not delivered.
const touchToPointerLike = (e) => {
const t = e.changedTouches && e.changedTouches[0];
if (!t) return null;
return {
clientX: t.clientX,
clientY: t.clientY,
pointerType: 'touch',
altKey: e.altKey,
shiftKey: e.shiftKey
};
};
const shouldHandleTouchFallback = () => !pointerEventsSeen;
canvas.addEventListener('touchstart', e => {
if (!shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e);
if (!fake) return;
touchFallbackHandled = true;
e.preventDefault();
handlePrimaryDown(fake, { fromTouch: true });
}, { passive: false });
canvas.addEventListener('touchmove', e => {
if (!touchFallbackHandled || !shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e);
if (!fake) return;
e.preventDefault();
handlePrimaryMove(fake, { fromTouch: true });
}, { passive: false });
canvas.addEventListener('touchend', e => {
if (!touchFallbackHandled || !shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e) || { pointerType: 'touch', shiftKey: false, altKey: false, clientX: 0, clientY: 0 };
e.preventDefault();
handlePrimaryUp(fake, { fromTouch: true });
touchFallbackHandled = false;
}, { passive: false });
canvas.addEventListener('touchcancel', e => {
if (!touchFallbackHandled || !shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e) || { pointerType: 'touch' };
handlePrimaryCancel(fake, { fromTouch: true });
touchFallbackHandled = false;
}, { passive: true });
// No global pointer/touch commits; rely on canvas handlers (as in the working older version).
// ====== Canvas & Drawing ======
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
fitView();
draw();
}
function clearCanvasArea() {
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
}
function draw() {
clearCanvasArea();
ctx.save();
ctx.scale(view.s, view.s);
ctx.translate(view.tx, view.ty);
const activeOrgTab = getActiveOrganicTab();
const isHeliumTab = activeOrgTab === '#tab-helium';
const canRenderHeliumRibbons = isHeliumTab;
const drawMaskedBalloon = (b, meta) => {
const sizeIndex = radiusToSizeIndex(b.radius);
const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
const shape = getBalloonMaskShape(sizePreset, activeOrgTab);
if (!shape.path) return;
const mb = shape.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab);
const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost;
const strokeW = Math.max(0.35, 0.5 / view.s);
const destX = b.x - (mb.cx - mb.x) * scale;
const destY = b.y - (mb.cy - mb.y) * scale;
const destW = Math.max(1, mb.w * scale);
const destH = Math.max(1, mb.h * scale);
const drawFill = () => {
if (b.image) {
const img = getImage(b.image);
if (!img || !img.complete || img.naturalWidth === 0) return;
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
const srcW = img.naturalWidth / zoom;
const srcH = img.naturalHeight / zoom;
const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW);
const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
ctx.drawImage(img, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
} else {
ctx.fillStyle = b.color;
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 10;
ctx.fillRect(destX, destY, destW, destH);
ctx.shadowBlur = 0;
}
};
ctx.save();
ctx.translate(b.x, b.y);
ctx.scale(scale, scale);
ctx.translate(-mb.cx, -mb.cy);
ctx.clip(shape.path);
ctx.translate(mb.cx, mb.cy);
ctx.scale(1 / scale, 1 / scale);
ctx.translate(-b.x, -b.y);
const lum = luminance(meta.hex || b.color);
if (b.image && lum > 0.6) {
const strength = clamp01((lum - 0.6) / 0.4);
ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`;
ctx.shadowBlur = 4 + 4 * strength;
ctx.shadowOffsetY = 1 + 2 * strength;
}
drawFill();
ctx.restore();
if (isBorderEnabled) {
ctx.save();
ctx.translate(b.x, b.y);
ctx.scale(scale, scale);
ctx.translate(-mb.cx, -mb.cy);
ctx.strokeStyle = '#111827';
ctx.lineWidth = strokeW / scale;
ctx.stroke(shape.path);
ctx.restore();
}
};
const tryDrawMaskedBalloon = (b, meta) => {
try {
drawMaskedBalloon(b, meta);
return true;
} catch (err) {
if (!balloonMaskDrawFailed) {
balloonMaskDrawFailed = true;
console.warn('Masked balloon draw failed; falling back to circular balloons.', err);
}
return false;
}
};
const drawHeliumRibbon = (b, meta, ribbonCfg) => {
if (!canRenderHeliumRibbons || !ribbonCfg) return;
const seed = hashString32(b.id || `${b.x},${b.y}`);
const baseColor = normalizeHex(meta?.hex || b.color || '#999999');
const rgb = hexToRgb(baseColor) || { r: 120, g: 120, b: 120 };
const side = (seed & 1) ? 1 : -1;
const phase = ((seed >>> 3) % 628) / 100;
const len = Math.max(20, b.radius * 2 * ribbonCfg.length);
const stem = Math.max(6, b.radius * 0.28);
const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012)));
const turns = Math.max(1, Math.min(5, ribbonCfg.turns | 0));
const anchorX = (b.kind === 'curl260') ? b.x : (b.x + side * Math.max(1, b.radius * 0.06));
const anchorY = (b.kind === 'curl260') ? (b.y - b.radius * 0.55) : (b.y + b.radius * 0.54);
const width = Math.max(2.6, (2.4 + b.radius * 0.14) / view.s);
const isSpiralRibbon = ribbonCfg.style === 'spiral';
const steps = isSpiralRibbon ? 42 : 28;
const traceRibbon = () => {
ctx.moveTo(anchorX, anchorY);
ctx.lineTo(anchorX, anchorY + stem);
if (isSpiralRibbon) {
const topLead = Math.max(8, len * 0.28);
const topAmp = Math.max(6, b.radius * 0.95);
// Lead-in swoop like a hand-curled ribbon before the tighter coils.
for (let i = 1; i <= Math.floor(steps * 0.33); i++) {
const t = i / Math.floor(steps * 0.33);
const y = anchorY + stem + topLead * t;
const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15);
ctx.lineTo(x, y);
}
const startY = anchorY + stem + topLead;
const remain = Math.max(8, len - topLead);
const coilAmp = Math.max(5, b.radius * 0.5);
const coilSteps = steps - Math.floor(steps * 0.33);
for (let i = 1; i <= coilSteps; i++) {
const t = i / coilSteps;
const angle = (Math.PI * 2 * turns * t) + phase;
const decay = 1 - t * 0.55;
const x = anchorX + side * (coilAmp * decay) * Math.sin(angle);
const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle);
ctx.lineTo(x, y);
}
return;
}
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const falloff = 1 - t * 0.25;
const y = anchorY + stem + len * t;
const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side;
ctx.lineTo(x, y);
}
};
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (isBorderEnabled) {
ctx.beginPath();
traceRibbon();
ctx.strokeStyle = '#111827';
ctx.lineWidth = width + Math.max(0.8, 1.2 / view.s);
ctx.stroke();
}
// ribbon (thicker 260-style)
ctx.beginPath();
traceRibbon();
ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.lineWidth = width;
ctx.stroke();
// tiny tie under the neck only for attached balloon ribbons/curls
if (b.kind !== 'curl260') {
ctx.beginPath();
ctx.moveTo(anchorX - 2.2 / view.s, anchorY + 1.2 / view.s);
ctx.lineTo(anchorX + 2.2 / view.s, anchorY + 1.2 / view.s);
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(1.2, 1.8 / view.s);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(anchorX - 2.2 / view.s, anchorY + 1.2 / view.s);
ctx.lineTo(anchorX + 2.2 / view.s, anchorY + 1.2 / view.s);
}
ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.lineWidth = Math.max(0.6, 0.9 / view.s);
ctx.stroke();
}
ctx.restore();
};
const drawWeightObject = (b, meta) => {
if (weightMaskPath) {
const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 };
const wt = getWeightMaskTransform(b);
const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
const weightImg = getImage(WEIGHT_IMAGE_URL);
const hasWeightImg = !!(weightImg && weightImg.complete && weightImg.naturalWidth > 0);
if (hasWeightImg) {
const nw = Math.max(1, weightImg.naturalWidth || 1);
const nh = Math.max(1, weightImg.naturalHeight || 1);
const dstW = Math.max(1, mb.w);
const dstH = Math.max(1, mb.h);
const srcAspect = nw / nh;
const dstAspect = dstW / dstH;
let srcX = 0, srcY = 0, srcW = nw, srcH = nh;
if (srcAspect > dstAspect) {
srcW = nh * dstAspect;
srcX = (nw - srcW) * 0.5;
} else if (srcAspect < dstAspect) {
srcH = nw / dstAspect;
srcY = (nh - srcH) * 0.5;
}
const inset = Math.max(0.8, Math.min(dstW, dstH) * 0.018);
ctx.save();
ctx.translate(b.x, b.y);
ctx.scale(wt.scale, wt.scale);
ctx.translate(-mb.cx, -mb.cy);
ctx.save();
ctx.clip(weightMaskPath);
// Base photo texture (cover fit so it doesn't squash/blur).
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.filter = 'saturate(1.15) contrast(1.04)';
ctx.drawImage(
weightImg,
srcX, srcY, srcW, srcH,
mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2)
);
ctx.filter = 'none';
// Tint only existing image pixels (no rectangular washout).
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.46)`;
ctx.fillRect(mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2));
// Bring back a little foil highlight after tint.
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2));
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
ctx.restore();
return;
}
const hi = {
r: Math.round(clamp(rgb.r + 78, 0, 255)),
g: Math.round(clamp(rgb.g + 78, 0, 255)),
b: Math.round(clamp(rgb.b + 78, 0, 255))
};
const midHi = {
r: Math.round(clamp(rgb.r + 38, 0, 255)),
g: Math.round(clamp(rgb.g + 38, 0, 255)),
b: Math.round(clamp(rgb.b + 38, 0, 255))
};
const lo = {
r: Math.round(clamp(rgb.r - 52, 0, 255)),
g: Math.round(clamp(rgb.g - 52, 0, 255)),
b: Math.round(clamp(rgb.b - 52, 0, 255))
};
const midLo = {
r: Math.round(clamp(rgb.r - 24, 0, 255)),
g: Math.round(clamp(rgb.g - 24, 0, 255)),
b: Math.round(clamp(rgb.b - 24, 0, 255))
};
const seed = hashString32(b.id || `${b.x},${b.y}`);
ctx.save();
ctx.translate(b.x, b.y);
ctx.scale(wt.scale, wt.scale);
ctx.translate(-mb.cx, -mb.cy);
// Base color
ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.fill(weightMaskPath);
// Metallic foil shading and specular highlights.
ctx.save();
ctx.clip(weightMaskPath);
// 45deg, multi-stop metallic ramp inspired by ibelick's CSS metal effect.
const foilGrad = ctx.createLinearGradient(mb.x, mb.y, mb.x + mb.w, mb.y + mb.h);
foilGrad.addColorStop(0.05, `rgba(${lo.r},${lo.g},${lo.b},0.34)`);
foilGrad.addColorStop(0.10, `rgba(${hi.r},${hi.g},${hi.b},0.42)`);
foilGrad.addColorStop(0.30, `rgba(${midLo.r},${midLo.g},${midLo.b},0.26)`);
foilGrad.addColorStop(0.50, `rgba(${midHi.r},${midHi.g},${midHi.b},0.22)`);
foilGrad.addColorStop(0.70, `rgba(${midLo.r},${midLo.g},${midLo.b},0.26)`);
foilGrad.addColorStop(0.80, `rgba(${hi.r},${hi.g},${hi.b},0.40)`);
foilGrad.addColorStop(0.95, `rgba(${lo.r},${lo.g},${lo.b},0.34)`);
ctx.fillStyle = foilGrad;
ctx.fillRect(mb.x - 2, mb.y - 2, mb.w + 4, mb.h + 4);
// Diagonal foil crinkle bands from the corner direction.
ctx.save();
const bandCount = 8;
const diag = Math.hypot(mb.w, mb.h);
const cx = mb.x + mb.w * 0.16;
const cy = mb.y + mb.h * 0.14;
ctx.translate(cx, cy);
ctx.rotate(-Math.PI / 4);
for (let i = 0; i < bandCount; i++) {
const t = (i + 0.5) / bandCount;
const jitter = ((((seed >>> (i * 3)) & 7) - 3) / 7) * (diag * 0.03);
const bx = -diag * 0.2 + diag * t + jitter;
const bandW = diag * (0.05 + (i % 3) * 0.01);
const band = ctx.createLinearGradient(bx - bandW, 0, bx + bandW, 0);
band.addColorStop(0, `rgba(${hi.r},${hi.g},${hi.b},0.00)`);
band.addColorStop(0.5, `rgba(${hi.r},${hi.g},${hi.b},0.22)`);
band.addColorStop(1, `rgba(${lo.r},${lo.g},${lo.b},0.00)`);
ctx.fillStyle = band;
ctx.fillRect(bx - bandW, -diag * 0.7, bandW * 2, diag * 1.8);
}
ctx.restore();
// Corner-entry sheen (top-left toward center) for a directional metallic hit.
const cornerX = mb.x + mb.w * 0.14;
const cornerY = mb.y + mb.h * 0.16;
const cornerSpec = ctx.createRadialGradient(cornerX, cornerY, mb.w * 0.03, cornerX, cornerY, mb.w * 0.72);
cornerSpec.addColorStop(0.0, 'rgba(255,255,255,0.34)');
cornerSpec.addColorStop(0.28, 'rgba(255,255,255,0.16)');
cornerSpec.addColorStop(1.0, 'rgba(255,255,255,0)');
ctx.fillStyle = cornerSpec;
ctx.fillRect(mb.x, mb.y, mb.w, mb.h);
ctx.restore();
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.lineWidth = (0.7 / view.s) / wt.scale;
ctx.stroke(weightMaskPath);
}
ctx.restore();
return;
}
const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 };
const base = b.radius;
const bagW = Math.max(18, base * 1.6);
const bagH = Math.max(20, base * 2.2);
const knotW = bagW * 0.36;
const knotH = Math.max(5, bagH * 0.14);
const topY = b.y - bagH * 0.95;
const bagTopY = topY + knotH + 2;
ctx.save();
// Tinsel burst
const burstCount = 26;
for (let i = 0; i < burstCount; i++) {
const a = (Math.PI * 2 * i) / burstCount;
const len = bagW * (0.45 + ((i % 5) / 8));
const x1 = b.x + Math.cos(a) * 3;
const y1 = topY + Math.sin(a) * 3;
const x2 = b.x + Math.cos(a) * len;
const y2 = topY + Math.sin(a) * len * 0.7;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`;
ctx.lineWidth = Math.max(1, 1.8 / view.s);
ctx.stroke();
if (isBorderEnabled) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(0.6, 0.9 / view.s);
ctx.stroke();
}
}
// top loop
ctx.beginPath();
ctx.ellipse(b.x, topY - bagH * 0.2, bagW * 0.18, bagW * 0.24, 0, 0, Math.PI * 2);
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(1.2, 1.8 / view.s);
ctx.stroke();
}
ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.lineWidth = Math.max(2, 3 / view.s);
ctx.stroke();
// knot
ctx.beginPath();
ctx.roundRect?.(b.x - knotW/2, topY, knotW, knotH, Math.max(2, knotH * 0.35));
if (!ctx.roundRect) {
ctx.rect(b.x - knotW/2, topY, knotW, knotH);
}
ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.fill();
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(0.9, 1.3 / view.s);
ctx.stroke();
}
// bag body
ctx.beginPath();
ctx.moveTo(b.x - bagW * 0.42, bagTopY);
ctx.quadraticCurveTo(b.x - bagW * 0.62, b.y + bagH * 0.05, b.x - bagW * 0.34, b.y + bagH * 0.78);
ctx.quadraticCurveTo(b.x, b.y + bagH * 0.98, b.x + bagW * 0.34, b.y + bagH * 0.78);
ctx.quadraticCurveTo(b.x + bagW * 0.62, b.y + bagH * 0.05, b.x + bagW * 0.42, bagTopY);
ctx.closePath();
ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.fill();
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(1, 1.4 / view.s);
ctx.stroke();
}
ctx.restore();
};
const drawMaskedSelectionRing = (b) => {
const sizeIndex = radiusToSizeIndex(b.radius);
const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
const shape = getBalloonMaskShape(sizePreset, activeOrgTab);
if (!shape.path) return false;
const mb = shape.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab);
const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost;
ctx.save();
ctx.translate(b.x, b.y);
ctx.scale(scale, scale);
ctx.translate(-mb.cx, -mb.cy);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = (4 / view.s) / scale;
ctx.stroke(shape.path);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = (2 / view.s) / scale;
ctx.stroke(shape.path);
ctx.restore();
return true;
};
const drawCurlSelectionRing = (b) => {
const ribbonCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig();
const meta = FLAT_COLORS[b.colorIdx] || {};
const seed = hashString32(b.id || `${b.x},${b.y}`);
const side = (seed & 1) ? 1 : -1;
const phase = ((seed >>> 3) % 628) / 100;
const len = Math.max(20, b.radius * 2 * ribbonCfg.length);
const stem = Math.max(6, b.radius * 0.28);
const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012)));
const turns = Math.max(1, Math.min(5, ribbonCfg.turns | 0));
const anchorX = b.x;
const anchorY = b.y - b.radius * 0.55;
const width = Math.max(2.6, (2.4 + b.radius * 0.14) / view.s);
const isSpiralRibbon = ribbonCfg.style === 'spiral';
const steps = isSpiralRibbon ? 42 : 28;
const traceCurl = () => {
ctx.moveTo(anchorX, anchorY);
ctx.lineTo(anchorX, anchorY + stem);
if (isSpiralRibbon) {
const topLead = Math.max(8, len * 0.28);
const topAmp = Math.max(6, b.radius * 0.95);
for (let i = 1; i <= Math.floor(steps * 0.33); i++) {
const t = i / Math.floor(steps * 0.33);
const y = anchorY + stem + topLead * t;
const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15);
ctx.lineTo(x, y);
}
const startY = anchorY + stem + topLead;
const remain = Math.max(8, len - topLead);
const coilAmp = Math.max(5, b.radius * 0.5);
const coilSteps = steps - Math.floor(steps * 0.33);
for (let i = 1; i <= coilSteps; i++) {
const t = i / coilSteps;
const angle = (Math.PI * 2 * turns * t) + phase;
const decay = 1 - t * 0.55;
const x = anchorX + side * (coilAmp * decay) * Math.sin(angle);
const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle);
ctx.lineTo(x, y);
}
return;
}
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const falloff = 1 - t * 0.25;
const y = anchorY + stem + len * t;
const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side;
ctx.lineTo(x, y);
}
};
ctx.save();
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
traceCurl();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = width + Math.max(6 / view.s, 3);
ctx.stroke();
ctx.beginPath();
traceCurl();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = width + Math.max(3 / view.s, 1.6);
ctx.stroke();
ctx.restore();
// Redraw curl so the selection halo does not visually tint/fill the curl body.
drawHeliumRibbon(b, meta, ribbonCfg);
return true;
};
const drawWeightSelectionRing = (b) => {
if (weightMaskPath) {
const wt = getWeightMaskTransform(b);
ctx.save();
ctx.translate(b.x, b.y);
ctx.scale(wt.scale, wt.scale);
ctx.translate(-weightMaskBounds.cx, -weightMaskBounds.cy);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = (4 / view.s) / wt.scale;
ctx.stroke(weightMaskPath);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = (2 / view.s) / wt.scale;
ctx.stroke(weightMaskPath);
ctx.restore();
return true;
}
const base = b.radius;
const bagW = Math.max(18, base * 1.6);
const bagH = Math.max(20, base * 2.2);
const knotH = Math.max(5, bagH * 0.14);
const topY = b.y - bagH * 0.95;
const bagTopY = topY + knotH + 2;
const pathWeight = () => {
// loop
ctx.moveTo(b.x + bagW * 0.18, topY - bagH * 0.2);
ctx.ellipse(b.x, topY - bagH * 0.2, bagW * 0.18, bagW * 0.24, 0, 0, Math.PI * 2);
// bag
ctx.moveTo(b.x - bagW * 0.42, bagTopY);
ctx.quadraticCurveTo(b.x - bagW * 0.62, b.y + bagH * 0.05, b.x - bagW * 0.34, b.y + bagH * 0.78);
ctx.quadraticCurveTo(b.x, b.y + bagH * 0.98, b.x + bagW * 0.34, b.y + bagH * 0.78);
ctx.quadraticCurveTo(b.x + bagW * 0.62, b.y + bagH * 0.05, b.x + bagW * 0.42, bagTopY);
ctx.closePath();
};
ctx.save();
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
pathWeight();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = Math.max(4 / view.s, 2.4);
ctx.stroke();
ctx.beginPath();
pathWeight();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = Math.max(2 / view.s, 1.3);
ctx.stroke();
ctx.restore();
return true;
};
balloons.forEach(b => {
if (b?.kind === 'ribbon') {
const meta = FLAT_COLORS[b.colorIdx] || {};
drawRibbonObject(b, meta);
return;
}
withObjectRotation(b, () => {
if (b?.kind === 'curl260') {
const meta = FLAT_COLORS[b.colorIdx] || {};
const curlCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig();
drawHeliumRibbon(b, meta, curlCfg);
return;
}
if (b?.kind === 'weight') {
const meta = FLAT_COLORS[b.colorIdx] || {};
drawWeightObject(b, meta);
return;
}
const sizeIndex = radiusToSizeIndex(b.radius);
const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
const maskShape = getBalloonMaskShape(sizePreset, activeOrgTab);
const useMask = !!(maskShape.path && activeOrgTab === '#tab-helium');
const meta = FLAT_COLORS[b.colorIdx] || {};
if (b.image) {
const img = getImage(b.image);
if (img && img.complete && img.naturalWidth > 0) {
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
const srcW = img.naturalWidth / zoom;
const srcH = img.naturalHeight / zoom;
const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW);
const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
if (useMask && tryDrawMaskedBalloon(b, meta)) {
// masked draw succeeded
} else {
ctx.save();
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.clip();
const lum = luminance(meta.hex || b.color);
if (lum > 0.6) {
const strength = clamp01((lum - 0.6) / 0.4); // more shadow for lighter colors
ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`;
ctx.shadowBlur = 4 + 4 * strength;
ctx.shadowOffsetY = 1 + 2 * strength;
}
ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
ctx.stroke();
}
ctx.restore();
}
}
} else if (useMask && tryDrawMaskedBalloon(b, { hex: b.color })) {
// masked draw succeeded
} else {
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 10;
ctx.fill();
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
ctx.stroke();
}
ctx.shadowBlur = 0;
}
if (isShineEnabled) {
const visualRadius = getBalloonVisualRadius(b, activeOrgTab);
const shineScale = useMask ? ((sizePreset === 11) ? 0.68 : 0.8) : 1;
const shineOffsetScale = useMask ? 0.78 : 1;
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const sx = b.x - visualRadius * SHINE_OFFSET * shineOffsetScale;
const sy = b.y - visualRadius * SHINE_OFFSET * shineOffsetScale;
const rx = visualRadius * SHINE_RX * shineScale;
const ry = visualRadius * SHINE_RY * shineScale;
const rotRad = SHINE_ROT * Math.PI / 180;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 3; // SHINE_BLUR
ctx.beginPath();
if (ctx.ellipse) {
ctx.ellipse(sx, sy, rx, ry, rotRad, 0, Math.PI * 2);
} else {
ctx.translate(sx, sy);
ctx.rotate(rotRad);
ctx.scale(rx / ry, 1);
ctx.arc(0, 0, ry, 0, Math.PI * 2);
}
ctx.fillStyle = shineFill;
if (shineStroke) {
ctx.strokeStyle = shineStroke;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.fill();
ctx.restore();
}
});
});
// garland path preview
if (mode === 'garland' && garlandPath.length) {
ctx.save();
ctx.lineWidth = 1.5 / view.s;
ctx.strokeStyle = 'rgba(59,130,246,0.7)';
ctx.setLineDash([8 / view.s, 6 / view.s]);
ctx.beginPath();
garlandPath.forEach((p, idx) => {
if (idx === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
ctx.setLineDash([]);
const previewNodes = computeGarlandNodes(garlandPath).sort((a, b) => b.radius - a.radius);
ctx.strokeStyle = 'rgba(59,130,246,0.28)';
previewNodes.forEach(n => {
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
ctx.stroke();
});
ctx.restore();
}
// selection ring(s)
if (selectedIds.size) {
ctx.save();
selectedIds.forEach(id => {
const b = balloons.find(bb => bb.id === id);
if (!b) return;
if (b.kind === 'ribbon') {
if (drawRibbonSelectionRing(b)) return;
}
withObjectRotation(b, () => {
if (b.kind === 'curl260') {
if (drawCurlSelectionRing(b)) return;
} else if (b.kind === 'weight') {
if (drawWeightSelectionRing(b)) return;
} else {
const sizeIndex = radiusToSizeIndex(b.radius);
const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
const maskShape = getBalloonMaskShape(sizePreset, activeOrgTab);
const useMask = !!(maskShape.path && activeOrgTab === '#tab-helium');
if (useMask && drawMaskedSelectionRing(b)) return;
}
ctx.beginPath();
const visualRadius = getBalloonVisualRadius(b, activeOrgTab);
ctx.arc(b.x, b.y, visualRadius + 3, 0, Math.PI * 2);
ctx.lineWidth = 4 / view.s;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.stroke();
ctx.lineWidth = 2 / view.s;
ctx.strokeStyle = '#3b82f6';
ctx.stroke();
});
});
ctx.restore();
}
if (activeOrgTab === '#tab-helium' && mode === 'draw' && heliumPlacementType === 'ribbon') {
// Show allowed connection nodes (balloon nozzles + weights) while in ribbon mode.
ctx.save();
const nodeR = Math.max(3, 4 / view.s);
balloons.forEach(b => {
if (b?.kind !== 'balloon' && b?.kind !== 'weight') return;
const p = getRibbonNodePosition(b);
if (!p) return;
ctx.beginPath();
ctx.arc(p.x, p.y, nodeR, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(59,130,246,0.65)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = Math.max(1 / view.s, 0.7);
ctx.stroke();
});
if (ribbonDraftStart) {
const draftStartPos = resolveRibbonEndpoint(ribbonDraftStart);
const draftEndPos = ribbonDraftMouse || mousePos;
if (draftStartPos && draftEndPos) {
const draftRibbon = {
kind: 'ribbon',
from: ribbonDraftStart,
to: null,
freeEnd: { x: draftEndPos.x, y: draftEndPos.y },
color: '#60a5fa',
colorIdx: selectedColorIdx
};
ctx.globalAlpha = 0.9;
drawRibbonObject(draftRibbon, FLAT_COLORS[selectedColorIdx] || {});
ctx.globalAlpha = 1;
}
}
ctx.restore();
}
// marquee preview
if (mode === 'select' && marqueeActive) {
ctx.save();
ctx.setLineDash([6 / view.s, 4 / view.s]);
ctx.lineWidth = 1.5 / view.s;
ctx.strokeStyle = 'rgba(59,130,246,0.8)';
ctx.fillStyle = 'rgba(59,130,246,0.12)';
const x = Math.min(marqueeStart.x, marqueeEnd.x);
const y = Math.min(marqueeStart.y, marqueeEnd.y);
const w = Math.abs(marqueeStart.x - marqueeEnd.x);
const h = Math.abs(marqueeStart.y - marqueeEnd.y);
ctx.strokeRect(x, y, w, h);
ctx.fillRect(x, y, w, h);
ctx.restore();
}
// eraser preview
if (mode === 'erase' && mouseInside) {
ctx.save();
ctx.beginPath();
ctx.arc(mousePos.x, mousePos.y, eraserRadius, 0, Math.PI * 2);
ctx.lineWidth = 1.5 / view.s;
ctx.strokeStyle = 'rgba(31,41,55,0.8)';
ctx.setLineDash([4 / view.s, 4 / view.s]);
ctx.stroke();
ctx.restore();
}
// eyedropper preview
if (mode === 'eyedropper' && mouseInside) {
ctx.save();
ctx.beginPath();
ctx.arc(mousePos.x, mousePos.y, 10 / view.s, 0, Math.PI * 2);
ctx.lineWidth = 2 / view.s;
ctx.strokeStyle = '#fff';
ctx.stroke();
ctx.lineWidth = 1 / view.s;
ctx.strokeStyle = '#000';
ctx.stroke();
ctx.restore();
}
ctx.restore();
// Debug overlay
const dbg = [
`mode:${mode}`,
`balloons:${balloons.length}`,
`garlandLen:${garlandPath.length}`,
`pointerDown:${pointerDown}`,
`lastCommit:${lastCommitMode || '-'}`,
`lastAdd:${lastAddStatus || '-'}`,
`dpr:${dpr.toFixed(2)} s:${view.s.toFixed(2)} canvas:${canvas.width}x${canvas.height}`,
`down:${evtStats.down} up:${evtStats.up} cancel:${evtStats.cancel} touchEnd:${evtStats.touchEnd} add:${evtStats.addBalloon} type:${evtStats.lastType}`
];
if (debugOverlay) debugOverlay.textContent = dbg.join(' | ');
}
new ResizeObserver(() => resizeCanvas()).observe(canvas.parentElement);
canvas.style.touchAction = 'none';
// ====== State Persistence ======
const APP_STATE_KEY = 'obd:state:v3';
const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1';
const getActiveOrganicTab = () => {
const active = document.body?.dataset?.activeTab || localStorage.getItem(ACTIVE_TAB_KEY) || '#tab-organic';
return (active === '#tab-helium') ? '#tab-helium' : '#tab-organic';
};
const getAppStateKey = (tabId) => (tabId === '#tab-helium')
? `${APP_STATE_KEY}:helium`
: `${APP_STATE_KEY}:organic`;
function saveAppState(tabId = getActiveOrganicTab()) {
// Note: isShineEnabled is managed globally.
const state = {
balloons,
selectedColorIdx,
currentDiameterInches,
eraserRadius,
view,
usedSortDesc,
garlandDensity,
garlandMainIdx,
garlandAccentIdx,
isBorderEnabled,
heliumPlacementType
};
try { localStorage.setItem(getAppStateKey(tabId), JSON.stringify(state)); } catch {}
}
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
function resetAppState() {
balloons = [];
selectedColorIdx = 0;
currentDiameterInches = 11;
currentRadius = inchesToRadiusPx(currentDiameterInches);
eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
view = { s: 1, tx: 0, ty: 0 };
usedSortDesc = true;
garlandPath = [];
garlandDensity = 1;
garlandMainIdx = [selectedColorIdx];
garlandAccentIdx = -1;
isBorderEnabled = true;
heliumPlacementType = 'balloon';
if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled;
selectedIds.clear();
setMode('draw');
}
function loadAppState(tabId = getActiveOrganicTab()) {
try {
const raw = localStorage.getItem(getAppStateKey(tabId));
if (!raw) {
resetAppState();
updateCurrentColorChip();
return;
}
const s = JSON.parse(raw || '{}');
if (Array.isArray(s.balloons)) balloons = s.balloons;
if (Array.isArray(balloons)) {
balloons.forEach(b => {
if (!b || typeof b !== 'object') return;
b.rotationDeg = normalizeRotationDeg(b.rotationDeg);
if (b.kind === 'curl260') {
const cfg = getCurlObjectConfig(b);
if (cfg) b.curl = cfg;
}
});
}
if (typeof s.selectedColorIdx === 'number') selectedColorIdx = s.selectedColorIdx;
if (typeof s.currentDiameterInches === 'number') {
currentDiameterInches = s.currentDiameterInches;
currentRadius = inchesToRadiusPx(currentDiameterInches);
}
if (typeof s.eraserRadius === 'number') {
eraserRadius = s.eraserRadius;
if (eraserSizeInput) eraserSizeInput.value = eraserRadius;
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
}
if (s.view && typeof s.view.s === 'number') { view = s.view; clampViewScale(); }
if (typeof s.usedSortDesc === 'boolean') {
usedSortDesc = s.usedSortDesc;
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
}
if (typeof s.garlandDensity === 'number') {
garlandDensity = clamp(s.garlandDensity, 0.6, 1.6);
if (garlandDensityInput) garlandDensityInput.value = garlandDensity;
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
}
if (Array.isArray(s.garlandMainIdx)) {
garlandMainIdx = s.garlandMainIdx.slice(0, 10).map(v => Number.isInteger(v) ? v : -1).filter((v, i) => i < 10);
if (!garlandMainIdx.length) garlandMainIdx = [selectedColorIdx];
}
if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx;
if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled;
if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled;
if (s.heliumPlacementType === 'balloon' || s.heliumPlacementType === 'curl260' || s.heliumPlacementType === 'weight' || s.heliumPlacementType === 'ribbon') {
heliumPlacementType = s.heliumPlacementType;
}
syncHeliumPlacementUi();
updateCurrentColorChip();
updateSelectButtons();
} catch {}
}
loadAppState();
resetHistory(); // establish initial history state for undo/redo controls
// ====== Garland color UI (dynamic chips) ======
const styleChip = (el, meta) => {
if (!el || !meta) return;
if (meta.image) {
el.style.backgroundImage = `url("${meta.image}")`;
el.style.backgroundColor = meta.hex || '#fff';
el.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`;
el.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
} else {
el.style.backgroundImage = 'none';
el.style.backgroundColor = meta.hex || '#f1f5f9';
}
};
const garlandMaxColors = 10;
function renderGarlandMainChips() {
if (!garlandMainChips) return;
garlandMainChips.innerHTML = '';
const items = garlandMainIdx.length ? garlandMainIdx : [selectedColorIdx];
items.forEach((idx, i) => {
const wrap = document.createElement('div');
wrap.className = 'flex items-center gap-1';
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'replace-chip garland-chip';
const meta = FLAT_COLORS[idx] || FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
styleChip(chip, meta);
chip.title = meta?.name || meta?.hex || 'Color';
chip.addEventListener('click', () => {
if (!window.openColorPicker) return;
window.openColorPicker({
title: 'Path color',
subtitle: 'Pick a main color',
items: (FLAT_COLORS || []).map((c, ci) => ({ label: c.name || c.hex, metaText: c.family || '', idx: ci })),
onSelect: (item) => {
garlandMainIdx[i] = item.idx;
renderGarlandMainChips();
if (mode === 'garland') requestDraw();
persist();
}
});
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn-yellow text-xs px-2 py-1';
removeBtn.textContent = '×';
removeBtn.title = 'Remove color';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
garlandMainIdx.splice(i, 1);
if (!garlandMainIdx.length) garlandMainIdx.push(selectedColorIdx);
renderGarlandMainChips();
if (mode === 'garland') requestDraw();
persist();
});
wrap.appendChild(chip);
wrap.appendChild(removeBtn);
garlandMainChips.appendChild(wrap);
});
}
garlandAddColorBtn?.addEventListener('click', () => {
if (garlandMainIdx.length >= garlandMaxColors) { showModal(`Max ${garlandMaxColors} colors.`); return; }
if (!window.openColorPicker) return;
window.openColorPicker({
title: 'Add path color',
subtitle: 'Choose a main color',
items: (FLAT_COLORS || []).map((c, ci) => ({ label: c.name || c.hex, metaText: c.family || '', idx: ci })),
onSelect: (item) => {
garlandMainIdx.push(item.idx);
renderGarlandMainChips();
if (mode === 'garland') requestDraw();
persist();
}
});
});
const updateAccentChip = () => {
if (!garlandAccentChip) return;
const meta = garlandAccentIdx >= 0 ? FLAT_COLORS[garlandAccentIdx] : null;
styleChip(garlandAccentChip, meta || { hex: '#f8fafc' });
};
garlandAccentChip?.addEventListener('click', () => {
if (!window.openColorPicker) return;
window.openColorPicker({
title: 'Accent color',
subtitle: 'Choose a 5" accent color',
items: (FLAT_COLORS || []).map((c, ci) => ({ label: c.name || c.hex, metaText: c.family || '', idx: ci })),
onSelect: (item) => {
garlandAccentIdx = item.idx;
updateAccentChip();
if (mode === 'garland') requestDraw();
persist();
}
});
});
garlandAccentClearBtn?.addEventListener('click', () => {
garlandAccentIdx = -1;
updateAccentChip();
if (mode === 'garland') requestDraw();
persist();
});
bindActiveChipPicker();
// ====== UI Rendering (Palettes) ======
function renderAllowedPalette() {
if (!paletteBox) return;
paletteBox.innerHTML = '';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-dark w-full';
btn.textContent = 'Choose color';
btn.addEventListener('click', () => {
if (!window.openColorPicker) return;
window.openColorPicker({
title: 'Choose active color',
subtitle: 'Applies to drawing and path tools',
items: (FLAT_COLORS || []).map((c, idx) => ({ label: c.name || c.hex, metaText: c.family || '', idx })),
onSelect: (item) => {
if (!Number.isInteger(item.idx)) return;
selectedColorIdx = item.idx;
updateCurrentColorChip();
persist();
}
});
});
paletteBox.appendChild(btn);
}
function getUsedColors() {
const map = new Map();
balloons.forEach(b => {
const key = normalizeHex(b.color);
if (!allowedSet.has(key)) return;
if (!map.has(key)) {
const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(key)] || {};
map.set(key, { hex: key, count: 0, image: meta.image, name: meta.name });
}
map.get(key).count++;
});
const arr = [...map.values()];
arr.sort((a, b) => (usedSortDesc ? (b.count - a.count) : (a.count - b.count)));
return arr;
}
function updateCurrentColorChip() {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
const updateChip = (chipId, labelId, { showLabel = true } = {}) => {
const chip = document.getElementById(chipId);
const label = labelId ? document.getElementById(labelId) : null;
if (!chip || !meta) return;
if (meta.image) {
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
chip.style.backgroundImage = `url("${meta.image}")`;
chip.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`;
chip.style.backgroundPosition = `${fx * 100}% ${fy * 100}%`;
chip.style.backgroundColor = '#fff';
} else {
chip.style.backgroundImage = 'none';
chip.style.backgroundColor = meta.hex || '#fff';
}
if (label) {
label.textContent = showLabel ? (meta.name || meta.hex || 'Current') : '';
label.title = meta.name || meta.hex || 'Current';
}
chip.title = meta.name || meta.hex || 'Current';
};
updateChip('current-color-chip', 'current-color-label', { showLabel: true });
updateChip('current-color-chip-global', 'current-color-label-global', { showLabel: false });
updateChip('mobile-active-color-chip', null, { showLabel: false });
updateChip('quick-color-chip', null, { showLabel: false });
}
function bindActiveChipPicker() {
const chips = ['current-color-chip', 'mobile-active-color-chip', 'quick-color-chip', 'quick-color-btn'];
chips.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.style.cursor = 'pointer';
el.addEventListener('click', () => {
if (!window.openColorPicker) return;
window.openColorPicker({
title: 'Choose active color',
subtitle: 'Applies to drawing and path tools',
items: (FLAT_COLORS || []).map((c, idx) => ({ label: c.name || c.hex, metaText: c.family || '', idx })),
onSelect: (item) => {
if (!Number.isInteger(item.idx)) return;
selectedColorIdx = item.idx;
updateCurrentColorChip();
persist();
}
});
});
});
}
window.organic = {
getColor: () => selectedColorIdx,
updateCurrentColorChip,
setColor: (idx) => {
if (!Number.isInteger(idx)) return;
selectedColorIdx = idx;
renderAllowedPalette?.();
renderUsedPalette?.();
updateCurrentColorChip?.();
persist?.();
},
buildOrganicSvgPayload,
};
function renderUsedPalette() {
if (!usedPaletteBox) return;
usedPaletteBox.innerHTML = '<div class="hint">Palette opens in modal.</div>';
if (replaceFromSel) replaceFromSel.innerHTML = '';
}
// ====== Balloon Ops & Data/Export ======
function buildBalloon(meta, x, y, radius) {
const b = {
kind: 'balloon',
x, y,
radius,
rotationDeg: 0,
color: meta.hex,
image: meta.image || null,
colorIdx: meta._idx,
id: makeId()
};
return b;
}
function buildCurlObject(meta, x, y, radius) {
return {
kind: 'curl260',
x, y,
radius,
rotationDeg: 0,
color: meta.hex,
image: null,
colorIdx: meta._idx,
id: makeId(),
curl: buildCurrentRibbonConfig()
};
}
function buildWeightObject(meta, x, y, radius) {
return {
kind: 'weight',
x, y,
radius,
rotationDeg: 0,
color: meta.hex,
image: null,
colorIdx: meta._idx,
id: makeId()
};
}
function buildRibbonObject(meta, fromNode, toNode, freeEnd) {
return {
kind: 'ribbon',
color: meta.hex,
image: null,
colorIdx: meta._idx,
id: makeId(),
from: fromNode ? { kind: fromNode.kind, id: fromNode.id } : null,
to: toNode ? { kind: toNode.kind, id: toNode.id } : null,
freeEnd: freeEnd ? { x: freeEnd.x, y: freeEnd.y } : null,
lengthScale: 1,
rotationDeg: 0
};
}
function resetRibbonDraft() {
ribbonDraftStart = null;
ribbonDraftMouse = null;
}
function handleRibbonPlacementAt(x, y) {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
if (!meta) return;
const node = findRibbonNodeAt(x, y);
if (!ribbonDraftStart) {
if (!node || node.kind !== 'balloon') {
showModal('Start ribbon on a balloon nozzle.');
return;
}
ribbonDraftStart = { kind: node.kind, id: node.id };
ribbonDraftMouse = { x, y };
requestDraw();
return;
}
const startObj = balloons.find(b => b.id === ribbonDraftStart.id);
if (!startObj) { resetRibbonDraft(); return; }
const targetNode = node && node.id !== ribbonDraftStart.id ? node : null;
if (targetNode && targetNode.kind !== 'weight') {
showModal('Ribbon can connect from balloon nozzle to a weight.');
return;
}
if (balloons.length >= MAX_BALLOONS) {
showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`);
resetRibbonDraft();
return;
}
const ribbon = buildRibbonObject(meta, ribbonDraftStart, targetNode, targetNode ? null : { x, y });
balloons.push(ribbon);
resetRibbonDraft();
refreshAll();
pushHistory();
}
function scaleSelectedRibbons(multiplier) {
const sel = selectionBalloons().filter(b => b?.kind === 'ribbon');
if (!sel.length) return;
sel.forEach(r => {
const base = Number(r.lengthScale) || 1;
r.lengthScale = clamp(base * multiplier, 0.4, 2.2);
});
refreshAll();
pushHistory();
}
function startAttachSelectedRibbonsToWeight() {
const sel = selectionBalloons().filter(b => b?.kind === 'ribbon');
if (!sel.length) return;
ribbonAttachMode = true;
showModal('Attach mode: click a weight to connect selected ribbon(s).');
}
function attachSelectedRibbonsToWeight(weightId) {
const sel = selectionBalloons().filter(b => b?.kind === 'ribbon');
if (!sel.length) return false;
let changed = false;
sel.forEach(r => {
r.to = { kind: 'weight', id: weightId };
r.freeEnd = null;
changed = true;
});
ribbonAttachMode = false;
if (changed) {
refreshAll();
pushHistory();
}
return changed;
}
function addBalloon(x, y) {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
if (balloons.length >= MAX_BALLOONS) {
lastAddStatus = 'limit';
showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`);
return;
}
const isHelium = getActiveOrganicTab() === '#tab-helium';
const placingCurl = isHelium && heliumPlacementType === 'curl260';
const placingWeight = isHelium && heliumPlacementType === 'weight';
balloons.push(
placingCurl
? buildCurlObject(meta, x, y, currentRadius)
: (placingWeight ? buildWeightObject(meta, x, y, currentRadius) : buildBalloon(meta, x, y, currentRadius))
);
lastAddStatus = 'balloon';
evtStats.addBalloon += 1;
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
refreshAll();
pushHistory();
}
function garlandSeed(path) {
let h = 2166136261 >>> 0;
path.forEach(p => {
h ^= Math.round(p.x * 10) + 0x9e3779b9;
h = Math.imul(h, 16777619);
h ^= Math.round(p.y * 10);
h = Math.imul(h, 16777619);
});
return h >>> 0 || 1;
}
function computeGarlandNodes(path) {
if (!Array.isArray(path) || path.length < 2) return [];
const baseRadius = inchesToRadiusPx(GARLAND_BASE_DIAM);
const fillerRadii = GARLAND_FILLER_DIAMS.map(inchesToRadiusPx);
const accentRadius = inchesToRadiusPx(GARLAND_ACCENT_DIAM);
const spacing = Math.max(10, baseRadius * (GARLAND_SPACING_RATIO / garlandDensity));
const nodes = [];
let carry = 0;
const rng = makeSeededRng(garlandSeed(path));
for (let i = 0; i < path.length - 1; i++) {
const a = path[i];
const b = path[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const segLen = Math.hypot(dx, dy);
if (segLen < 1) continue;
let dist = carry;
const nx = segLen > 0 ? (dy / segLen) : 0;
const ny = segLen > 0 ? (-dx / segLen) : 0;
while (dist <= segLen) {
const t = dist / segLen;
const px = a.x + dx * t;
const py = a.y + dy * t;
const side = rng() > 0.5 ? 1 : -1;
const wobble = (rng() * 2 - 1) * baseRadius * GARLAND_WOBBLE_RATIO;
const sizeJitter = 1 + (rng() * 2 - 1) * GARLAND_SIZE_JITTER;
const r = clamp(baseRadius * sizeJitter, baseRadius * 0.75, baseRadius * 1.35);
const baseX = px + nx * wobble;
const baseY = py + ny * wobble;
nodes.push({ x: baseX, y: baseY, radius: r, type: 'base' });
// filler balloons hugging the base to thicken the line
const fillerR = fillerRadii[Math.floor(rng() * fillerRadii.length)] || baseRadius * 0.7;
const offset1 = r * 0.7;
nodes.push({
x: baseX + nx * offset1 * side,
y: baseY + ny * offset1 * side,
radius: fillerR * (0.9 + rng() * 0.2),
type: 'filler'
});
const tangentSide = side * (rng() > 0.5 ? 1 : -1);
const offset2 = r * 0.5;
nodes.push({
x: baseX + (-ny) * offset2 * tangentSide,
y: baseY + (nx) * offset2 * tangentSide,
radius: fillerR * (0.8 + rng() * 0.25),
type: 'filler'
});
// Tight cluster of three 5" balloons, slightly more open
{
const clusterCenterX = baseX + nx * r * 0.25 * side;
const clusterCenterY = baseY + ny * r * 0.25 * side;
const baseAng = rng() * Math.PI * 2;
const mags = [0.7, 0.95, 0.8].map(m => m * accentRadius);
const angs = [baseAng, baseAng + (2 * Math.PI / 3), baseAng + (4 * Math.PI / 3)];
for (let c = 0; c < 3; c++) {
const mag = mags[c] * (0.98 + rng() * 0.08);
const jitterAng = angs[c] + (rng() * 0.25 - 0.125);
nodes.push({
x: clusterCenterX + Math.cos(jitterAng) * mag,
y: clusterCenterY + Math.sin(jitterAng) * mag,
radius: accentRadius * (0.88 + rng() * 0.15),
type: 'accent'
});
}
}
dist += spacing;
}
carry = dist - segLen;
}
return nodes;
}
function addGarlandFromPath(path) {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
if (!meta) return;
const nodes = computeGarlandNodes(path).sort((a, b) => b.radius - a.radius); // draw larger first so small accents sit on top
if (!nodes.length) { lastAddStatus = 'garland:none'; return; }
const available = Math.max(0, MAX_BALLOONS - balloons.length);
const limitedNodes = available ? nodes.slice(0, available) : [];
if (!limitedNodes.length) { lastAddStatus = 'limit'; showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; }
const newIds = [];
const rng = makeSeededRng(garlandSeed(path) + 101);
const metaFromIdx = idx => {
const m = FLAT_COLORS[idx];
return m ? m : meta;
};
const pickMainMeta = () => {
const choices = garlandMainIdx.filter(v => Number.isFinite(v) && v >= 0 && FLAT_COLORS[v]);
if (!choices.length) return meta;
const pick = choices.length === 1 ? choices[0] : choices[Math.floor(rng() * choices.length)];
return metaFromIdx(pick);
};
const accentMeta = (garlandAccentIdx >= 0 && FLAT_COLORS[garlandAccentIdx])
? FLAT_COLORS[garlandAccentIdx]
: metaFromIdx(garlandMainIdx.find(v => v >= 0));
limitedNodes.forEach(n => {
const m = n.type === 'accent' ? accentMeta : pickMainMeta();
const b = buildBalloon(m, n.x, n.y, n.radius);
balloons.push(b);
newIds.push(b.id);
});
lastAddStatus = `garland:${newIds.length}`;
if (newIds.length) {
selectedIds.clear();
updateSelectButtons();
}
refreshAll();
pushHistory();
}
function findBalloonIndexAt(x, y) {
for (let i = balloons.length - 1; i >= 0; i--) {
const b = balloons[i];
if (b?.kind === 'ribbon') {
const hit = ribbonDistanceToPoint(b, x, y);
if (hit <= Math.max(8 / view.s, 5)) return i;
continue;
}
if (b?.kind === 'weight') {
const grabR = Math.max(b.radius * 2.1, 22);
if (Math.hypot(x - b.x, y - b.y) <= grabR) return i;
if (weightHitTest(b, x, y, 0, { loose: true })) return i;
continue;
}
const hitR = (b?.kind === 'curl260') ? (b.radius * 1.35) : getBalloonVisualRadius(b, getActiveOrganicTab());
if (Math.hypot(x - b.x, y - b.y) <= hitR) return i;
}
return -1;
}
function findWeightIndexAt(x, y) {
for (let i = balloons.length - 1; i >= 0; i--) {
const b = balloons[i];
if (b?.kind !== 'weight') continue;
const grabR = Math.max(b.radius * 2.1, 22);
if (Math.hypot(x - b.x, y - b.y) <= grabR) return i;
if (weightHitTest(b, x, y, 0, { loose: true })) return i;
}
return -1;
}
function selectAt(x, y) {
const i = findBalloonIndexAt(x, y);
selectedIds.clear();
if (i !== -1) selectedIds.add(balloons[i].id);
updateSelectButtons();
draw();
}
function moveSelected(dx, dy) {
const sel = selectionBalloons();
if (!sel.length) return;
sel.forEach(b => {
if (b?.kind === 'ribbon') {
if (b.freeEnd) {
b.freeEnd.x += dx;
b.freeEnd.y += dy;
}
return;
}
b.x += dx;
b.y += dy;
});
refreshAll();
pushHistory();
}
function resizeSelected(newDiamInches) {
const sel = selectionBalloons();
if (!sel.length) return;
const diam = clamp(newDiamInches, 5, 32);
const newRadius = inchesToRadiusPx(diam);
let changed = false;
sel.forEach(b => {
if (!Number.isFinite(b.radius)) return;
b.radius = newRadius;
changed = true;
});
if (!changed) return;
refreshAll();
updateSelectButtons();
resizeChanged = true;
clearTimeout(resizeSaveTimer);
resizeSaveTimer = setTimeout(() => {
if (resizeChanged) {
pushHistory();
resizeChanged = false;
}
}, 200);
}
function rotateSelected(deltaDeg, { absolute = false } = {}) {
const sel = selectionBalloons();
if (!sel.length) return;
sel.forEach(b => {
if (b?.kind === 'ribbon') return;
const next = absolute ? Number(deltaDeg || 0) : ((Number(b.rotationDeg) || 0) + Number(deltaDeg || 0));
b.rotationDeg = normalizeRotationDeg(next);
});
refreshAll();
pushHistory();
}
function bringSelectedForward() {
const sel = selectionArray();
if (!sel.length) return;
const set = new Set(sel);
const kept = balloons.filter(b => !set.has(b.id));
const moving = balloons.filter(b => set.has(b.id));
balloons = kept.concat(moving);
refreshAll({ autoFit: true });
pushHistory();
}
function sendSelectedBackward() {
const sel = selectionArray();
if (!sel.length) return;
const set = new Set(sel);
const moving = balloons.filter(b => set.has(b.id));
const kept = balloons.filter(b => !set.has(b.id));
balloons = moving.concat(kept);
refreshAll({ autoFit: true });
pushHistory();
}
function applyColorToSelected() {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
if (!meta) return;
let changed = false;
selectionBalloons().forEach(b => {
b.color = meta.hex;
b.image = meta.image || null;
b.colorIdx = meta._idx;
changed = true;
});
if (!changed) return;
refreshAll();
pushHistory();
}
function deleteSelected() {
if (!selectedIds.size) return;
balloons = balloons.filter(b => !selectedIds.has(b.id));
selectedIds.clear();
updateSelectButtons();
refreshAll({ autoFit: true });
pushHistory();
}
function duplicateSelected() {
const sel = selectionBalloons();
if (!sel.length) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
sel.forEach(b => {
const bb = getObjectBounds(b);
minX = Math.min(minX, bb.minX);
maxX = Math.max(maxX, bb.maxX);
minY = Math.min(minY, bb.minY);
maxY = Math.max(maxY, bb.maxY);
});
const width = Math.max(1, maxX - minX);
const dx = width + 18;
const dy = 0;
const copies = sel.map(b => {
const c = { ...b, id: makeId() };
if (Number.isFinite(c.x) && Number.isFinite(c.y)) { c.x += dx; c.y += dy; }
if (c.freeEnd && Number.isFinite(c.freeEnd.x) && Number.isFinite(c.freeEnd.y)) {
c.freeEnd = { x: c.freeEnd.x + dx, y: c.freeEnd.y + dy };
}
return c;
});
copies.forEach(c => balloons.push(c));
selectedIds = new Set(copies.map(c => c.id));
refreshAll({ autoFit: true });
updateSelectButtons();
pushHistory();
}
function eraseAt(x, y) {
const before = balloons.length;
balloons = balloons.filter(b => {
if (b?.kind === 'ribbon') return ribbonDistanceToPoint(b, x, y) > Math.max(eraserRadius * 0.5, 6 / view.s);
if (b?.kind === 'weight') return !weightHitTest(b, x, y, eraserRadius * 0.8);
const hitR = (b?.kind === 'curl260') ? (b.radius * 1.35) : getBalloonVisualRadius(b, getActiveOrganicTab());
return Math.hypot(x - b.x, y - b.y) > (eraserRadius + hitR * 0.15);
});
const removed = balloons.length !== before;
if (selectedIds.size) {
const set = new Set(balloons.map(b => b.id));
let changed = false;
selectedIds.forEach(id => { if (!set.has(id)) { selectedIds.delete(id); changed = true; } });
if (changed) updateSelectButtons();
}
if (removed && !erasingActive) requestDraw();
return removed;
}
function pickColorAt(x, y) {
const i = findBalloonIndexAt(x, y);
if (i !== -1) {
selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0;
renderAllowedPalette();
renderUsedPalette();
updateCurrentColorChip();
}
}
function promptForFilename(suggested) {
const m = suggested.match(/\.([a-z0-9]+)$/i);
const ext = m ? m[1].toLowerCase() : '';
const defaultBase = suggested.replace(/\.[^.]+$/, '');
const lsKey = ext ? `lastFilenameBase.${ext}` : `lastFilenameBase`;
const last = localStorage.getItem(lsKey) || defaultBase;
const input = window.prompt(ext ? `File name (.${ext} will be added)` : 'File name', last);
if (input === null) return null;
let base = (input.trim() || defaultBase)
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
.replace(/\.+$/, '')
.replace(/\.[^.]+$/, '');
try { localStorage.setItem(lsKey, base); } catch {}
return ext ? `${base}.${ext}` : base;
}
function download(href, suggestedFilename) {
const finalName = promptForFilename(suggestedFilename);
if (!finalName) return;
sharedDownload?.(href, finalName);
}
function saveJson() {
download('data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify({ balloons })), 'balloon_design.json');
}
function loadJson(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
const loaded = Array.isArray(data.balloons)
? data.balloons.map(b => {
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
const meta = FLAT_COLORS[idx] || {};
return {
x: b.x, y: b.y, radius: b.radius,
color: meta.hex || b.color,
image: meta.image || null,
colorIdx: idx,
id: makeId()
};
})
: [];
balloons = loaded.slice(0, MAX_BALLOONS);
selectedIds.clear();
updateSelectButtons();
refreshAll({ refit: true });
resetHistory();
persist();
if (loaded.length > MAX_BALLOONS) {
showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`);
}
} catch {
showModal('Error parsing JSON file.');
}
};
reader.readAsText(file);
}
// ====== Export helpers ======
let lastActiveTab = getActiveOrganicTab();
const getImageHref = getImageHrefShared;
function setImageHref(el, val) {
el.setAttribute('href', val);
el.setAttributeNS(XLINK_NS, 'xlink:href', val);
}
async function embedImagesInSvg(svgEl) {
const images = Array.from(svgEl.querySelectorAll('image'));
const hrefs = [...new Set(images.map(getImageHref).filter(h => h && !h.startsWith('data:')))];
const urlMap = new Map();
await Promise.all(hrefs.map(async (href) => {
urlMap.set(href, await imageUrlToDataUrl(href));
}));
images.forEach(img => {
const orig = getImageHref(img);
const val = urlMap.get(orig);
if (val) setImageHref(img, val);
});
return svgEl;
}
function pointsToPathD(points) {
if (!Array.isArray(points) || points.length < 2) return '';
let d = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) d += ` L ${points[i].x} ${points[i].y}`;
return d;
}
function buildCurlPathPoints(b, ribbonCfg) {
const cfg = ribbonCfg || buildCurrentRibbonConfig();
const seed = hashString32(b.id || `${b.x},${b.y}`);
const side = (seed & 1) ? 1 : -1;
const phase = ((seed >>> 3) % 628) / 100;
const len = Math.max(20, b.radius * 2 * cfg.length);
const stem = Math.max(6, b.radius * 0.28);
const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012)));
const turns = Math.max(1, Math.min(5, cfg.turns | 0));
const anchorX = b.x;
const anchorY = b.y - b.radius * 0.55;
const isSpiral = cfg.style === 'spiral';
const steps = isSpiral ? 42 : 28;
const pts = [{ x: anchorX, y: anchorY }, { x: anchorX, y: anchorY + stem }];
if (isSpiral) {
const leadSteps = Math.floor(steps * 0.33);
const topLead = Math.max(8, len * 0.28);
const topAmp = Math.max(6, b.radius * 0.95);
for (let i = 1; i <= leadSteps; i++) {
const t = i / leadSteps;
const y = anchorY + stem + topLead * t;
const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15);
pts.push({ x, y });
}
const startY = anchorY + stem + topLead;
const remain = Math.max(8, len - topLead);
const coilAmp = Math.max(5, b.radius * 0.5);
const coilSteps = Math.max(1, steps - leadSteps);
for (let i = 1; i <= coilSteps; i++) {
const t = i / coilSteps;
const angle = (Math.PI * 2 * turns * t) + phase;
const decay = 1 - t * 0.55;
const x = anchorX + side * (coilAmp * decay) * Math.sin(angle);
const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle);
pts.push({ x, y });
}
} else {
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const falloff = 1 - t * 0.25;
const y = anchorY + stem + len * t;
const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side;
pts.push({ x, y });
}
}
return pts;
}
async function buildOrganicSvgPayload() {
if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.');
const activeOrgTab = getActiveOrganicTab();
const needsWeightImage = balloons.some(b => b?.kind === 'weight');
const uniqueImageUrls = [...new Set([
...balloons.map(b => b.image).filter(Boolean),
...(needsWeightImage ? [WEIGHT_IMAGE_URL] : [])
])];
const dataUrlMap = new Map();
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
const bounds = balloonsBounds();
const pad = 120; // extra room to avoid clipping drop-shadows and outlines
const width = bounds.w + pad * 2;
const height = bounds.h + pad * 2;
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');
let defs = '';
let elements = '';
const patterns = new Map();
const shadowFilters = new Map();
let clipCounter = 0;
const ensureShadowFilter = (dx, dy, blurPx, alpha) => {
const key = `${dx}|${dy}|${blurPx}|${alpha}`;
if (!shadowFilters.has(key)) {
const id = `shadow-${shadowFilters.size}`;
const stdDev = Math.max(0.01, blurPx * 0.5);
const clampedAlpha = clamp01(alpha);
const flood = `<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);
const weightImageHref = dataUrlMap.get(WEIGHT_IMAGE_URL) || WEIGHT_IMAGE_URL;
const ensureClipPath = (pathD, transform = '') => {
const id = `clip-${clipCounter++}`;
const tAttr = transform ? ` transform="${transform}"` : '';
defs += `<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><path d="${pathD}"${tAttr} /></clipPath>`;
return id;
};
const wrapWithRotation = (markup, b) => {
const deg = Number(b?.rotationDeg) || 0;
if (!deg) return markup;
return `<g transform="rotate(${deg} ${b.x} ${b.y})">${markup}</g>`;
};
balloons.forEach(b => {
const kind = b?.kind || 'balloon';
const meta = FLAT_COLORS[b.colorIdx] || {};
if (kind === 'ribbon') {
const pts = getRibbonPoints(b);
if (!pts || pts.length < 2) return;
const d = pointsToPathD(pts);
const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#999999')) || { r: 120, g: 120, b: 120 };
const w = Math.max(1.9, 2.2);
if (isBorderEnabled) {
elements += `<path d="${d}" fill="none" stroke="#111827" stroke-width="${w + 1.2}" stroke-linecap="round" stroke-linejoin="round" />`;
}
elements += `<path d="${d}" fill="none" stroke="rgb(${rgb.r},${rgb.g},${rgb.b})" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" />`;
if (b?.from?.kind === 'balloon') {
const p0 = pts[0];
const p1 = pts[Math.min(1, pts.length - 1)];
const vx = p1.x - p0.x;
const vy = p1.y - p0.y;
const vl = Math.max(1e-6, Math.hypot(vx, vy));
const ux = vx / vl;
const uy = vy / vl;
const nx = -uy;
const ny = ux;
const side = (hashString32(b.id || '') & 1) ? 1 : -1;
const amp = Math.max(3.2, 2.3);
const len = Math.max(12, 8.5);
const steps = 14;
const curlPts = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const dd = len * t;
const x = p0.x + ux * dd + nx * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
const y = p0.y + uy * dd + ny * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
curlPts.push({ x, y });
}
const curlD = pointsToPathD(curlPts);
if (curlD) {
if (isBorderEnabled) {
elements += `<path d="${curlD}" fill="none" stroke="#111827" stroke-width="${w + 1.2}" stroke-linecap="round" stroke-linejoin="round" />`;
}
elements += `<path d="${curlD}" fill="none" stroke="rgb(${rgb.r},${rgb.g},${rgb.b})" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" />`;
}
}
return;
}
if (kind === 'curl260') {
const ribbonCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig();
const pts = buildCurlPathPoints(b, ribbonCfg);
const d = pointsToPathD(pts);
if (!d) return;
const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#999999')) || { r: 120, g: 120, b: 120 };
const w = Math.max(2.6, 2.4 + b.radius * 0.14);
let markup = '';
if (isBorderEnabled) {
markup += `<path d="${d}" fill="none" stroke="#111827" stroke-width="${w + 1.2}" stroke-linecap="round" stroke-linejoin="round" />`;
}
markup += `<path d="${d}" fill="none" stroke="rgb(${rgb.r},${rgb.g},${rgb.b})" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" />`;
elements += wrapWithRotation(markup, b);
return;
}
if (kind === 'weight') {
const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 };
if (weightMaskPathData) {
const wt = getWeightMaskTransform(b);
const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
const pathTx = `translate(${b.x} ${b.y}) scale(${wt.scale}) translate(${-mb.cx} ${-mb.cy})`;
const clipId = ensureClipPath(weightMaskPathData, pathTx);
let markup = '';
if (weightImageHref) {
markup += `<image href="${weightImageHref}" x="${wt.minX}" y="${wt.minY}" width="${Math.max(1, mb.w * wt.scale)}" height="${Math.max(1, mb.h * wt.scale)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${clipId})" />`;
markup += `<rect x="${wt.minX}" y="${wt.minY}" width="${Math.max(1, mb.w * wt.scale)}" height="${Math.max(1, mb.h * wt.scale)}" fill="rgba(${rgb.r},${rgb.g},${rgb.b},0.34)" clip-path="url(#${clipId})" />`;
} else {
markup += `<path d="${weightMaskPathData}" transform="${pathTx}" fill="rgb(${rgb.r},${rgb.g},${rgb.b})" />`;
}
if (isBorderEnabled) {
markup += `<path d="${weightMaskPathData}" transform="${pathTx}" fill="none" stroke="#111827" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" />`;
}
elements += wrapWithRotation(markup, b);
return;
}
const base = b.radius;
const bagW = Math.max(18, base * 1.6);
const bagH = Math.max(20, base * 2.2);
const bagTopY = b.y - bagH * 0.95 + Math.max(5, bagH * 0.14) + 2;
const bagD = `M ${b.x - bagW * 0.42} ${bagTopY}
Q ${b.x - bagW * 0.62} ${b.y + bagH * 0.05} ${b.x - bagW * 0.34} ${b.y + bagH * 0.78}
Q ${b.x} ${b.y + bagH * 0.98} ${b.x + bagW * 0.34} ${b.y + bagH * 0.78}
Q ${b.x + bagW * 0.62} ${b.y + bagH * 0.05} ${b.x + bagW * 0.42} ${bagTopY} Z`;
let markup = `<path d="${bagD}" fill="rgb(${rgb.r},${rgb.g},${rgb.b})" />`;
if (isBorderEnabled) markup += `<path d="${bagD}" fill="none" stroke="#111827" stroke-width="1.2" />`;
elements += wrapWithRotation(markup, b);
return;
}
let fill = b.color;
if (b.image) {
const patternKey = `${b.colorIdx}|${b.image}`;
const imageHref = dataUrlMap.get(b.image);
if (!patterns.has(patternKey) && imageHref) {
const patternId = `p${patterns.size}`;
patterns.set(patternKey, patternId);
const meta = FLAT_COLORS[b.colorIdx] || {};
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
const imgW = zoom, imgH = zoom;
const imgX = 0.5 - (fx * zoom);
const imgY = 0.5 - (fy * zoom);
defs += `<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 sizeIndex = radiusToSizeIndex(b.radius);
const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
const maskInfo = getBalloonMaskShape(sizePreset, activeOrgTab);
const hasMask = activeOrgTab === '#tab-helium' && !!maskInfo.path;
const maskPathD = (activeOrgTab === '#tab-helium' && sizePreset === 24) ? balloon24MaskPathData : balloonMaskPathData;
if (hasMask && maskPathD) {
const mb = maskInfo.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab);
const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost;
const pathTx = `translate(${b.x} ${b.y}) scale(${scale}) translate(${-mb.cx} ${-mb.cy})`;
const clipId = ensureClipPath(maskPathD, pathTx);
const destX = b.x - (mb.cx - mb.x) * scale;
const destY = b.y - (mb.cy - mb.y) * scale;
const destW = Math.max(1, mb.w * scale);
const destH = Math.max(1, mb.h * scale);
let markup = '';
if (b.image) {
const imageHref = dataUrlMap.get(b.image) || b.image;
markup += `<image href="${imageHref}" x="${destX}" y="${destY}" width="${destW}" height="${destH}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${clipId})"${filterAttr} />`;
} else {
markup += `<rect x="${destX}" y="${destY}" width="${destW}" height="${destH}" fill="${b.color}" clip-path="url(#${clipId})"${filterAttr} />`;
}
if (isBorderEnabled) {
markup += `<path d="${maskPathD}" transform="${pathTx}" fill="none" stroke="#111827" stroke-width="0.5" />`;
}
elements += wrapWithRotation(markup, b);
} else {
const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
elements += wrapWithRotation(`<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}"${strokeAttr}${filterAttr} />`, b);
}
if (isShineEnabled) {
const visualRadius = getBalloonVisualRadius(b, activeOrgTab);
const shineScale = hasMask ? ((sizePreset === 11) ? 0.68 : 0.8) : 1;
const shineOffsetScale = hasMask ? 0.78 : 1;
const sx = b.x - visualRadius * SHINE_OFFSET * shineOffsetScale;
const sy = b.y - visualRadius * SHINE_OFFSET * shineOffsetScale;
const rx = visualRadius * SHINE_RX * shineScale;
const ry = visualRadius * SHINE_RY * shineScale;
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : '';
const shineFilter = shineShadowId ? ` filter="url(#${shineShadowId})"` : '';
const shineMarkup = `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke}${shineFilter} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
elements += wrapWithRotation(shineMarkup, b);
}
});
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) {
const bb = getObjectBounds(b);
minX = Math.min(minX, bb.minX);
minY = Math.min(minY, bb.minY);
maxX = Math.max(maxX, bb.maxX);
maxY = Math.max(maxY, bb.maxY);
}
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
}
function fitView() {
const box = balloonsBounds();
const cw = canvas.width / dpr; // CSS px
const ch = canvas.height / dpr;
if (balloons.length === 0) { view.s = 1; clampViewScale(); view.tx = 0; view.ty = 0; return; }
const pad = FIT_PADDING_PX;
const w = Math.max(1, box.w);
const h = Math.max(1, box.h);
const sFit = Math.min((cw - 2*pad) / w, (ch - 2*pad) / h);
view.s = Math.min(VIEW_MAX_SCALE, isFinite(sFit) && sFit > 0 ? sFit : 1);
clampViewScale();
const worldW = cw / view.s;
const worldH = ch / view.s;
view.tx = (worldW - w) * 0.5 - box.minX;
view.ty = (worldH - h) * 0.5 - box.minY;
}
function balloonScreenBounds(b) {
const bb = getObjectBounds(b);
const left = (bb.minX + view.tx) * view.s;
const right = (bb.maxX + view.tx) * view.s;
const top = (bb.minY + view.ty) * view.s;
const bottom = (bb.maxY + view.ty) * view.s;
return { left, right, top, bottom };
}
function ensureVisibleAfterAdd(b) {
const pad = FIT_PADDING_PX;
const cw = canvas.width / dpr;
const ch = canvas.height / dpr;
// zoom out only if needed to keep the new balloon visible
const bb = getObjectBounds(b);
const needSx = (cw - 2*pad) / Math.max(1, bb.w);
const needSy = (ch - 2*pad) / Math.max(1, bb.h);
const sNeeded = Math.min(needSx, needSy);
if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) {
view.s = Math.max(VIEW_MIN_SCALE, sNeeded);
}
clampViewScale();
const r = balloonScreenBounds(b);
let dx = 0, dy = 0;
if (r.left < pad) dx = (pad - r.left) / view.s;
else if (r.right > cw-pad) dx = ((cw - pad) - r.right) / view.s;
if (r.top < pad) dy = (pad - r.top) / view.s;
else if (r.bottom > ch-pad) dy = ((ch - pad) - r.bottom) / view.s;
view.tx += dx;
view.ty += dy;
return (dx !== 0 || dy !== 0);
}
// ====== Refresh & Events ======
function refreshAll({ refit = false, autoFit = false } = {}) {
if (refit) fitView();
else if (autoFit) fitView();
draw();
renderUsedPalette();
persist();
if(window.updateExportButtonVisibility) window.updateExportButtonVisibility();
}
// --- UI bindings ---
modalCloseBtn?.addEventListener('click', hideModal);
toolDrawBtn?.addEventListener('click', () => setMode('draw'));
toolGarlandBtn?.addEventListener('click', () => setMode('garland'));
toolEraseBtn?.addEventListener('click', () => setMode('erase'));
toolSelectBtn?.addEventListener('click', () => setMode('select'));
eraserSizeInput?.addEventListener('input', e => {
eraserRadius = parseInt(e.target.value, 10);
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
if (mode === 'erase') draw();
persist();
});
toggleBorderCheckbox?.addEventListener('change', e => {
isBorderEnabled = !!e.target.checked;
draw();
persist();
});
garlandDensityInput?.addEventListener('input', e => {
garlandDensity = clamp(parseFloat(e.target.value) || 1, 0.6, 1.6);
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
if (mode === 'garland') requestDraw();
persist();
});
const refreshGarlandColors = () => {
renderGarlandMainChips();
updateAccentChip();
if (mode === 'garland') requestDraw();
persist();
};
refreshGarlandColors();
deleteSelectedBtn?.addEventListener('click', deleteSelected);
duplicateSelectedBtn?.addEventListener('click', duplicateSelected);
nudgeSelectedBtns.forEach(btn => btn.addEventListener('click', () => {
const dx = Number(btn.dataset.dx || 0);
const dy = Number(btn.dataset.dy || 0);
moveSelected(dx, dy);
}));
selectedSizeInput?.addEventListener('input', e => {
resizeSelected(parseFloat(e.target.value) || 0);
});
selectedSizeInput?.addEventListener('pointerdown', () => {
resizeChanged = false;
clearTimeout(resizeSaveTimer);
});
selectedSizeInput?.addEventListener('pointerup', () => {
clearTimeout(resizeSaveTimer);
if (resizeChanged) {
pushHistory();
resizeChanged = false;
}
});
bringForwardBtn?.addEventListener('click', bringSelectedForward);
sendBackwardBtn?.addEventListener('click', sendSelectedBackward);
rotateSelectedLeftBtn?.addEventListener('click', () => rotateSelected(-15));
rotateSelectedResetBtn?.addEventListener('click', () => rotateSelected(0, { absolute: true }));
rotateSelectedRightBtn?.addEventListener('click', () => rotateSelected(15));
ribbonLengthDownBtn?.addEventListener('click', () => scaleSelectedRibbons(0.9));
ribbonLengthUpBtn?.addEventListener('click', () => scaleSelectedRibbons(1.1));
ribbonAttachWeightBtn?.addEventListener('click', startAttachSelectedRibbonsToWeight);
applyColorBtn?.addEventListener('click', applyColorToSelected);
fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true }));
heliumPlaceBalloonBtn?.addEventListener('click', () => {
heliumPlacementType = 'balloon';
resetRibbonDraft();
syncHeliumPlacementUi();
persist();
});
heliumPlaceCurlBtn?.addEventListener('click', () => {
heliumPlacementType = 'curl260';
resetRibbonDraft();
syncHeliumPlacementUi();
persist();
});
heliumPlaceRibbonBtn?.addEventListener('click', () => {
heliumPlacementType = 'ribbon';
resetRibbonDraft();
syncHeliumPlacementUi();
persist();
});
heliumPlaceWeightBtn?.addEventListener('click', () => {
heliumPlacementType = 'weight';
resetRibbonDraft();
syncHeliumPlacementUi();
persist();
});
document.addEventListener('keydown', e => {
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
if (e.key === 'e' || e.key === 'E') setMode('erase');
else if (e.key === 'v' || e.key === 'V') setMode('draw');
else if (e.key === 's' || e.key === 'S') setMode('select');
else if ((e.key === 'g' || e.key === 'G') && getActiveOrganicTab() !== '#tab-helium') setMode('garland');
else if (e.key === 'Escape') {
if (ribbonAttachMode) {
ribbonAttachMode = false;
updateSelectButtons();
requestDraw();
return;
}
if (selectedIds.size) {
clearSelection();
} else if (mode !== 'draw') {
setMode('draw');
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.size) { e.preventDefault(); deleteSelected(); }
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault();
undo();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
e.preventDefault();
redo();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') {
e.preventDefault();
duplicateSelected();
} else if (e.key === '[') {
if (selectedIds.size) { e.preventDefault(); rotateSelected(-15); }
} else if (e.key === ']') {
if (selectedIds.size) { e.preventDefault(); rotateSelected(15); }
}
});
async function confirmAndClear() {
let ok = true;
if (window.Swal) {
const res = await Swal.fire({
title: 'Start fresh?',
text: 'This will remove all balloons from the canvas.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, clear',
cancelButtonText: 'Cancel'
});
ok = res.isConfirmed;
} else {
ok = window.confirm('Start fresh? This will remove all balloons from the canvas.');
}
if (!ok) return;
balloons = [];
selectedIds.clear();
garlandPath = [];
updateSelectButtons();
refreshAll({ refit: true });
pushHistory();
}
clearCanvasBtn?.addEventListener('click', confirmAndClear);
clearCanvasBtnTop?.addEventListener('click', confirmAndClear);
saveJsonBtn?.addEventListener('click', saveJson);
loadJsonInput?.addEventListener('change', loadJson);
generateLinkBtn?.addEventListener('click', generateShareLink);
sortUsedToggle?.addEventListener('click', () => {
usedSortDesc = !usedSortDesc;
sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
renderUsedPalette();
persist();
});
function populateReplaceTo() {
if (!replaceToSel) return;
replaceToSel.innerHTML = '';
(window.PALETTE || []).forEach(group => {
const og = document.createElement('optgroup');
og.label = group.family;
(group.colors || []).forEach(c => {
const idx =
FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx
?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)) ?? 0;
const opt = document.createElement('option');
opt.value = String(idx);
opt.textContent = c.name + (c.image ? ' (image)' : '');
og.appendChild(opt);
});
replaceToSel.appendChild(og);
});
updateReplaceChips();
}
function populateGarlandColorSelects() {
const addOpts = sel => {
if (!sel) return;
sel.innerHTML = '';
const noneOpt = document.createElement('option');
noneOpt.value = '-1';
noneOpt.textContent = 'None (use active color)';
sel.appendChild(noneOpt);
FLAT_COLORS.forEach((c, idx) => {
const opt = document.createElement('option');
opt.value = String(idx);
opt.textContent = c.name || c.hex;
sel.appendChild(opt);
});
};
addOpts(garlandColorMain1Sel);
addOpts(garlandColorMain2Sel);
addOpts(garlandColorMain3Sel);
addOpts(garlandColorMain4Sel);
addOpts(garlandColorAccentSel);
if (garlandColorMain1Sel) garlandColorMain1Sel.value = String(garlandMainIdx[0] ?? -1);
if (garlandColorMain2Sel) garlandColorMain2Sel.value = String(garlandMainIdx[1] ?? -1);
if (garlandColorMain3Sel) garlandColorMain3Sel.value = String(garlandMainIdx[2] ?? -1);
if (garlandColorMain4Sel) garlandColorMain4Sel.value = String(garlandMainIdx[3] ?? -1);
if (garlandColorAccentSel) garlandColorAccentSel.value = String(garlandAccentIdx ?? -1);
updateGarlandSwatches();
}
const updateReplaceChips = () => {
const fromHex = replaceFromSel?.value;
const toIdx = parseInt(replaceToSel?.value || '-1', 10);
const setChip = (chip, hex, meta = null) => {
if (!chip) return;
if (meta?.image) {
chip.style.backgroundImage = `url("${meta.image}")`;
chip.style.backgroundColor = meta.hex || '#fff';
chip.style.backgroundSize = 'cover';
} else {
chip.style.backgroundImage = 'none';
chip.style.backgroundColor = hex || '#f1f5f9';
}
};
const toMeta = Number.isInteger(toIdx) && toIdx >= 0 ? FLAT_COLORS[toIdx] : null;
setChip(replaceFromChip, fromHex || '#f8fafc', null);
setChip(replaceToChip, toMeta?.hex || '#f8fafc', toMeta);
// count matches
const targetHex = normalizeHex(fromHex || '');
let count = 0;
if (targetHex) {
balloons.forEach(b => { if (normalizeHex(b.color) === targetHex) count++; });
}
if (replaceCountLabel) replaceCountLabel.textContent = count ? `${count} match${count === 1 ? '' : 'es'}` : '0 matches';
return count;
};
const openReplacePicker = (mode = 'from') => {
if (!window.openColorPicker) return;
if (mode === 'from') {
const used = getUsedColors();
const items = used.map(u => ({
label: u.name || NAME_BY_HEX.get(u.hex) || u.hex,
metaText: `${u.count} in design`,
hex: u.hex
}));
window.openColorPicker({
title: 'Replace: From color',
subtitle: 'Pick a color that already exists on canvas',
items,
onSelect: (item) => {
if (!replaceFromSel) return;
replaceFromSel.value = item.hex;
updateReplaceChips();
}
});
} else {
const items = (FLAT_COLORS || []).map((c, idx) => ({
label: c.name || c.hex,
metaText: c.family || '',
idx
}));
window.openColorPicker({
title: 'Replace: To color',
subtitle: 'Choose any color from the library',
items,
onSelect: (item) => {
if (!replaceToSel) return;
replaceToSel.value = String(item.idx);
updateReplaceChips();
}
});
}
};
replaceFromChip?.addEventListener('click', () => openReplacePicker('from'));
replaceToChip?.addEventListener('click', () => openReplacePicker('to'));
replaceFromSel?.addEventListener('change', updateReplaceChips);
replaceToSel?.addEventListener('change', updateReplaceChips);
replaceBtn?.addEventListener('click', () => {
const fromHex = replaceFromSel?.value;
const toIdx = parseInt(replaceToSel?.value || '', 10);
if (!fromHex || Number.isNaN(toIdx)) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; }
let count = 0;
balloons.forEach(b => {
if (normalizeHex(b.color) === normalizeHex(fromHex)) {
const toMeta = FLAT_COLORS[toIdx];
b.color = toMeta.hex;
b.image = toMeta.image || null;
b.colorIdx = toMeta._idx;
count++;
}
});
if (count > 0) {
if (count > 80) {
const ok = window.confirm(`Replace ${count} balloons? This cannot be undone except via Undo.`);
if (!ok) return;
}
pushHistory();
if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`;
if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx;
refreshAll();
renderAllowedPalette();
} else {
if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.';
}
updateReplaceChips();
});
// ====== Init ======
sizePresetGroup && (sizePresetGroup.innerHTML = '');
SIZE_PRESETS.forEach(di => {
const btn = document.createElement('button');
btn.className = 'tool-btn';
btn.textContent = `${di}"`;
btn.setAttribute('aria-pressed', String(di === currentDiameterInches));
btn.addEventListener('click', () => {
currentDiameterInches = di;
currentRadius = inchesToRadiusPx(di);
if (getActiveOrganicTab() === '#tab-helium') heliumPlacementType = 'balloon';
[...sizePresetGroup.querySelectorAll('button')].forEach(b => b.setAttribute('aria-pressed', 'false'));
btn.setAttribute('aria-pressed', 'true');
syncHeliumPlacementUi();
persist();
});
sizePresetGroup?.appendChild(btn);
});
toggleShineCheckbox?.addEventListener('change', e => {
const on = !!e.target.checked;
window.syncAppShine(on);
});
mode = 'draw'; // force default tool on load
renderAllowedPalette();
resizeCanvas();
loadFromUrl();
renderUsedPalette();
// Initialize wall designer if available (wall.js sets this)
window.WallDesigner?.init?.();
setMode('draw');
updateSelectButtons();
syncHeliumPlacementUi();
populateReplaceTo();
populateGarlandColorSelects();
// default to canvas-first on mobile; no expansion toggles remain
// Initialize shine state from localStorage for both panels
let initialShineState = true;
try {
const saved = localStorage.getItem('app:shineEnabled:v1');
if (saved !== null) initialShineState = JSON.parse(saved);
} catch {}
// Set Organic panel's internal state and UI
isShineEnabled = initialShineState;
if (toggleShineCheckbox) toggleShineCheckbox.checked = isShineEnabled;
// Set Classic panel's UI checkbox (its script will read this too)
const classicCb = document.getElementById('classic-shine-enabled');
if (classicCb) classicCb.checked = isShineEnabled;
// ===============================
// ===== TAB SWITCHING (UI) ======
// ===============================
const orgSection = document.getElementById('tab-organic');
const claSection = document.getElementById('tab-classic');
const wallSection = document.getElementById('tab-wall');
const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn');
const handleOrganicTabChange = (nextTab) => {
if (nextTab === lastActiveTab) return;
saveAppState(lastActiveTab);
lastActiveTab = nextTab;
loadAppState(lastActiveTab);
resetHistory();
refreshAll({ refit: true });
renderUsedPalette();
updateSelectButtons();
renderGarlandMainChips();
updateAccentChip();
syncHeliumToolUi();
syncHeliumPlacementUi();
};
const observer = new MutationObserver(() => {
const next = getActiveOrganicTab();
handleOrganicTabChange(next);
});
if (document.body) observer.observe(document.body, { attributes: true, attributeFilter: ['data-active-tab'] });
syncHeliumToolUi();
syncHeliumPlacementUi();
// Tab/mobile logic lives in script.js
});
})();