chore: snapshot v4 version
This commit is contained in:
parent
0070506d92
commit
6af31f4c81
20
classic.js
20
classic.js
@ -158,6 +158,7 @@
|
||||
let topperOffsetY_Px = 0;
|
||||
let topperSizeMultiplier = 1;
|
||||
let shineEnabled = true;
|
||||
let borderEnabled = false;
|
||||
|
||||
const patterns = {};
|
||||
const api = {
|
||||
@ -171,7 +172,8 @@
|
||||
setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; },
|
||||
setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; },
|
||||
setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; },
|
||||
setShineEnabled(on) { shineEnabled = !!on; }
|
||||
setShineEnabled(on) { shineEnabled = !!on; },
|
||||
setBorderEnabled(on) { borderEnabled = !!on; }
|
||||
};
|
||||
|
||||
const svg = (tag, attrs, children) => m(tag, attrs, children);
|
||||
@ -199,8 +201,10 @@
|
||||
const scale = cellScale(cell);
|
||||
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
|
||||
const commonAttrs = {
|
||||
'vector-effect': 'non-scaling-stroke', stroke: '#111827',
|
||||
'stroke-width': 2, 'paint-order': 'stroke fill', class: 'balloon',
|
||||
'vector-effect': 'non-scaling-stroke',
|
||||
stroke: borderEnabled ? '#111827' : 'none',
|
||||
'stroke-width': borderEnabled ? 0.6 : 0,
|
||||
'paint-order': 'stroke fill', class: 'balloon',
|
||||
fill: explicitFill || '#cccccc'
|
||||
};
|
||||
if (cell.isTopper) {
|
||||
@ -667,7 +671,7 @@ function distinctPaletteSlots(palette) {
|
||||
function initClassic() {
|
||||
try {
|
||||
if (typeof window.m === 'undefined') return fail('Mithril not loaded');
|
||||
const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled');
|
||||
const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), borderEnabledCb = document.getElementById('classic-border-enabled');
|
||||
const patternShapeBtns = Array.from(document.querySelectorAll('[data-pattern-shape]'));
|
||||
const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]'));
|
||||
const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]'));
|
||||
@ -762,6 +766,7 @@ function distinctPaletteSlots(palette) {
|
||||
GC.setTopperOffsetY(topperOffsetY);
|
||||
GC.setTopperSize(topperSizeInp?.value);
|
||||
GC.setShineEnabled(!!shineEnabledCb?.checked);
|
||||
GC.setBorderEnabled(!!borderEnabledCb?.checked);
|
||||
if (document.body) {
|
||||
if (showTopper) document.body.dataset.topperOverlay = '1';
|
||||
else delete document.body.dataset.topperOverlay;
|
||||
@ -814,8 +819,15 @@ function distinctPaletteSlots(palette) {
|
||||
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInp || el === topperEnabledCb) lastPresetKey = 'custom'; updateClassicDesign(); }); });
|
||||
topperEnabledCb?.addEventListener('change', updateClassicDesign);
|
||||
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
|
||||
borderEnabledCb?.addEventListener('change', (e) => {
|
||||
const on = !!e.target.checked;
|
||||
GC.setBorderEnabled(on);
|
||||
try { localStorage.setItem('classic:borderEnabled:v1', JSON.stringify(on)); } catch {}
|
||||
updateClassicDesign();
|
||||
});
|
||||
refreshClassicPaletteUi = initClassicColorPicker(updateClassicDesign);
|
||||
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
|
||||
try { const saved = localStorage.getItem('classic:borderEnabled:v1'); if (saved !== null && borderEnabledCb) borderEnabledCb.checked = JSON.parse(saved); } catch {}
|
||||
setLengthForPattern();
|
||||
updateClassicDesign();
|
||||
refreshClassicPaletteUi?.();
|
||||
|
||||
60
index.html
60
index.html
@ -7,6 +7,7 @@
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<script src="https://unpkg.com/mithril/mithril.js" defer></script>
|
||||
|
||||
@ -33,11 +34,19 @@
|
||||
<div class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<nav id="mode-tabs" class="flex gap-2">
|
||||
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
|
||||
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1 px-2 py-1 rounded-xl bg-white/70 border border-gray-200 shadow-sm" title="Active Color">
|
||||
<div id="current-color-chip-global" class="current-color-chip">
|
||||
<span id="current-color-label-global" class="text-[10px] font-semibold text-slate-700"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clear-canvas-btn-top" class="btn-danger text-xs px-3 py-2">Start Fresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -49,11 +58,15 @@
|
||||
<div class="control-stack" data-mobile-tab="controls">
|
||||
<div class="panel-heading">Tools</div>
|
||||
<div class="panel-card">
|
||||
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||
<div class="grid grid-cols-4 gap-2 mb-3">
|
||||
<button id="tool-draw" class="tool-btn" aria-pressed="true" title="V">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<span class="hidden sm:inline">Draw</span>
|
||||
</button>
|
||||
<button id="tool-garland" class="tool-btn" aria-pressed="false" title="G">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 17c3-4 6-6 9-6s5 2 7 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="14" r="1.4"/><circle cx="11.5" cy="12.5" r="1.4"/><circle cx="16" cy="13.5" r="1.4"/><circle cx="19" cy="16.5" r="1.4"/></svg>
|
||||
<span class="hidden sm:inline">Path</span>
|
||||
</button>
|
||||
<button id="tool-erase" class="tool-btn" aria-pressed="false" title="E">
|
||||
<svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.008 4.008 0 0 1-5.66 0L2.81 17c-.78-.79-.78-2.05 0-2.84l10.6-10.6c.79-.78 2.05-.78 2.83 0zM4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0l8.48-8.48-3.54-3.54-8.48 8.48c-.79.79-.79 2.05 0 2.84z"/></svg>
|
||||
<span class="hidden sm:inline">Erase</span>
|
||||
@ -77,6 +90,41 @@
|
||||
<span class="hidden sm:inline">Picker</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint mt-1">Use Path to click-drag a line; balloons will be auto-placed along it.</p>
|
||||
<div id="garland-controls" class="mt-2 flex flex-col gap-3 text-sm text-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="garland-density" class="font-medium w-20">Density</label>
|
||||
<input id="garland-density" type="range" min="0.6" max="1.6" step="0.1" value="1" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<span id="garland-density-label" class="w-10 text-right text-xs text-gray-500">1.0</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main1" class="font-medium w-24">Main A</label>
|
||||
<select id="garland-color-main1" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main1" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main2" class="font-medium w-24">Main B</label>
|
||||
<select id="garland-color-main2" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main2" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main3" class="font-medium w-24">Main C</label>
|
||||
<select id="garland-color-main3" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main3" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main4" class="font-medium w-24">Main D</label>
|
||||
<select id="garland-color-main4" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main4" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-accent" class="font-medium w-24">5" Accent</label>
|
||||
<select id="garland-color-accent" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-accent" class="swatch tiny"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="eraser-controls" class="hidden flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">Eraser Size: <span id="eraser-size-label">30</span>px</label>
|
||||
<input type="range" id="eraser-size" min="10" max="120" value="30" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
@ -115,6 +163,10 @@
|
||||
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
|
||||
Enable Shine
|
||||
</label>
|
||||
<label class="text-sm inline-flex items-center gap-2 font-medium">
|
||||
<input id="toggle-border-checkbox" type="checkbox" class="align-middle">
|
||||
Outline Balloons
|
||||
</label>
|
||||
<button type="button" id="fit-view-btn" class="btn-dark text-sm mt-3 w-full">Fit to Design</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -264,6 +316,10 @@
|
||||
<input id="classic-shine-enabled" type="checkbox" class="align-middle" checked>
|
||||
Enable Shine
|
||||
</label>
|
||||
<label class="text-sm inline-flex items-center gap-2 font-medium">
|
||||
<input id="classic-border-enabled" type="checkbox" class="align-middle">
|
||||
Outline Balloons
|
||||
</label>
|
||||
<label class="text-sm inline-flex items-center gap-2">
|
||||
<input id="classic-reverse" type="checkbox" class="align-middle">
|
||||
Reverse spiral
|
||||
|
||||
543
script.js
543
script.js
@ -11,7 +11,7 @@
|
||||
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
||||
|
||||
// ====== Shine ellipse tuning ======
|
||||
const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.45; // ROT is now in degrees
|
||||
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;
|
||||
|
||||
@ -20,9 +20,30 @@
|
||||
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
|
||||
const SWATCH_TEXTURE_ZOOM = 2.5;
|
||||
const PNG_EXPORT_SCALE = 3;
|
||||
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;
|
||||
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const clamp01 = v => clamp(v, 0, 1);
|
||||
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';
|
||||
|
||||
@ -78,6 +99,7 @@
|
||||
|
||||
// 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');
|
||||
@ -97,10 +119,24 @@
|
||||
const sendBackwardBtn = document.getElementById('send-backward');
|
||||
const applyColorBtn = document.getElementById('apply-selected-color');
|
||||
const fitViewBtn = document.getElementById('fit-view-btn');
|
||||
const garlandDensityInput = document.getElementById('garland-density');
|
||||
const garlandDensityLabel = document.getElementById('garland-density-label');
|
||||
const garlandColorMain1Sel = document.getElementById('garland-color-main1');
|
||||
const garlandColorMain2Sel = document.getElementById('garland-color-main2');
|
||||
const garlandColorMain3Sel = document.getElementById('garland-color-main3');
|
||||
const garlandColorMain4Sel = document.getElementById('garland-color-main4');
|
||||
const garlandColorAccentSel = document.getElementById('garland-color-accent');
|
||||
const garlandSwatchMain1 = document.getElementById('garland-swatch-main1');
|
||||
const garlandSwatchMain2 = document.getElementById('garland-swatch-main2');
|
||||
const garlandSwatchMain3 = document.getElementById('garland-swatch-main3');
|
||||
const garlandSwatchMain4 = document.getElementById('garland-swatch-main4');
|
||||
const garlandSwatchAccent = document.getElementById('garland-swatch-accent');
|
||||
const garlandControls = document.getElementById('garland-controls');
|
||||
|
||||
const sizePresetGroup = document.getElementById('size-preset-group');
|
||||
const toggleShineBtn = null;
|
||||
const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox');
|
||||
const toggleBorderCheckbox = document.getElementById('toggle-border-checkbox');
|
||||
|
||||
const paletteBox = document.getElementById('color-palette');
|
||||
const usedPaletteBox = document.getElementById('used-palette');
|
||||
@ -129,6 +165,7 @@
|
||||
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');
|
||||
|
||||
// messages
|
||||
const messageModal = document.getElementById('message-modal');
|
||||
@ -149,6 +186,7 @@
|
||||
let currentDiameterInches = 11;
|
||||
let currentRadius = inchesToRadiusPx(currentDiameterInches);
|
||||
let isShineEnabled = true; // will be initialized from localStorage
|
||||
let isBorderEnabled = false;
|
||||
|
||||
let dpr = 1;
|
||||
let mode = 'draw';
|
||||
@ -157,6 +195,10 @@
|
||||
let mousePos = { x: 0, y: 0 };
|
||||
let selectedIds = new Set();
|
||||
let usedSortDesc = true;
|
||||
let garlandPath = [];
|
||||
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
|
||||
let garlandMainIdx = [0, 0, 0, 0];
|
||||
let garlandAccentIdx = 0;
|
||||
|
||||
// History for Undo/Redo
|
||||
const historyStack = [];
|
||||
@ -266,13 +308,26 @@
|
||||
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
|
||||
}
|
||||
function shineStyle(colorHex) {
|
||||
const lum = luminance(colorHex);
|
||||
const hex = normalizeHex(colorHex);
|
||||
const isRetroWhite = hex === '#e8e3d9';
|
||||
const isPureWhite = hex === '#ffffff';
|
||||
const lum = luminance(hex);
|
||||
if (isPureWhite || isRetroWhite) {
|
||||
// subtle gray shine on pure white
|
||||
return { fill: 'rgba(220,220,220,0.22)', stroke: null };
|
||||
}
|
||||
if (lum > 0.7) {
|
||||
const t = clamp01((lum - 0.7) / 0.3);
|
||||
const fillAlpha = 0.22 + (0.10 - 0.22) * t;
|
||||
const fillAlpha = 0.08 + (0.04 - 0.08) * t;
|
||||
return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null };
|
||||
}
|
||||
return { fill: `rgba(255,255,255,${SHINE_ALPHA})`, stroke: null };
|
||||
const base = SHINE_ALPHA;
|
||||
const softened = lum > 0.4 ? base * 0.7 : base;
|
||||
const finalAlpha = isRetroWhite ? softened * 0.6 : softened;
|
||||
return { fill: `rgba(255,255,255,${finalAlpha})`, stroke: null };
|
||||
}
|
||||
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; }
|
||||
@ -288,8 +343,25 @@
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function showModal(msg) { if (!messageModal) return; modalText.textContent = msg; messageModal.classList.remove('hidden'); }
|
||||
function hideModal() { if (!messageModal) return; messageModal.classList.add('hidden'); }
|
||||
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();
|
||||
@ -322,17 +394,23 @@
|
||||
};
|
||||
|
||||
function setMode(next) {
|
||||
if (mode === 'garland' && next !== 'garland') {
|
||||
garlandPath = [];
|
||||
}
|
||||
mode = next;
|
||||
toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw'));
|
||||
toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland'));
|
||||
toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase'));
|
||||
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
|
||||
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
|
||||
|
||||
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';
|
||||
|
||||
@ -427,6 +505,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'garland') {
|
||||
pointerDown = true;
|
||||
garlandPath = [{ ...mousePos }];
|
||||
requestDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'select') {
|
||||
pointerDown = true;
|
||||
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
||||
@ -486,6 +571,17 @@
|
||||
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'garland') {
|
||||
if (pointerDown) {
|
||||
const last = garlandPath[garlandPath.length - 1];
|
||||
if (!last || Math.hypot(mousePos.x - last.x, mousePos.y - last.y) >= GARLAND_POINT_STEP) {
|
||||
garlandPath.push({ ...mousePos });
|
||||
requestDraw();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'erase') {
|
||||
if (pointerDown) {
|
||||
@ -505,6 +601,13 @@
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
pointerDown = false;
|
||||
isDragging = false;
|
||||
if (mode === 'garland') {
|
||||
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
|
||||
garlandPath = [];
|
||||
requestDraw();
|
||||
canvas.releasePointerCapture?.(e.pointerId);
|
||||
return;
|
||||
}
|
||||
if (mode === 'select' && dragMoved) {
|
||||
refreshAll();
|
||||
pushHistory();
|
||||
@ -535,6 +638,11 @@
|
||||
canvas.addEventListener('pointerleave', () => {
|
||||
mouseInside = false;
|
||||
marqueeActive = false;
|
||||
if (mode === 'garland') {
|
||||
pointerDown = false;
|
||||
garlandPath = [];
|
||||
requestDraw();
|
||||
}
|
||||
if (mode === 'erase') requestDraw();
|
||||
}, { passive: true });
|
||||
|
||||
@ -570,34 +678,56 @@
|
||||
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);
|
||||
const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
|
||||
ctx.restore();
|
||||
} else {
|
||||
// fallback solid
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = b.color;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
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 {
|
||||
// solid fill
|
||||
// fallback solid
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = b.color;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fill();
|
||||
if (isBorderEnabled) {
|
||||
ctx.strokeStyle = '#111827';
|
||||
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
} else {
|
||||
// solid fill
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = b.color;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fill();
|
||||
if (isBorderEnabled) {
|
||||
ctx.strokeStyle = '#111827';
|
||||
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
if (isShineEnabled) {
|
||||
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
||||
@ -619,16 +749,39 @@
|
||||
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();
|
||||
}
|
||||
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();
|
||||
@ -700,7 +853,18 @@
|
||||
|
||||
function saveAppState() {
|
||||
// Note: isShineEnabled is managed globally.
|
||||
const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, view, usedSortDesc };
|
||||
const state = {
|
||||
balloons,
|
||||
selectedColorIdx,
|
||||
currentDiameterInches,
|
||||
eraserRadius,
|
||||
view,
|
||||
usedSortDesc,
|
||||
garlandDensity,
|
||||
garlandMainIdx,
|
||||
garlandAccentIdx,
|
||||
isBorderEnabled
|
||||
};
|
||||
try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {}
|
||||
}
|
||||
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
|
||||
@ -719,11 +883,23 @@
|
||||
if (eraserSizeInput) eraserSizeInput.value = eraserRadius;
|
||||
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
|
||||
}
|
||||
if (s.view && typeof s.view.s === 'number') view = s.view;
|
||||
if (s.view && typeof s.view.s === 'number') { view = s.view; clampViewScale(); }
|
||||
if (typeof s.usedSortDesc === 'boolean') {
|
||||
usedSortDesc = s.usedSortDesc;
|
||||
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
|
||||
}
|
||||
if (typeof s.garlandDensity === 'number') {
|
||||
garlandDensity = clamp(s.garlandDensity, 0.6, 1.6);
|
||||
if (garlandDensityInput) garlandDensityInput.value = garlandDensity;
|
||||
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
|
||||
}
|
||||
if (Array.isArray(s.garlandMainIdx)) {
|
||||
garlandMainIdx = s.garlandMainIdx.slice(0, 4).map(v => Number(v) || -1);
|
||||
while (garlandMainIdx.length < 4) garlandMainIdx.push(-1);
|
||||
}
|
||||
if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx;
|
||||
if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled;
|
||||
if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled;
|
||||
updateCurrentColorChip();
|
||||
} catch {}
|
||||
}
|
||||
@ -794,7 +970,7 @@
|
||||
|
||||
function updateCurrentColorChip() {
|
||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||
const updateChip = (chipId, labelId) => {
|
||||
const updateChip = (chipId, labelId, { showLabel = true } = {}) => {
|
||||
const chip = document.getElementById(chipId);
|
||||
const label = document.getElementById(labelId);
|
||||
if (!chip || !meta) return;
|
||||
@ -809,9 +985,14 @@
|
||||
chip.style.backgroundImage = 'none';
|
||||
chip.style.backgroundColor = meta.hex || '#fff';
|
||||
}
|
||||
if (label) label.textContent = meta.name || meta.hex || 'Current';
|
||||
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');
|
||||
updateChip('current-color-chip', 'current-color-label', { showLabel: true });
|
||||
updateChip('current-color-chip-global', 'current-color-label-global', { showLabel: false });
|
||||
}
|
||||
|
||||
function renderUsedPalette() {
|
||||
@ -870,18 +1051,150 @@
|
||||
}
|
||||
|
||||
// ====== Balloon Ops & Data/Export ======
|
||||
function addBalloon(x, y) {
|
||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||
balloons.push({
|
||||
function buildBalloon(meta, x, y, radius) {
|
||||
return {
|
||||
x, y,
|
||||
radius: currentRadius,
|
||||
radius,
|
||||
color: meta.hex,
|
||||
image: meta.image || null,
|
||||
colorIdx: meta._idx,
|
||||
id: crypto.randomUUID()
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function addBalloon(x, y) {
|
||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||
if (balloons.length >= MAX_BALLOONS) { showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; }
|
||||
balloons.push(buildBalloon(meta, x, y, currentRadius));
|
||||
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
|
||||
refreshAll({ autoFit: true });
|
||||
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'
|
||||
});
|
||||
|
||||
// Accent cluster of three 5" balloons to add texture
|
||||
if (rng() < Math.min(0.8, 0.35 * garlandDensity + 0.1)) {
|
||||
const clusterCenterX = baseX + nx * r * 0.4 * side;
|
||||
const clusterCenterY = baseY + ny * r * 0.4 * side;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const ang = rng() * Math.PI * 2;
|
||||
const mag = accentRadius * (0.8 + rng() * 0.5);
|
||||
nodes.push({
|
||||
x: clusterCenterX + Math.cos(ang) * mag,
|
||||
y: clusterCenterY + Math.sin(ang) * mag,
|
||||
radius: accentRadius * (0.85 + rng() * 0.25),
|
||||
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) return;
|
||||
const available = Math.max(0, MAX_BALLOONS - balloons.length);
|
||||
const limitedNodes = available ? nodes.slice(0, available) : [];
|
||||
if (!limitedNodes.length) { 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);
|
||||
});
|
||||
if (newIds.length) {
|
||||
selectedIds.clear();
|
||||
updateSelectButtons();
|
||||
}
|
||||
refreshAll();
|
||||
pushHistory();
|
||||
}
|
||||
|
||||
@ -1048,7 +1361,7 @@
|
||||
reader.onload = ev => {
|
||||
try {
|
||||
const data = JSON.parse(ev.target.result);
|
||||
balloons = Array.isArray(data.balloons)
|
||||
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] || {};
|
||||
@ -1061,11 +1374,15 @@
|
||||
};
|
||||
})
|
||||
: [];
|
||||
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.');
|
||||
}
|
||||
@ -1201,7 +1518,8 @@
|
||||
}
|
||||
fill = `url(#${patterns.get(patternKey)})`;
|
||||
}
|
||||
elements += `<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}" stroke="#111827" stroke-width="2" />`;
|
||||
const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
|
||||
elements += `<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}"${strokeAttr} />`;
|
||||
|
||||
if (isShineEnabled) {
|
||||
const sx = b.x - b.radius * SHINE_OFFSET;
|
||||
@ -1209,7 +1527,7 @@
|
||||
const rx = b.radius * SHINE_RX;
|
||||
const ry = b.radius * SHINE_RY;
|
||||
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
||||
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1.5"` : '';
|
||||
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : '';
|
||||
elements += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
|
||||
}
|
||||
});
|
||||
@ -1269,8 +1587,8 @@
|
||||
|
||||
// Some viewers ignore external styles; bake key style attributes directly
|
||||
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
|
||||
if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827');
|
||||
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '2');
|
||||
if (!el.getAttribute('stroke')) el.setAttribute('stroke', isBorderEnabled ? '#111827' : 'none');
|
||||
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', isBorderEnabled ? '1' : '0');
|
||||
if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill');
|
||||
if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke');
|
||||
});
|
||||
@ -1408,18 +1726,23 @@
|
||||
try {
|
||||
let jsonStr = LZString.decompressFromEncodedURIComponent(encoded) || atob(encoded);
|
||||
const data = JSON.parse(jsonStr);
|
||||
balloons = Array.isArray(data.balloons)
|
||||
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: crypto.randomUUID() };
|
||||
})
|
||||
: compactToDesign(data);
|
||||
balloons = loaded.slice(0, MAX_BALLOONS);
|
||||
refreshAll({ refit: true });
|
||||
resetHistory();
|
||||
persist();
|
||||
updateCurrentColorChip();
|
||||
showModal('Design loaded from link!');
|
||||
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.');
|
||||
}
|
||||
@ -1442,14 +1765,15 @@
|
||||
const box = balloonsBounds();
|
||||
const cw = canvas.width / dpr; // CSS px
|
||||
const ch = canvas.height / dpr;
|
||||
if (balloons.length === 0) { view.s = 1; view.tx = 0; view.ty = 0; return; }
|
||||
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(1, isFinite(sFit) && sFit > 0 ? sFit : 1);
|
||||
view.s = Math.min(VIEW_MAX_SCALE, isFinite(sFit) && sFit > 0 ? sFit : 1);
|
||||
clampViewScale();
|
||||
|
||||
const worldW = cw / view.s;
|
||||
const worldH = ch / view.s;
|
||||
@ -1475,8 +1799,9 @@
|
||||
const needSy = (ch - 2*pad) / (2*b.radius);
|
||||
const sNeeded = Math.min(needSx, needSy);
|
||||
if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) {
|
||||
view.s = Math.max(0.05, sNeeded);
|
||||
view.s = Math.max(VIEW_MIN_SCALE, sNeeded);
|
||||
}
|
||||
clampViewScale();
|
||||
|
||||
const r = balloonScreenBounds(b);
|
||||
let dx = 0, dy = 0;
|
||||
@ -1505,6 +1830,7 @@
|
||||
modalCloseBtn?.addEventListener('click', hideModal);
|
||||
|
||||
toolDrawBtn?.addEventListener('click', () => setMode('draw'));
|
||||
toolGarlandBtn?.addEventListener('click', () => setMode('garland'));
|
||||
toolEraseBtn?.addEventListener('click', () => setMode('erase'));
|
||||
toolSelectBtn?.addEventListener('click', () => setMode('select'));
|
||||
|
||||
@ -1514,6 +1840,42 @@
|
||||
if (mode === 'erase') draw();
|
||||
persist();
|
||||
});
|
||||
toggleBorderCheckbox?.addEventListener('change', e => {
|
||||
isBorderEnabled = !!e.target.checked;
|
||||
draw();
|
||||
persist();
|
||||
});
|
||||
garlandDensityInput?.addEventListener('input', e => {
|
||||
garlandDensity = clamp(parseFloat(e.target.value) || 1, 0.6, 1.6);
|
||||
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
|
||||
if (mode === 'garland') requestDraw();
|
||||
persist();
|
||||
});
|
||||
const handleGarlandColorChange = () => {
|
||||
updateGarlandSwatches();
|
||||
persist();
|
||||
if (mode === 'garland') requestDraw();
|
||||
};
|
||||
garlandColorMain1Sel?.addEventListener('change', e => {
|
||||
garlandMainIdx[0] = parseInt(e.target.value, 10) || -1;
|
||||
handleGarlandColorChange();
|
||||
});
|
||||
garlandColorMain2Sel?.addEventListener('change', e => {
|
||||
garlandMainIdx[1] = parseInt(e.target.value, 10) || -1;
|
||||
handleGarlandColorChange();
|
||||
});
|
||||
garlandColorMain3Sel?.addEventListener('change', e => {
|
||||
garlandMainIdx[2] = parseInt(e.target.value, 10) || -1;
|
||||
handleGarlandColorChange();
|
||||
});
|
||||
garlandColorMain4Sel?.addEventListener('change', e => {
|
||||
garlandMainIdx[3] = parseInt(e.target.value, 10) || -1;
|
||||
handleGarlandColorChange();
|
||||
});
|
||||
garlandColorAccentSel?.addEventListener('change', e => {
|
||||
garlandAccentIdx = parseInt(e.target.value, 10) || -1;
|
||||
handleGarlandColorChange();
|
||||
});
|
||||
|
||||
deleteSelectedBtn?.addEventListener('click', deleteSelected);
|
||||
duplicateSelectedBtn?.addEventListener('click', duplicateSelected);
|
||||
@ -1548,6 +1910,7 @@
|
||||
if (e.key === 'e' || e.key === 'E') setMode('erase');
|
||||
else if (e.key === 'v' || e.key === 'V') setMode('draw');
|
||||
else if (e.key === 's' || e.key === 'S') setMode('select');
|
||||
else if (e.key === 'g' || e.key === 'G') setMode('garland');
|
||||
else if (e.key === 'Escape') {
|
||||
if (selectedIds.size) {
|
||||
clearSelection();
|
||||
@ -1568,13 +1931,32 @@
|
||||
}
|
||||
});
|
||||
|
||||
clearCanvasBtn?.addEventListener('click', () => {
|
||||
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);
|
||||
@ -1607,6 +1989,55 @@
|
||||
});
|
||||
}
|
||||
|
||||
function populateGarlandColorSelects() {
|
||||
const addOpts = sel => {
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
const noneOpt = document.createElement('option');
|
||||
noneOpt.value = '-1';
|
||||
noneOpt.textContent = 'None (use active color)';
|
||||
sel.appendChild(noneOpt);
|
||||
FLAT_COLORS.forEach((c, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(idx);
|
||||
opt.textContent = c.name || c.hex;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
};
|
||||
addOpts(garlandColorMain1Sel);
|
||||
addOpts(garlandColorMain2Sel);
|
||||
addOpts(garlandColorMain3Sel);
|
||||
addOpts(garlandColorMain4Sel);
|
||||
addOpts(garlandColorAccentSel);
|
||||
if (garlandColorMain1Sel) garlandColorMain1Sel.value = String(garlandMainIdx[0] ?? -1);
|
||||
if (garlandColorMain2Sel) garlandColorMain2Sel.value = String(garlandMainIdx[1] ?? -1);
|
||||
if (garlandColorMain3Sel) garlandColorMain3Sel.value = String(garlandMainIdx[2] ?? -1);
|
||||
if (garlandColorMain4Sel) garlandColorMain4Sel.value = String(garlandMainIdx[3] ?? -1);
|
||||
if (garlandColorAccentSel) garlandColorAccentSel.value = String(garlandAccentIdx ?? -1);
|
||||
|
||||
updateGarlandSwatches();
|
||||
}
|
||||
|
||||
function updateGarlandSwatches() {
|
||||
const setSw = (sw, idx) => {
|
||||
if (!sw) return;
|
||||
const meta = idx >= 0 ? FLAT_COLORS[idx] : null;
|
||||
if (meta?.image) {
|
||||
sw.style.backgroundImage = `url("${meta.image}")`;
|
||||
sw.style.backgroundColor = meta.hex || '#fff';
|
||||
sw.style.backgroundSize = 'cover';
|
||||
} else {
|
||||
sw.style.backgroundImage = 'none';
|
||||
sw.style.backgroundColor = meta?.hex || '#f1f5f9';
|
||||
}
|
||||
};
|
||||
setSw(garlandSwatchMain1, garlandMainIdx[0]);
|
||||
setSw(garlandSwatchMain2, garlandMainIdx[1]);
|
||||
setSw(garlandSwatchMain3, garlandMainIdx[2]);
|
||||
setSw(garlandSwatchMain4, garlandMainIdx[3]);
|
||||
setSw(garlandSwatchAccent, garlandAccentIdx);
|
||||
}
|
||||
|
||||
replaceBtn?.addEventListener('click', () => {
|
||||
const fromHex = replaceFromSel?.value;
|
||||
const toIdx = parseInt(replaceToSel?.value || '', 10);
|
||||
@ -1664,6 +2095,7 @@
|
||||
setMode('draw');
|
||||
updateSelectButtons();
|
||||
populateReplaceTo();
|
||||
populateGarlandColorSelects();
|
||||
|
||||
// default to canvas-first on mobile; no expansion toggles remain
|
||||
|
||||
@ -1728,10 +2160,9 @@
|
||||
function updateFloatingNudge() {
|
||||
const el = document.getElementById('floating-topper-nudge');
|
||||
if (!el) return;
|
||||
const isMobile = !window.matchMedia('(min-width: 1024px)').matches;
|
||||
const classicActive = document.body?.dataset.activeTab === '#tab-classic';
|
||||
const topperActive = document.body?.dataset.topperOverlay === '1';
|
||||
const shouldShow = isMobile && classicActive && topperActive;
|
||||
const shouldShow = classicActive && topperActive;
|
||||
el.classList.toggle('hidden', !shouldShow);
|
||||
el.classList.toggle('collapsed', floatingNudgeCollapsed);
|
||||
const toggle = document.getElementById('floating-nudge-toggle');
|
||||
|
||||
@ -90,10 +90,11 @@ body { color: #1f2937; }
|
||||
.swatch:hover { transform: scale(1.1); z-index: 10; }
|
||||
.swatch:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; }
|
||||
.swatch.active { outline: 2px solid #6366f1; outline-offset: 2px; }
|
||||
.swatch.tiny { width: 1.4rem; height: 1.4rem; border-width: 1px; box-shadow: none; }
|
||||
|
||||
.current-color-chip {
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(51,65,85,0.15);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
@ -222,9 +223,6 @@ body { color: #1f2937; }
|
||||
.floating-nudge-body.collapsed { display: none; }
|
||||
.floating-nudge.collapsed .floating-nudge-body { display: none; }
|
||||
.floating-nudge.collapsed #floating-nudge-toggle { opacity: 0.8; }
|
||||
@media (min-width: 1024px) {
|
||||
.floating-nudge { display: none !important; }
|
||||
}
|
||||
|
||||
.slot-label {
|
||||
font-weight: 600;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user