3940 lines
154 KiB
JavaScript
3940 lines
154 KiB
JavaScript
// script.js
|
||
(() => {
|
||
'use strict';
|
||
|
||
// -----------------------------
|
||
// Organic app logic
|
||
// -----------------------------
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Shared values
|
||
const {
|
||
PX_PER_INCH,
|
||
SIZE_PRESETS,
|
||
TEXTURE_ZOOM_DEFAULT,
|
||
TEXTURE_FOCUS_DEFAULT,
|
||
SWATCH_TEXTURE_ZOOM,
|
||
PNG_EXPORT_SCALE,
|
||
clamp,
|
||
clamp01,
|
||
normalizeHex,
|
||
hexToRgb,
|
||
shineStyle,
|
||
luminance,
|
||
FLAT_COLORS,
|
||
NAME_BY_HEX,
|
||
HEX_TO_FIRST_IDX,
|
||
allowedSet,
|
||
getImage: sharedGetImage,
|
||
imageUrlToDataUrl,
|
||
download: sharedDownload,
|
||
XLINK_NS,
|
||
blobToDataUrl,
|
||
imageToDataUrl,
|
||
DATA_URL_CACHE,
|
||
} = window.shared || {};
|
||
if (!window.shared) return;
|
||
const getImageHrefShared = (el) => el.getAttribute('href') || el.getAttributeNS(window.shared.XLINK_NS, 'href');
|
||
const getImage = (path) => sharedGetImage(path, () => draw());
|
||
|
||
// ====== Shine ellipse tuning ======
|
||
const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.20; // ROT is now in degrees
|
||
let view = { s: 1, tx: 0, ty: 0 };
|
||
const FIT_PADDING_PX = 30;
|
||
|
||
const VIEW_MIN_SCALE = 0.12;
|
||
const VIEW_MAX_SCALE = 1.05;
|
||
const MAX_BALLOONS = 800;
|
||
|
||
// ====== Garland path defaults ======
|
||
const GARLAND_POINT_STEP = 8;
|
||
const GARLAND_BASE_DIAM = 18;
|
||
const GARLAND_FILLER_DIAMS = [11, 9];
|
||
const GARLAND_ACCENT_DIAM = 5;
|
||
const GARLAND_SPACING_RATIO = 0.85; // spacing along path vs base diameter
|
||
const GARLAND_WOBBLE_RATIO = 0.35;
|
||
const GARLAND_SIZE_JITTER = 0.14;
|
||
|
||
// Make sure shared palette is populated (fallback in case shared init missed)
|
||
if (Array.isArray(window.PALETTE) && FLAT_COLORS.length === 0) {
|
||
window.PALETTE.forEach(group => {
|
||
(group.colors || []).forEach(c => {
|
||
if (!c?.hex) return;
|
||
const item = { ...c, family: group.family };
|
||
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
|
||
item.imageFocus = {
|
||
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
|
||
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
|
||
};
|
||
item._idx = FLAT_COLORS.length;
|
||
FLAT_COLORS.push(item);
|
||
const key = (c.hex || '').toLowerCase();
|
||
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
|
||
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
|
||
allowedSet.add(key);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Ensure palette exists if shared initialization was skipped
|
||
if (Array.isArray(window.PALETTE) && FLAT_COLORS.length === 0) {
|
||
window.PALETTE.forEach(group => {
|
||
(group.colors || []).forEach(c => {
|
||
if (!c?.hex) return;
|
||
const item = { ...c, family: group.family };
|
||
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
|
||
item.imageFocus = {
|
||
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
|
||
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
|
||
};
|
||
item._idx = FLAT_COLORS.length;
|
||
FLAT_COLORS.push(item);
|
||
const key = (c.hex || '').toLowerCase();
|
||
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
|
||
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
|
||
allowedSet.add(key);
|
||
});
|
||
});
|
||
}
|
||
|
||
const makeSeededRng = seed => {
|
||
let s = seed || 1;
|
||
return () => {
|
||
s ^= s << 13;
|
||
s ^= s >>> 17;
|
||
s ^= s << 5;
|
||
return (s >>> 0) / 4294967296;
|
||
};
|
||
};
|
||
|
||
const QUERY_KEY = 'd';
|
||
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
|
||
});
|
||
})();
|