merge: incorporate v4 version
This commit is contained in:
commit
ef7df3a89d
20
classic.js
20
classic.js
@ -158,6 +158,7 @@
|
|||||||
let topperOffsetY_Px = 0;
|
let topperOffsetY_Px = 0;
|
||||||
let topperSizeMultiplier = 1;
|
let topperSizeMultiplier = 1;
|
||||||
let shineEnabled = true;
|
let shineEnabled = true;
|
||||||
|
let borderEnabled = false;
|
||||||
|
|
||||||
const patterns = {};
|
const patterns = {};
|
||||||
const api = {
|
const api = {
|
||||||
@ -171,7 +172,8 @@
|
|||||||
setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; },
|
setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; },
|
||||||
setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; },
|
setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; },
|
||||||
setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; },
|
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);
|
const svg = (tag, attrs, children) => m(tag, attrs, children);
|
||||||
@ -199,8 +201,10 @@
|
|||||||
const scale = cellScale(cell);
|
const scale = cellScale(cell);
|
||||||
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
|
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
|
||||||
const commonAttrs = {
|
const commonAttrs = {
|
||||||
'vector-effect': 'non-scaling-stroke', stroke: '#111827',
|
'vector-effect': 'non-scaling-stroke',
|
||||||
'stroke-width': 2, 'paint-order': 'stroke fill', class: 'balloon',
|
stroke: borderEnabled ? '#111827' : 'none',
|
||||||
|
'stroke-width': borderEnabled ? 0.6 : 0,
|
||||||
|
'paint-order': 'stroke fill', class: 'balloon',
|
||||||
fill: explicitFill || '#cccccc'
|
fill: explicitFill || '#cccccc'
|
||||||
};
|
};
|
||||||
if (cell.isTopper) {
|
if (cell.isTopper) {
|
||||||
@ -667,7 +671,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
function initClassic() {
|
function initClassic() {
|
||||||
try {
|
try {
|
||||||
if (typeof window.m === 'undefined') return fail('Mithril not loaded');
|
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 patternShapeBtns = Array.from(document.querySelectorAll('[data-pattern-shape]'));
|
||||||
const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]'));
|
const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]'));
|
||||||
const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]'));
|
const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]'));
|
||||||
@ -762,6 +766,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
GC.setTopperOffsetY(topperOffsetY);
|
GC.setTopperOffsetY(topperOffsetY);
|
||||||
GC.setTopperSize(topperSizeInp?.value);
|
GC.setTopperSize(topperSizeInp?.value);
|
||||||
GC.setShineEnabled(!!shineEnabledCb?.checked);
|
GC.setShineEnabled(!!shineEnabledCb?.checked);
|
||||||
|
GC.setBorderEnabled(!!borderEnabledCb?.checked);
|
||||||
if (document.body) {
|
if (document.body) {
|
||||||
if (showTopper) document.body.dataset.topperOverlay = '1';
|
if (showTopper) document.body.dataset.topperOverlay = '1';
|
||||||
else delete document.body.dataset.topperOverlay;
|
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(); }); });
|
.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);
|
topperEnabledCb?.addEventListener('change', updateClassicDesign);
|
||||||
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
|
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);
|
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('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();
|
setLengthForPattern();
|
||||||
updateClassicDesign();
|
updateClassicDesign();
|
||||||
refreshClassicPaletteUi?.();
|
refreshClassicPaletteUi?.();
|
||||||
|
|||||||
58
index.html
58
index.html
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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/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>
|
<script src="https://unpkg.com/mithril/mithril.js" defer></script>
|
||||||
|
|
||||||
@ -38,6 +39,14 @@
|
|||||||
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
|
<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>
|
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -49,11 +58,15 @@
|
|||||||
<div class="control-stack" data-mobile-tab="controls">
|
<div class="control-stack" data-mobile-tab="controls">
|
||||||
<div class="panel-heading">Tools</div>
|
<div class="panel-heading">Tools</div>
|
||||||
<div class="panel-card">
|
<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">
|
<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>
|
<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>
|
<span class="hidden sm:inline">Draw</span>
|
||||||
</button>
|
</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">
|
<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>
|
<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>
|
<span class="hidden sm:inline">Erase</span>
|
||||||
@ -76,6 +89,41 @@
|
|||||||
<svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
|
||||||
<span class="hidden sm:inline">Picker</span>
|
<span class="hidden sm:inline">Picker</span>
|
||||||
</button>
|
</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>
|
||||||
<div id="eraser-controls" class="hidden flex flex-col gap-2">
|
<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>
|
<label class="text-sm font-medium text-gray-700">Eraser Size: <span id="eraser-size-label">30</span>px</label>
|
||||||
@ -115,6 +163,10 @@
|
|||||||
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
|
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
|
||||||
Enable Shine
|
Enable Shine
|
||||||
</label>
|
</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>
|
<button type="button" id="fit-view-btn" class="btn-dark text-sm mt-3 w-full">Fit to Design</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -264,6 +316,10 @@
|
|||||||
<input id="classic-shine-enabled" type="checkbox" class="align-middle" checked>
|
<input id="classic-shine-enabled" type="checkbox" class="align-middle" checked>
|
||||||
Enable Shine
|
Enable Shine
|
||||||
</label>
|
</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">
|
<label class="text-sm inline-flex items-center gap-2">
|
||||||
<input id="classic-reverse" type="checkbox" class="align-middle">
|
<input id="classic-reverse" type="checkbox" class="align-middle">
|
||||||
Reverse spiral
|
Reverse spiral
|
||||||
|
|||||||
491
script.js
491
script.js
@ -11,7 +11,7 @@
|
|||||||
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
||||||
|
|
||||||
// ====== Shine ellipse tuning ======
|
// ====== 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 };
|
let view = { s: 1, tx: 0, ty: 0 };
|
||||||
const FIT_PADDING_PX = 30;
|
const FIT_PADDING_PX = 30;
|
||||||
|
|
||||||
@ -20,9 +20,30 @@
|
|||||||
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
|
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
|
||||||
const SWATCH_TEXTURE_ZOOM = 2.5;
|
const SWATCH_TEXTURE_ZOOM = 2.5;
|
||||||
const PNG_EXPORT_SCALE = 3;
|
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 clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||||
const clamp01 = v => clamp(v, 0, 1);
|
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';
|
const QUERY_KEY = 'd';
|
||||||
|
|
||||||
@ -78,6 +99,7 @@
|
|||||||
|
|
||||||
// tool buttons
|
// tool buttons
|
||||||
const toolDrawBtn = document.getElementById('tool-draw');
|
const toolDrawBtn = document.getElementById('tool-draw');
|
||||||
|
const toolGarlandBtn = document.getElementById('tool-garland');
|
||||||
const toolEraseBtn = document.getElementById('tool-erase');
|
const toolEraseBtn = document.getElementById('tool-erase');
|
||||||
const toolSelectBtn = document.getElementById('tool-select');
|
const toolSelectBtn = document.getElementById('tool-select');
|
||||||
const toolUndoBtn = document.getElementById('tool-undo');
|
const toolUndoBtn = document.getElementById('tool-undo');
|
||||||
@ -97,10 +119,24 @@
|
|||||||
const sendBackwardBtn = document.getElementById('send-backward');
|
const sendBackwardBtn = document.getElementById('send-backward');
|
||||||
const applyColorBtn = document.getElementById('apply-selected-color');
|
const applyColorBtn = document.getElementById('apply-selected-color');
|
||||||
const fitViewBtn = document.getElementById('fit-view-btn');
|
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 sizePresetGroup = document.getElementById('size-preset-group');
|
||||||
const toggleShineBtn = null;
|
const toggleShineBtn = null;
|
||||||
const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox');
|
const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox');
|
||||||
|
const toggleBorderCheckbox = document.getElementById('toggle-border-checkbox');
|
||||||
|
|
||||||
const paletteBox = document.getElementById('color-palette');
|
const paletteBox = document.getElementById('color-palette');
|
||||||
const usedPaletteBox = document.getElementById('used-palette');
|
const usedPaletteBox = document.getElementById('used-palette');
|
||||||
@ -129,6 +165,7 @@
|
|||||||
const generateLinkBtn = document.getElementById('generate-link-btn');
|
const generateLinkBtn = document.getElementById('generate-link-btn');
|
||||||
const shareLinkOutput = document.getElementById('share-link-output');
|
const shareLinkOutput = document.getElementById('share-link-output');
|
||||||
const copyMessage = document.getElementById('copy-message');
|
const copyMessage = document.getElementById('copy-message');
|
||||||
|
const clearCanvasBtnTop = document.getElementById('clear-canvas-btn-top');
|
||||||
|
|
||||||
// messages
|
// messages
|
||||||
const messageModal = document.getElementById('message-modal');
|
const messageModal = document.getElementById('message-modal');
|
||||||
@ -149,6 +186,7 @@
|
|||||||
let currentDiameterInches = 11;
|
let currentDiameterInches = 11;
|
||||||
let currentRadius = inchesToRadiusPx(currentDiameterInches);
|
let currentRadius = inchesToRadiusPx(currentDiameterInches);
|
||||||
let isShineEnabled = true; // will be initialized from localStorage
|
let isShineEnabled = true; // will be initialized from localStorage
|
||||||
|
let isBorderEnabled = false;
|
||||||
|
|
||||||
let dpr = 1;
|
let dpr = 1;
|
||||||
let mode = 'draw';
|
let mode = 'draw';
|
||||||
@ -157,6 +195,10 @@
|
|||||||
let mousePos = { x: 0, y: 0 };
|
let mousePos = { x: 0, y: 0 };
|
||||||
let selectedIds = new Set();
|
let selectedIds = new Set();
|
||||||
let usedSortDesc = true;
|
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
|
// History for Undo/Redo
|
||||||
const historyStack = [];
|
const historyStack = [];
|
||||||
@ -266,13 +308,26 @@
|
|||||||
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
|
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
|
||||||
}
|
}
|
||||||
function shineStyle(colorHex) {
|
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) {
|
if (lum > 0.7) {
|
||||||
const t = clamp01((lum - 0.7) / 0.3);
|
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(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 inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; }
|
||||||
function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; }
|
function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; }
|
||||||
@ -288,8 +343,25 @@
|
|||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
function showModal(msg) { if (!messageModal) return; modalText.textContent = msg; messageModal.classList.remove('hidden'); }
|
function showModal(msg, opts = {}) {
|
||||||
function hideModal() { if (!messageModal) return; messageModal.classList.add('hidden'); }
|
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 showCopyMessage() { if (!copyMessage) return; copyMessage.classList.add('show'); setTimeout(() => copyMessage.classList.remove('show'), 2000); }
|
||||||
function getMousePos(e) {
|
function getMousePos(e) {
|
||||||
const r = canvas.getBoundingClientRect();
|
const r = canvas.getBoundingClientRect();
|
||||||
@ -322,17 +394,23 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setMode(next) {
|
function setMode(next) {
|
||||||
|
if (mode === 'garland' && next !== 'garland') {
|
||||||
|
garlandPath = [];
|
||||||
|
}
|
||||||
mode = next;
|
mode = next;
|
||||||
toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw'));
|
toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw'));
|
||||||
|
toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland'));
|
||||||
toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase'));
|
toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase'));
|
||||||
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
|
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
|
||||||
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
|
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
|
||||||
|
|
||||||
eraserControls?.classList.toggle('hidden', mode !== 'erase');
|
eraserControls?.classList.toggle('hidden', mode !== 'erase');
|
||||||
selectControls?.classList.toggle('hidden', mode !== 'select');
|
selectControls?.classList.toggle('hidden', mode !== 'select');
|
||||||
|
garlandControls?.classList.toggle('hidden', mode !== 'garland');
|
||||||
|
|
||||||
if (mode === 'erase') canvas.style.cursor = 'none';
|
if (mode === 'erase') canvas.style.cursor = 'none';
|
||||||
else if (mode === 'select') canvas.style.cursor = 'default'; // will be move over items
|
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 if (mode === 'eyedropper') canvas.style.cursor = 'cell';
|
||||||
else canvas.style.cursor = 'crosshair';
|
else canvas.style.cursor = 'crosshair';
|
||||||
|
|
||||||
@ -427,6 +505,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'garland') {
|
||||||
|
pointerDown = true;
|
||||||
|
garlandPath = [{ ...mousePos }];
|
||||||
|
requestDraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'select') {
|
if (mode === 'select') {
|
||||||
pointerDown = true;
|
pointerDown = true;
|
||||||
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
||||||
@ -487,6 +572,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (mode === 'erase') {
|
||||||
if (pointerDown) {
|
if (pointerDown) {
|
||||||
eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged;
|
eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged;
|
||||||
@ -505,6 +601,13 @@
|
|||||||
canvas.addEventListener('pointerup', e => {
|
canvas.addEventListener('pointerup', e => {
|
||||||
pointerDown = false;
|
pointerDown = false;
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
if (mode === 'garland') {
|
||||||
|
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
|
||||||
|
garlandPath = [];
|
||||||
|
requestDraw();
|
||||||
|
canvas.releasePointerCapture?.(e.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mode === 'select' && dragMoved) {
|
if (mode === 'select' && dragMoved) {
|
||||||
refreshAll();
|
refreshAll();
|
||||||
pushHistory();
|
pushHistory();
|
||||||
@ -535,6 +638,11 @@
|
|||||||
canvas.addEventListener('pointerleave', () => {
|
canvas.addEventListener('pointerleave', () => {
|
||||||
mouseInside = false;
|
mouseInside = false;
|
||||||
marqueeActive = false;
|
marqueeActive = false;
|
||||||
|
if (mode === 'garland') {
|
||||||
|
pointerDown = false;
|
||||||
|
garlandPath = [];
|
||||||
|
requestDraw();
|
||||||
|
}
|
||||||
if (mode === 'erase') requestDraw();
|
if (mode === 'erase') requestDraw();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
@ -576,7 +684,19 @@
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
||||||
ctx.clip();
|
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);
|
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();
|
ctx.restore();
|
||||||
} else {
|
} else {
|
||||||
// fallback solid
|
// fallback solid
|
||||||
@ -586,6 +706,11 @@
|
|||||||
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
||||||
ctx.shadowBlur = 10;
|
ctx.shadowBlur = 10;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
if (isBorderEnabled) {
|
||||||
|
ctx.strokeStyle = '#111827';
|
||||||
|
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -596,6 +721,11 @@
|
|||||||
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
||||||
ctx.shadowBlur = 10;
|
ctx.shadowBlur = 10;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
if (isBorderEnabled) {
|
||||||
|
ctx.strokeStyle = '#111827';
|
||||||
|
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -629,6 +759,29 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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)
|
// selection ring(s)
|
||||||
if (selectedIds.size) {
|
if (selectedIds.size) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@ -700,7 +853,18 @@
|
|||||||
|
|
||||||
function saveAppState() {
|
function saveAppState() {
|
||||||
// Note: isShineEnabled is managed globally.
|
// 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 {}
|
try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {}
|
||||||
}
|
}
|
||||||
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
|
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
|
||||||
@ -719,11 +883,23 @@
|
|||||||
if (eraserSizeInput) eraserSizeInput.value = eraserRadius;
|
if (eraserSizeInput) eraserSizeInput.value = eraserRadius;
|
||||||
if (eraserSizeLabel) eraserSizeLabel.textContent = 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') {
|
if (typeof s.usedSortDesc === 'boolean') {
|
||||||
usedSortDesc = s.usedSortDesc;
|
usedSortDesc = s.usedSortDesc;
|
||||||
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
|
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();
|
updateCurrentColorChip();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@ -794,7 +970,7 @@
|
|||||||
|
|
||||||
function updateCurrentColorChip() {
|
function updateCurrentColorChip() {
|
||||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||||
const updateChip = (chipId, labelId) => {
|
const updateChip = (chipId, labelId, { showLabel = true } = {}) => {
|
||||||
const chip = document.getElementById(chipId);
|
const chip = document.getElementById(chipId);
|
||||||
const label = document.getElementById(labelId);
|
const label = document.getElementById(labelId);
|
||||||
if (!chip || !meta) return;
|
if (!chip || !meta) return;
|
||||||
@ -809,9 +985,14 @@
|
|||||||
chip.style.backgroundImage = 'none';
|
chip.style.backgroundImage = 'none';
|
||||||
chip.style.backgroundColor = meta.hex || '#fff';
|
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() {
|
function renderUsedPalette() {
|
||||||
@ -870,18 +1051,150 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ====== Balloon Ops & Data/Export ======
|
// ====== Balloon Ops & Data/Export ======
|
||||||
function addBalloon(x, y) {
|
function buildBalloon(meta, x, y, radius) {
|
||||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
return {
|
||||||
balloons.push({
|
|
||||||
x, y,
|
x, y,
|
||||||
radius: currentRadius,
|
radius,
|
||||||
color: meta.hex,
|
color: meta.hex,
|
||||||
image: meta.image || null,
|
image: meta.image || null,
|
||||||
colorIdx: meta._idx,
|
colorIdx: meta._idx,
|
||||||
id: crypto.randomUUID()
|
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]);
|
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();
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1048,7 +1361,7 @@
|
|||||||
reader.onload = ev => {
|
reader.onload = ev => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(ev.target.result);
|
const data = JSON.parse(ev.target.result);
|
||||||
balloons = Array.isArray(data.balloons)
|
const loaded = Array.isArray(data.balloons)
|
||||||
? data.balloons.map(b => {
|
? data.balloons.map(b => {
|
||||||
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
|
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
|
||||||
const meta = FLAT_COLORS[idx] || {};
|
const meta = FLAT_COLORS[idx] || {};
|
||||||
@ -1061,11 +1374,15 @@
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
balloons = loaded.slice(0, MAX_BALLOONS);
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
refreshAll({ refit: true });
|
refreshAll({ refit: true });
|
||||||
resetHistory();
|
resetHistory();
|
||||||
persist();
|
persist();
|
||||||
|
if (loaded.length > MAX_BALLOONS) {
|
||||||
|
showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showModal('Error parsing JSON file.');
|
showModal('Error parsing JSON file.');
|
||||||
}
|
}
|
||||||
@ -1201,7 +1518,8 @@
|
|||||||
}
|
}
|
||||||
fill = `url(#${patterns.get(patternKey)})`;
|
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) {
|
if (isShineEnabled) {
|
||||||
const sx = b.x - b.radius * SHINE_OFFSET;
|
const sx = b.x - b.radius * SHINE_OFFSET;
|
||||||
@ -1209,7 +1527,7 @@
|
|||||||
const rx = b.radius * SHINE_RX;
|
const rx = b.radius * SHINE_RX;
|
||||||
const ry = b.radius * SHINE_RY;
|
const ry = b.radius * SHINE_RY;
|
||||||
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
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})" />`;
|
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
|
// Some viewers ignore external styles; bake key style attributes directly
|
||||||
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
|
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
|
||||||
if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827');
|
if (!el.getAttribute('stroke')) el.setAttribute('stroke', isBorderEnabled ? '#111827' : 'none');
|
||||||
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '2');
|
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('paint-order')) el.setAttribute('paint-order', 'stroke fill');
|
||||||
if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke');
|
if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke');
|
||||||
});
|
});
|
||||||
@ -1408,18 +1726,23 @@
|
|||||||
try {
|
try {
|
||||||
let jsonStr = LZString.decompressFromEncodedURIComponent(encoded) || atob(encoded);
|
let jsonStr = LZString.decompressFromEncodedURIComponent(encoded) || atob(encoded);
|
||||||
const data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
balloons = Array.isArray(data.balloons)
|
const loaded = Array.isArray(data.balloons)
|
||||||
? data.balloons.map(b => {
|
? data.balloons.map(b => {
|
||||||
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
|
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
|
||||||
const meta = FLAT_COLORS[idx] || {};
|
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() };
|
return { x: b.x, y: b.y, radius: b.radius, color: meta.hex, image: meta.image, colorIdx: idx, id: crypto.randomUUID() };
|
||||||
})
|
})
|
||||||
: compactToDesign(data);
|
: compactToDesign(data);
|
||||||
|
balloons = loaded.slice(0, MAX_BALLOONS);
|
||||||
refreshAll({ refit: true });
|
refreshAll({ refit: true });
|
||||||
resetHistory();
|
resetHistory();
|
||||||
persist();
|
persist();
|
||||||
updateCurrentColorChip();
|
updateCurrentColorChip();
|
||||||
|
if (loaded.length > MAX_BALLOONS) {
|
||||||
|
showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`);
|
||||||
|
} else {
|
||||||
showModal('Design loaded from link!');
|
showModal('Design loaded from link!');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showModal('Could not load design from URL.');
|
showModal('Could not load design from URL.');
|
||||||
}
|
}
|
||||||
@ -1442,14 +1765,15 @@
|
|||||||
const box = balloonsBounds();
|
const box = balloonsBounds();
|
||||||
const cw = canvas.width / dpr; // CSS px
|
const cw = canvas.width / dpr; // CSS px
|
||||||
const ch = canvas.height / dpr;
|
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 pad = FIT_PADDING_PX;
|
||||||
const w = Math.max(1, box.w);
|
const w = Math.max(1, box.w);
|
||||||
const h = Math.max(1, box.h);
|
const h = Math.max(1, box.h);
|
||||||
|
|
||||||
const sFit = Math.min((cw - 2*pad) / w, (ch - 2*pad) / 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 worldW = cw / view.s;
|
||||||
const worldH = ch / view.s;
|
const worldH = ch / view.s;
|
||||||
@ -1475,8 +1799,9 @@
|
|||||||
const needSy = (ch - 2*pad) / (2*b.radius);
|
const needSy = (ch - 2*pad) / (2*b.radius);
|
||||||
const sNeeded = Math.min(needSx, needSy);
|
const sNeeded = Math.min(needSx, needSy);
|
||||||
if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) {
|
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);
|
const r = balloonScreenBounds(b);
|
||||||
let dx = 0, dy = 0;
|
let dx = 0, dy = 0;
|
||||||
@ -1505,6 +1830,7 @@
|
|||||||
modalCloseBtn?.addEventListener('click', hideModal);
|
modalCloseBtn?.addEventListener('click', hideModal);
|
||||||
|
|
||||||
toolDrawBtn?.addEventListener('click', () => setMode('draw'));
|
toolDrawBtn?.addEventListener('click', () => setMode('draw'));
|
||||||
|
toolGarlandBtn?.addEventListener('click', () => setMode('garland'));
|
||||||
toolEraseBtn?.addEventListener('click', () => setMode('erase'));
|
toolEraseBtn?.addEventListener('click', () => setMode('erase'));
|
||||||
toolSelectBtn?.addEventListener('click', () => setMode('select'));
|
toolSelectBtn?.addEventListener('click', () => setMode('select'));
|
||||||
|
|
||||||
@ -1514,6 +1840,42 @@
|
|||||||
if (mode === 'erase') draw();
|
if (mode === 'erase') draw();
|
||||||
persist();
|
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);
|
deleteSelectedBtn?.addEventListener('click', deleteSelected);
|
||||||
duplicateSelectedBtn?.addEventListener('click', duplicateSelected);
|
duplicateSelectedBtn?.addEventListener('click', duplicateSelected);
|
||||||
@ -1548,6 +1910,7 @@
|
|||||||
if (e.key === 'e' || e.key === 'E') setMode('erase');
|
if (e.key === 'e' || e.key === 'E') setMode('erase');
|
||||||
else if (e.key === 'v' || e.key === 'V') setMode('draw');
|
else if (e.key === 'v' || e.key === 'V') setMode('draw');
|
||||||
else if (e.key === 's' || e.key === 'S') setMode('select');
|
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') {
|
else if (e.key === 'Escape') {
|
||||||
if (selectedIds.size) {
|
if (selectedIds.size) {
|
||||||
clearSelection();
|
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 = [];
|
balloons = [];
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
|
garlandPath = [];
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
refreshAll({ refit: true });
|
refreshAll({ refit: true });
|
||||||
pushHistory();
|
pushHistory();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
clearCanvasBtn?.addEventListener('click', confirmAndClear);
|
||||||
|
clearCanvasBtnTop?.addEventListener('click', confirmAndClear);
|
||||||
|
|
||||||
saveJsonBtn?.addEventListener('click', saveJson);
|
saveJsonBtn?.addEventListener('click', saveJson);
|
||||||
loadJsonInput?.addEventListener('change', loadJson);
|
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', () => {
|
replaceBtn?.addEventListener('click', () => {
|
||||||
const fromHex = replaceFromSel?.value;
|
const fromHex = replaceFromSel?.value;
|
||||||
const toIdx = parseInt(replaceToSel?.value || '', 10);
|
const toIdx = parseInt(replaceToSel?.value || '', 10);
|
||||||
@ -1664,6 +2095,7 @@
|
|||||||
setMode('draw');
|
setMode('draw');
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
populateReplaceTo();
|
populateReplaceTo();
|
||||||
|
populateGarlandColorSelects();
|
||||||
|
|
||||||
// default to canvas-first on mobile; no expansion toggles remain
|
// default to canvas-first on mobile; no expansion toggles remain
|
||||||
|
|
||||||
@ -1728,10 +2160,9 @@
|
|||||||
function updateFloatingNudge() {
|
function updateFloatingNudge() {
|
||||||
const el = document.getElementById('floating-topper-nudge');
|
const el = document.getElementById('floating-topper-nudge');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const isMobile = !window.matchMedia('(min-width: 1024px)').matches;
|
|
||||||
const classicActive = document.body?.dataset.activeTab === '#tab-classic';
|
const classicActive = document.body?.dataset.activeTab === '#tab-classic';
|
||||||
const topperActive = document.body?.dataset.topperOverlay === '1';
|
const topperActive = document.body?.dataset.topperOverlay === '1';
|
||||||
const shouldShow = isMobile && classicActive && topperActive;
|
const shouldShow = classicActive && topperActive;
|
||||||
el.classList.toggle('hidden', !shouldShow);
|
el.classList.toggle('hidden', !shouldShow);
|
||||||
el.classList.toggle('collapsed', floatingNudgeCollapsed);
|
el.classList.toggle('collapsed', floatingNudgeCollapsed);
|
||||||
const toggle = document.getElementById('floating-nudge-toggle');
|
const toggle = document.getElementById('floating-nudge-toggle');
|
||||||
|
|||||||
@ -90,10 +90,11 @@ body { color: #1f2937; }
|
|||||||
.swatch:hover { transform: scale(1.1); z-index: 10; }
|
.swatch:hover { transform: scale(1.1); z-index: 10; }
|
||||||
.swatch:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; }
|
.swatch:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; }
|
||||||
.swatch.active { 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 {
|
.current-color-chip {
|
||||||
min-width: 2.5rem;
|
min-width: 2rem;
|
||||||
height: 2.5rem;
|
height: 2rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 2px solid rgba(51,65,85,0.15);
|
border: 2px solid rgba(51,65,85,0.15);
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
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-body.collapsed { display: none; }
|
||||||
.floating-nudge.collapsed .floating-nudge-body { display: none; }
|
.floating-nudge.collapsed .floating-nudge-body { display: none; }
|
||||||
.floating-nudge.collapsed #floating-nudge-toggle { opacity: 0.8; }
|
.floating-nudge.collapsed #floating-nudge-toggle { opacity: 0.8; }
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.floating-nudge { display: none !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-label {
|
.slot-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user