merge: incorporate v4 version

This commit is contained in:
chris 2025-12-01 09:21:49 -05:00
commit ef7df3a89d
4 changed files with 564 additions and 67 deletions

View File

@ -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?.();

View File

@ -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&quot; 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
View File

@ -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');

View File

@ -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;