Checkpoint: export fixes and mobile controls

This commit is contained in:
chris 2025-12-02 16:25:36 -05:00
parent 22075cadb4
commit 57423a1d88
4 changed files with 773 additions and 167 deletions

View File

@ -447,14 +447,14 @@
<span>Balloon Size</span>
<span class="text-xs text-gray-500" id="wall-size-label">52 px (fixed)</span>
</div>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-fill-gaps" type="checkbox" class="align-middle">
Fill gaps with 11" balloons
</label>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-show-wire" type="checkbox" class="align-middle" checked>
Show wireframe for empty spots
</label>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-outline" type="checkbox" class="align-middle">
Outline balloons
</label>
</div>
</div>
@ -494,6 +494,15 @@
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</button>
<p class="hint w-full">Exports the current wall view.</p>
</div>
<div>
<div class="panel-heading text-sm">Quick Paint (uses active color)</div>
<div class="grid grid-cols-2 gap-2 mt-2">
<button type="button" id="wall-paint-links" class="btn-blue text-xs px-2 py-2">Paint Links</button>
<button type="button" id="wall-paint-small" class="btn-blue text-xs px-2 py-2">Paint 5&quot; Nodes</button>
<button type="button" id="wall-paint-gaps" class="btn-blue text-xs px-2 py-2">Paint 11&quot; Gaps</button>
</div>
<p class="hint mt-2">Fills only that group; pattern-aware for Grid vs X.</p>
</div>
<div class="flex flex-wrap gap-3">
<button type="button" id="wall-clear" class="btn-danger text-sm px-3 py-2 flex-1">Clear</button>
<button type="button" id="wall-fill-all" class="btn-blue text-sm px-3 py-2 flex-1">Fill All</button>
@ -540,6 +549,7 @@
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js" defer></script>
<!-- Palette must load before shared.js; it is already included in the <head>. -->
<script src="shared.js" defer></script>
<script src="script.js" defer></script>
<script src="organic.js" defer></script>

View File

@ -174,6 +174,12 @@
const shareLinkOutput = document.getElementById('share-link-output');
const copyMessage = document.getElementById('copy-message');
const clearCanvasBtnTop = document.getElementById('clear-canvas-btn-top');
// Debug overlay to diagnose mobile input issues
const debugOverlay = document.createElement('div');
debugOverlay.id = 'organic-debug-overlay';
debugOverlay.style.cssText = 'position:fixed;bottom:8px;right:8px;z-index:9999;background:rgba(0,0,0,0.7);color:#fff;padding:6px 8px;border-radius:8px;font-size:10px;font-family:monospace;pointer-events:none;opacity:0.9;line-height:1.3;';
debugOverlay.textContent = 'organic debug';
document.body.appendChild(debugOverlay);
// messages
const messageModal = document.getElementById('message-modal');
@ -207,6 +213,10 @@
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
let garlandMainIdx = [0, 0, 0, 0];
let garlandAccentIdx = 0;
let lastCommitMode = '';
let lastAddStatus = '';
let evtStats = { down: 0, up: 0, cancel: 0, touchEnd: 0, addBalloon: 0, addGarland: 0, lastType: '' };
let addedThisPointer = false;
// History for Undo/Redo
const historyStack = [];
@ -333,6 +343,13 @@
y: (e.clientY - r.top) / view.s - view.ty
};
}
function getTouchPos(touch) {
const r = canvas.getBoundingClientRect();
return {
x: (touch.clientX - r.left) / view.s - view.tx,
y: (touch.clientY - r.top) / view.s - view.ty
};
}
// ====== Global shine sync (shared with Classic)
window.syncAppShine = function(isEnabled) {
@ -449,11 +466,74 @@
});
}
// Avoid touch scrolling stealing pointer events.
canvas.style.touchAction = 'none';
function handlePointerDownInternal(pos, pointerType) {
if (pointerType === 'touch') evtStats.lastType = 'touch';
mouseInside = true;
mousePos = pos;
if (mode === 'erase') {
pointerDown = true;
evtStats.down += 1;
erasingActive = true;
eraseChanged = eraseAt(mousePos.x, mousePos.y);
return;
}
if (mode === 'garland') {
pointerDown = true;
evtStats.down += 1;
garlandPath = [{ ...mousePos }];
requestDraw();
return;
}
if (mode === 'select') {
pointerDown = true;
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
if (clickedIdx !== -1) {
const b = balloons[clickedIdx];
if (event?.shiftKey) {
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
else selectedIds.add(b.id);
} else if (!selectedIds.has(b.id)) {
selectedIds.clear();
selectedIds.add(b.id);
}
updateSelectButtons();
draw();
isDragging = true;
dragStartPos = { ...mousePos };
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
dragMoved = false;
} else {
if (!event?.shiftKey) selectedIds.clear();
updateSelectButtons();
marqueeActive = true;
marqueeStart = { ...mousePos };
marqueeEnd = { ...mousePos };
requestDraw();
}
return;
}
// draw mode: add
pointerDown = true;
evtStats.down += 1;
addBalloon(mousePos.x, mousePos.y);
evtStats.addBalloon += 1;
addedThisPointer = true;
}
canvas.addEventListener('pointerdown', e => {
e.preventDefault();
if (e.pointerType === 'touch') e.preventDefault();
canvas.setPointerCapture?.(e.pointerId);
mouseInside = true;
mousePos = getMousePos(e);
evtStats.down += 1;
evtStats.lastType = e.pointerType || '';
if (e.altKey || mode === 'eyedropper') {
pickColorAt(mousePos.x, mousePos.y);
@ -505,8 +585,12 @@
}
// draw mode: add
pointerDown = true;
evtStats.down += 1;
evtStats.lastType = e.pointerType || '';
addBalloon(mousePos.x, mousePos.y);
pointerDown = true; // track for potential continuous drawing or other gestures?
evtStats.addBalloon += 1;
addedThisPointer = true;
}, { passive: false });
canvas.addEventListener('pointermove', e => {
@ -561,13 +645,20 @@
if (mode === 'erase') requestDraw();
});
function commitGarlandPath() {
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
garlandPath = [];
requestDraw();
lastCommitMode = mode;
}
canvas.addEventListener('pointerup', e => {
pointerDown = false;
isDragging = false;
evtStats.up += 1;
evtStats.lastType = e.pointerType || '';
if (mode === 'garland') {
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
garlandPath = [];
requestDraw();
commitGarlandPath();
canvas.releasePointerCapture?.(e.pointerId);
return;
}
@ -591,11 +682,18 @@
refreshAll(); // update palette/persist once after the stroke
pushHistory();
}
if (mode === 'draw' && !addedThisPointer) {
addBalloon(mousePos.x, mousePos.y);
evtStats.addBalloon += 1;
lastAddStatus = 'balloon:up';
}
addedThisPointer = false;
erasingActive = false;
dragMoved = false;
eraseChanged = false;
marqueeActive = false;
canvas.releasePointerCapture?.(e.pointerId);
requestDraw();
}, { passive: true });
canvas.addEventListener('pointerleave', () => {
@ -603,12 +701,44 @@
marqueeActive = false;
if (mode === 'garland') {
pointerDown = false;
commitGarlandPath();
}
if (mode === 'draw') addedThisPointer = false;
if (mode === 'erase') requestDraw();
}, { passive: true });
canvas.addEventListener('pointercancel', (e) => {
pointerDown = false;
evtStats.cancel += 1;
evtStats.lastType = e.pointerType || '';
if (mode === 'draw') addedThisPointer = false;
if (mode === 'garland') commitGarlandPath();
}, { passive: true });
const commitIfGarland = () => {
if (mode === 'garland' && garlandPath.length > 1) {
commitGarlandPath();
} else if (mode === 'garland') {
garlandPath = [];
requestDraw();
}
if (mode === 'erase') requestDraw();
};
window.addEventListener('pointerup', () => {
pointerDown = false;
if (mode === 'draw') addedThisPointer = false;
commitIfGarland();
}, { passive: true });
const touchEndCommit = () => {
pointerDown = false;
evtStats.touchEnd += 1;
evtStats.lastType = 'touch';
if (mode === 'draw') addedThisPointer = false;
commitIfGarland();
};
window.addEventListener('touchend', touchEndCommit, { passive: true });
window.addEventListener('touchcancel', touchEndCommit, { passive: true });
// ====== Canvas & Drawing ======
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
@ -806,6 +936,19 @@
}
ctx.restore();
// Debug overlay
const dbg = [
`mode:${mode}`,
`balloons:${balloons.length}`,
`garlandLen:${garlandPath.length}`,
`pointerDown:${pointerDown}`,
`lastCommit:${lastCommitMode || '-'}`,
`lastAdd:${lastAddStatus || '-'}`,
`dpr:${dpr.toFixed(2)} s:${view.s.toFixed(2)}`,
`down:${evtStats.down} up:${evtStats.up} cancel:${evtStats.cancel} touchEnd:${evtStats.touchEnd} type:${evtStats.lastType}`
];
if (debugOverlay) debugOverlay.textContent = dbg.join(' | ');
}
new ResizeObserver(() => resizeCanvas()).observe(canvas.parentElement);
@ -1041,8 +1184,13 @@
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; }
if (balloons.length >= MAX_BALLOONS) {
lastAddStatus = 'limit';
showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`);
return;
}
balloons.push(buildBalloon(meta, x, y, currentRadius));
lastAddStatus = 'balloon';
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
refreshAll();
pushHistory();
@ -1144,10 +1292,10 @@
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;
if (!nodes.length) { lastAddStatus = 'garland:none'; return; }
const available = Math.max(0, MAX_BALLOONS - balloons.length);
const limitedNodes = available ? nodes.slice(0, available) : [];
if (!limitedNodes.length) { showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; }
if (!limitedNodes.length) { lastAddStatus = 'limit'; showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; }
const newIds = [];
const rng = makeSeededRng(garlandSeed(path) + 101);
const metaFromIdx = idx => {
@ -1170,6 +1318,7 @@
balloons.push(b);
newIds.push(b.id);
});
lastAddStatus = `garland:${newIds.length}`;
if (newIds.length) {
selectedIds.clear();
updateSelectButtons();
@ -1398,7 +1547,7 @@
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
const bounds = balloonsBounds();
const pad = 50; // extra room to avoid clipping drop-shadows
const pad = 120; // extra room to avoid clipping drop-shadows and outlines
const width = bounds.w + pad * 2;
const height = bounds.h + pad * 2;
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');

223
script.js
View File

@ -35,6 +35,17 @@
// Export buttons
const exportPngBtn = document.querySelector('[data-export="png"]');
const exportSvgBtn = document.querySelector('[data-export="svg"]');
const { XLINK_NS } = window.shared || {};
const MOBILE_TAB_DEFAULT = 'controls';
const MOBILE_TAB_PREFIX = 'designer:mobileTab:';
function getImageHref(el) {
return el.getAttribute('href') || (XLINK_NS ? el.getAttributeNS(XLINK_NS, 'href') : null);
}
function setImageHref(el, val) {
el.setAttribute('href', val);
if (XLINK_NS) el.setAttributeNS(XLINK_NS, 'xlink:href', val);
}
// Update export buttons visibility depending on tab/design
window.updateExportButtonVisibility = () => {
@ -48,9 +59,139 @@
if (isClassic) enable(exportSvgBtn, hasClassic); else if (isWall) enable(exportSvgBtn, hasWall); else enable(exportSvgBtn, true);
};
async function buildClassicSvgExportFromDom() {
const svgElement = document.querySelector('#classic-display svg');
if (!svgElement) throw new Error('Classic design not found. Please create a design first.');
const clonedSvg = svgElement.cloneNode(true);
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
await Promise.all(allImages.map(async img => {
const href = getImageHref(img);
if (!href || href.startsWith('data:')) return;
const dataUrl = await window.shared.imageUrlToDataUrl(href);
if (dataUrl) setImageHref(img, dataUrl);
}));
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!clonedSvg.getAttribute('xmlns:xlink') && XLINK_NS) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
let width = Number(clonedSvg.getAttribute('width')) || null;
let height = Number(clonedSvg.getAttribute('height')) || null;
const viewBox = (clonedSvg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
if ((!width || !height) && viewBox.length === 4 && viewBox.every(v => Number.isFinite(v))) {
width = width || viewBox[2];
height = height || viewBox[3];
}
if (!width || !height) {
const vbW = svgElement.clientWidth || 1000;
const vbH = svgElement.clientHeight || 1000;
width = width || vbW;
height = height || vbH;
}
clonedSvg.setAttribute('width', width);
clonedSvg.setAttribute('height', height);
return { svgString: new XMLSerializer().serializeToString(clonedSvg), width, height };
}
async function buildWallSvgExportFromDom() {
const svgElement = document.querySelector('#wall-display svg');
if (!svgElement) throw new Error('Wall design not found. Please create a design first.');
const clonedSvg = svgElement.cloneNode(true);
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
await Promise.all(allImages.map(async img => {
const href = getImageHref(img);
if (!href || href.startsWith('data:')) return;
const dataUrl = await window.shared.imageUrlToDataUrl(href);
if (dataUrl) setImageHref(img, dataUrl);
}));
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!clonedSvg.getAttribute('xmlns:xlink') && XLINK_NS) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
let width = Number(clonedSvg.getAttribute('width')) || null;
let height = Number(clonedSvg.getAttribute('height')) || null;
const viewBox = (clonedSvg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
if ((!width || !height) && viewBox.length === 4 && viewBox.every(v => Number.isFinite(v))) {
width = width || viewBox[2];
height = height || viewBox[3];
}
if (!width || !height) {
const vbW = svgElement.clientWidth || 1000;
const vbH = svgElement.clientHeight || 1000;
width = width || vbW;
height = height || vbH;
}
clonedSvg.setAttribute('width', width);
clonedSvg.setAttribute('height', height);
return { svgString: new XMLSerializer().serializeToString(clonedSvg), width, height };
}
// Export routing
async function buildWallSvgExportFromDom() {
const svgElement = document.querySelector('#wall-display svg');
if (!svgElement) throw new Error('Wall design not found. Please create a design first.');
const clonedSvg = svgElement.cloneNode(true);
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
await Promise.all(allImages.map(async img => {
const href = getImageHref(img);
if (!href || href.startsWith('data:')) return;
const dataUrl = await window.shared.imageUrlToDataUrl(href);
if (dataUrl) setImageHref(img, dataUrl);
}));
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!clonedSvg.getAttribute('xmlns:xlink') && XLINK_NS) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
let width = Number(clonedSvg.getAttribute('width')) || null;
let height = Number(clonedSvg.getAttribute('height')) || null;
const viewBox = (clonedSvg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
if ((!width || !height) && viewBox.length === 4 && viewBox.every(v => Number.isFinite(v))) {
width = width || viewBox[2];
height = height || viewBox[3];
}
if (!width || !height) {
const vbW = svgElement.clientWidth || 1000;
const vbH = svgElement.clientHeight || 1000;
width = width || vbW;
height = height || vbH;
}
clonedSvg.setAttribute('width', width);
clonedSvg.setAttribute('height', height);
const svgString = new XMLSerializer().serializeToString(clonedSvg);
return { svgString, width, height };
}
async function inlineSvgImages(svgString) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, 'image/svg+xml');
if (doc.querySelector('parsererror')) return svgString;
const imgs = Array.from(doc.querySelectorAll('image'));
await Promise.all(imgs.map(async img => {
const href = img.getAttribute('href') || img.getAttribute('xlink:href');
if (!href || href.startsWith('data:')) return;
const dataUrl = await window.shared.imageUrlToDataUrl(href);
if (dataUrl) {
img.setAttribute('href', dataUrl);
img.removeAttribute('xlink:href');
}
}));
return new XMLSerializer().serializeToString(doc.documentElement);
} catch (err) {
console.warn('[Export] inlineSvgImages failed', err);
return svgString;
}
}
async function svgStringToPng(svgString, width, height) {
const { PNG_EXPORT_SCALE } = window.shared;
if (!/xmlns=/.test(svgString)) {
svgString = svgString.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
// Inline any external images to avoid CORS/fetch issues.
svgString = await inlineSvgImages(svgString);
const img = new Image();
img.crossOrigin = 'anonymous';
const scale = PNG_EXPORT_SCALE;
@ -67,16 +208,22 @@
img.onerror = () => reject(new Error('Could not rasterize SVG.'));
img.src = src;
});
let blobUrl = null;
// Prefer base64 data URL to avoid parser issues with raw text.
const svg64 = btoa(unescape(encodeURIComponent(svgString)));
const dataUrl = `data:image/svg+xml;base64,${svg64}`;
try {
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
blobUrl = URL.createObjectURL(blob);
await loadSrc(blobUrl);
} catch (e) {
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
await loadSrc(dataUrl);
} finally {
if (blobUrl) URL.revokeObjectURL(blobUrl);
} catch (e) {
// Fallback to blob if base64 path fails.
let blobUrl = null;
try {
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
blobUrl = URL.createObjectURL(blob);
await loadSrc(blobUrl);
} finally {
if (blobUrl) URL.revokeObjectURL(blobUrl);
}
}
ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
return canvasEl.toDataURL('image/png');
@ -94,13 +241,15 @@
try {
const tab = detectCurrentTab();
if (tab === '#tab-classic') {
const { svgString, width, height } = await window.ClassicExport.buildClassicSvgPayload();
const { svgString, width, height } = window.ClassicExport?.buildClassicSvgPayload
? await window.ClassicExport.buildClassicSvgPayload()
: await buildClassicSvgExportFromDom();
const pngUrl = await svgStringToPng(svgString, width, height);
window.shared.download(pngUrl, 'classic_design.png');
return;
}
if (tab === '#tab-wall') {
const { svgString, width, height } = await window.WallDesigner.buildWallSvgPayload(true);
const { svgString, width, height } = await buildWallSvgExportFromDom();
const pngUrl = await svgStringToPng(svgString, width, height);
window.shared.download(pngUrl, 'wall_design.png');
return;
@ -118,7 +267,9 @@
try {
const tab = detectCurrentTab();
if (tab === '#tab-classic') {
const { svgString, width, height } = await window.ClassicExport.buildClassicSvgPayload();
const { svgString, width, height } = window.ClassicExport?.buildClassicSvgPayload
? await window.ClassicExport.buildClassicSvgPayload()
: await buildClassicSvgExportFromDom();
try {
const pngUrl = await svgStringToPng(svgString, width, height);
const cleanSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
@ -132,7 +283,7 @@
return;
}
if (tab === '#tab-wall') {
const { svgString } = await window.WallDesigner.buildWallSvgPayload(true);
const { svgString, width, height } = await buildWallSvgExportFromDom();
downloadSvg(svgString, 'wall_design.svg');
return;
}
@ -186,14 +337,17 @@
const wallPanel = document.getElementById('wall-controls-panel');
const currentTab = detectCurrentTab();
const panel = currentTab === '#tab-classic' ? claPanel : (currentTab === '#tab-wall' ? wallPanel : orgPanel);
const target = tabName || document.body?.dataset?.mobileTab || 'controls';
const target = tabName || document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT;
const isHidden = document.body?.dataset?.controlsHidden === '1';
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (!panel) return;
const stacks = Array.from(panel.querySelectorAll('.control-stack'));
if (!stacks.length) return;
stacks.forEach(stack => {
if (isHidden) {
stack.style.display = 'none';
} else if (isDesktop) {
stack.style.display = '';
} else {
const show = stack.dataset.mobileTab === target;
stack.style.display = show ? 'block' : 'none';
@ -201,13 +355,25 @@
});
}
function setMobileTab(tab) {
const name = tab || 'controls';
function setMobileTab(tab, mainTabId, skipPersist = false) {
const name = tab || MOBILE_TAB_DEFAULT;
const activeMainTab = mainTabId || detectCurrentTab();
if (!skipPersist && activeMainTab) {
try { localStorage.setItem(`${MOBILE_TAB_PREFIX}${activeMainTab}`, name); } catch {}
}
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (document.body) {
document.body.dataset.mobileTab = name;
delete document.body.dataset.controlsHidden;
}
// Ensure the current panel is not minimized/hidden when we select a tab.
const panel = activeMainTab === '#tab-classic'
? document.getElementById('classic-controls-panel')
: (activeMainTab === '#tab-wall'
? document.getElementById('wall-controls-panel')
: document.getElementById('controls-panel'));
panel?.classList.remove('minimized');
if (panel) panel.style.display = '';
updateSheets();
updateMobileStacks(name);
const buttons = document.querySelectorAll('#mobile-tabbar .mobile-tab-btn');
@ -312,7 +478,12 @@
document.getElementById('clear-canvas-btn-top')?.classList.toggle('hidden', !isOrganic);
const headerActiveSwatch = document.getElementById('current-color-chip-global')?.closest('.flex');
headerActiveSwatch?.classList.toggle('hidden', !showHeaderColor);
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
const savedMobile = (() => {
try { return localStorage.getItem(`${MOBILE_TAB_PREFIX}${id}`) || MOBILE_TAB_DEFAULT; } catch {}
return MOBILE_TAB_DEFAULT;
})();
if (document.body) document.body.dataset.mobileTab = savedMobile;
setMobileTab(savedMobile, id, true);
orgSheet?.classList.toggle('hidden', id !== '#tab-organic');
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
@ -328,8 +499,14 @@
try { savedTab = localStorage.getItem(ACTIVE_TAB_KEY); } catch {}
setTab(savedTab || '#tab-organic', true);
window.__whichTab = () => current;
if (!document.body?.dataset?.mobileTab) document.body.dataset.mobileTab = 'controls';
setMobileTab(document.body.dataset.mobileTab);
if (!document.body?.dataset?.mobileTab) {
const initialMobile = (() => {
try { return localStorage.getItem(`${MOBILE_TAB_PREFIX}${current}`) || MOBILE_TAB_DEFAULT; } catch {}
return MOBILE_TAB_DEFAULT;
})();
document.body.dataset.mobileTab = initialMobile;
}
setMobileTab(document.body.dataset.mobileTab, current, true);
updateSheets();
updateMobileStacks(document.body.dataset.mobileTab);
}
@ -359,16 +536,12 @@
});
const mq = window.matchMedia('(min-width: 1024px)');
const sync = () => {
if (mq.matches) {
document.body?.removeAttribute('data-mobile-tab');
updateMobileStacks('controls');
} else {
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
}
const current = document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT;
setMobileTab(current, detectCurrentTab(), true);
updateFloatingNudge();
};
mq.addEventListener('change', sync);
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
setMobileTab(document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT, detectCurrentTab(), true);
sync();
const nudgeToggle = document.getElementById('floating-nudge-toggle');
nudgeToggle?.addEventListener('click', () => {

530
wall.js
View File

@ -26,8 +26,8 @@
const wallColsInput = document.getElementById('wall-cols');
const wallPatternSelect = document.getElementById('wall-pattern');
const wallGridLabel = document.getElementById('wall-grid-label');
const wallFillGapsCb = document.getElementById('wall-fill-gaps');
const wallShowWireCb = document.getElementById('wall-show-wire');
const wallOutlineCb = document.getElementById('wall-outline');
const wallClearBtn = document.getElementById('wall-clear');
const wallFillAllBtn = document.getElementById('wall-fill-all');
const wallUsedPaletteEl = document.getElementById('wall-used-palette');
@ -38,14 +38,22 @@
const wallReplaceMsg = document.getElementById('wall-replace-msg');
const wallSpacingLabel = document.getElementById('wall-spacing-label');
const wallSizeLabel = document.getElementById('wall-size-label');
const wallPaintLinksBtn = document.getElementById('wall-paint-links');
const wallPaintSmallBtn = document.getElementById('wall-paint-small');
const wallPaintGapsBtn = document.getElementById('wall-paint-gaps');
const patternKey = () => (wallState.pattern === 'x' ? 'x' : 'grid');
const autoGapColorIdx = () =>
Number.isInteger(wallState?.activeColorIdx) && wallState.activeColorIdx >= 0
? wallState.activeColorIdx
: 0;
const ensurePatternStore = () => {
if (!wallState.patternStore || typeof wallState.patternStore !== 'object') wallState.patternStore = {};
['grid', 'x'].forEach(k => {
if (!wallState.patternStore[k]) {
wallState.patternStore[k] = { colors: [], customColors: {}, fillGaps: false, showWireframes: false };
wallState.patternStore[k] = { colors: [], customColors: {}, fillGaps: false, showWireframes: false, outline: false };
}
});
};
@ -56,8 +64,8 @@
wallState.patternStore[key] = {
colors: wallState.colors,
customColors: wallState.customColors,
fillGaps: wallState.fillGaps,
showWireframes: wallState.showWireframes
showWireframes: wallState.showWireframes,
outline: wallState.outline
};
}
@ -70,12 +78,28 @@
if (st.customColors && typeof st.customColors === 'object' && Object.keys(st.customColors).length) {
wallState.customColors = st.customColors;
}
if (typeof st.fillGaps === 'boolean') wallState.fillGaps = st.fillGaps;
if (typeof st.showWireframes === 'boolean') wallState.showWireframes = st.showWireframes;
if (typeof st.outline === 'boolean') wallState.outline = st.outline;
}
function wallDefaultState() {
return { rows: 7, cols: 9, spacing: 75, bigSize: 52, pattern: 'grid', fillGaps: false, showWireframes: false, colors: [], customColors: {}, patternStore: {}, activeColorIdx: 0 };
// Default to wireframes on so empty cells are visible/clickable.
return { rows: 7, cols: 9, spacing: 75, bigSize: 52, pattern: 'grid', fillGaps: false, showWireframes: true, outline: false, colors: [], customColors: {}, patternStore: {}, activeColorIdx: 0 };
}
// Build FLAT_COLORS locally if shared failed to populate (e.g., palette not ready)
function ensureFlatColors() {
if (Array.isArray(FLAT_COLORS) && FLAT_COLORS.length > 0) return;
if (!Array.isArray(window.PALETTE)) return;
console.warn('[Wall] FLAT_COLORS missing; rebuilding from window.PALETTE');
let idx = 0;
window.PALETTE.forEach(group => {
(group.colors || []).forEach(c => {
if (!c?.hex) return;
const item = { ...c, family: group.family, _idx: idx++ };
FLAT_COLORS.push(item);
});
});
}
function loadWallState() {
@ -88,12 +112,13 @@
base.spacing = 75; // fixed
base.bigSize = 52; // fixed
base.pattern = saved.pattern === 'x' ? 'x' : 'grid';
base.fillGaps = !!saved.fillGaps;
base.fillGaps = false;
base.showWireframes = saved.showWireframes !== false;
base.patternStore = saved.patternStore && typeof saved.patternStore === 'object' ? saved.patternStore : {};
base.customColors = (saved.customColors && typeof saved.customColors === 'object') ? saved.customColors : {};
if (Number.isInteger(saved.activeColorIdx)) base.activeColorIdx = saved.activeColorIdx;
if (Array.isArray(saved.colors)) base.colors = saved.colors;
if (typeof saved.outline === 'boolean') base.outline = saved.outline;
}
} catch {}
return base;
@ -133,17 +158,35 @@
else if (parts[1] === 'v' && (rVal >= r - 1 || cVal >= c)) delete wallState.customColors[k];
return;
}
if (type === 'f') {
// f-h/f-v/f-x
if (parts[1] === 'h' || parts[1] === 'v') {
const { rVal, cVal } = parseRC(parts[2], parts[3]);
if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; }
if (parts[1] === 'h' && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k];
else if (parts[1] === 'v' && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k];
return;
}
if (parts[1] === 'x') {
const { rVal, cVal } = parseRC(parts[2], parts[3]);
if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; }
if (rVal <= 0 || cVal <= 0 || rVal >= r - 1 || cVal >= c - 1) delete wallState.customColors[k];
return;
}
}
const { rVal, cVal } = parseRC(parts[1], parts[2]);
if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; }
if (type === 'h' && (rVal >= r || cVal >= c - 1)) delete wallState.customColors[k];
else if (type === 'v' && (rVal >= r - 1 || cVal >= c)) delete wallState.customColors[k];
else if (type === 'g' && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k];
else if ((type === 'c' || type.startsWith('l')) && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k];
else if ((type === 'c' || type.startsWith('l') || type === 'f') && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k];
});
}
const wallColorMeta = (idx) => (Number.isInteger(idx) && idx >= 0 && FLAT_COLORS[idx]) ? FLAT_COLORS[idx] : { hex: WALL_FALLBACK_COLOR };
async function buildWallSvgPayload(forExport = false) {
async function buildWallSvgPayload(forExport = false, customColorsOverride = null) {
const customColors = customColorsOverride || wallState.customColors;
if (!ensureShared()) throw new Error('Wall designer shared helpers missing.');
if (!wallState) wallState = loadWallState();
ensurePatternStore();
@ -174,16 +217,17 @@
const offsetX = margin + labelPad;
const offsetY = margin + labelPad;
const showWireframes = !!wallState.showWireframes;
const showOutline = !!wallState.outline;
const colSpacing = spacing;
const rowStep = spacing;
const showGaps = !!wallState.fillGaps;
const showGaps = false;
const uniqueImages = new Set();
wallState.colors.forEach(row => row.forEach(idx => {
const meta = wallColorMeta(idx);
if (meta.image) uniqueImages.add(meta.image);
}));
Object.values(wallState.customColors || {}).forEach(idx => {
Object.values(customColors || {}).forEach(idx => {
const meta = wallColorMeta(idx);
if (meta.image) uniqueImages.add(meta.image);
});
@ -222,6 +266,7 @@
const composite = `<feComposite in="shadow" in2="SourceAlpha" operator="in" result="shadow" />`;
const merge = `<feMerge><feMergeNode in="shadow" /><feMergeNode in="SourceGraphic" /></feMerge>`;
defs.push(`<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${flood}${blur}${offsetNode}${composite}${merge}</filter>`);
shadowFilters.set(key, id);
}
return shadowFilters.get(key);
};
@ -260,9 +305,12 @@
}
const customOverride = (key) => {
const val = wallState.customColors?.[key];
if (val === -1) return { mode: 'empty' };
if (Number.isInteger(val) && val >= 0) return { mode: 'color', idx: val };
const raw = customColors?.[key];
const parsed = Number.isInteger(raw) ? raw : Number.parseInt(raw, 10);
if (parsed === -1) return { mode: 'empty' };
if (Number.isInteger(parsed) && parsed >= 0) {
return { mode: 'color', idx: normalizeColorIdx(parsed) };
}
return { mode: 'auto' };
};
@ -286,25 +334,25 @@
const override = customOverride(keyId);
const customIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || customIdx === null;
if (isEmpty && !showWireframes) continue;
const invisible = isEmpty && !showWireframes;
const hitFill = 'rgba(0,0,0,0.001)';
const meta = wallColorMeta(customIdx);
const patId = ensurePattern(meta);
const fill = isEmpty ? (showWireframes ? 'none' : 'transparent') : (patId ? `url(#${patId})` : meta.hex);
const stroke = isEmpty ? (showWireframes ? '#cbd5e1' : 'none') : '#d1d5db';
const strokeW = isEmpty ? (showWireframes ? 1.4 : 0) : 1.2;
const filter = isEmpty ? '' : `filter="url(#${smallShadow})"`;
const fill = invisible ? hitFill : (isEmpty ? hitFill : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? (showWireframes ? '#cbd5e1' : 'none') : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? (showWireframes ? 1.4 : 0) : (showOutline ? 0.6 : 0));
const filter = (isEmpty || invisible) ? '' : `filter="url(#${smallShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${pos.x},${pos.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
}
}
// Gap 11" balloons between centers (horizontal/vertical midpoints) inside the grid (exclude outer perimeter)
// Gap 11" balloons between centers (horizontal/vertical midpoints) inside the grid (exclude only right edge for horizontals, bottom edge for verticals)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) {
const p1 = positions.get(`${r}-${c}`);
@ -315,20 +363,19 @@
const override = customOverride(keyId);
const customIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || customIdx === null;
if (isEmpty && !showWireframes) continue;
const invisible = isEmpty && !showWireframes;
const hitFill = 'rgba(0,0,0,0.001)';
const meta = wallColorMeta(customIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showWireframes;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const fill = invisible ? hitFill : (isEmpty ? hitFill : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y})">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
}
@ -344,20 +391,19 @@
const override = customOverride(keyId);
const customIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || customIdx === null;
if (isEmpty && !showWireframes) continue;
const invisible = isEmpty && !showWireframes;
const hitFill = 'rgba(0,0,0,0.001)';
const meta = wallColorMeta(customIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showWireframes;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const fill = invisible ? hitFill : (isEmpty ? hitFill : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y}) rotate(90)">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
}
@ -370,19 +416,21 @@
const center = { x: (pTL.x + pBR.x) / 2, y: (pTL.y + pBR.y) / 2 };
const gapKey = `g-${r}-${c}`;
const override = customOverride(gapKey);
const gapIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || gapIdx === null;
const gapIdx = override.mode === 'color'
? override.idx
: (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null));
const isEmpty = gapIdx === null;
const meta = wallColorMeta(gapIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showGaps;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82; // slightly smaller 11" gap balloon
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${gapKey}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${center.x},${center.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shineGap}
</g>`);
}
@ -400,21 +448,20 @@
const centerOverride = customOverride(centerKey);
const centerCustomIdx = centerOverride.mode === 'color' ? centerOverride.idx : null;
const centerIsEmpty = centerOverride.mode === 'empty' || centerCustomIdx === null;
const invisible = centerIsEmpty && !showWireframes;
if (!centerIsEmpty || showWireframes) {
const meta = wallColorMeta(centerCustomIdx);
const patId = ensurePattern(meta);
const fill = centerIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex);
const stroke = centerIsEmpty ? '#cbd5e1' : '#d1d5db';
const strokeW = centerIsEmpty ? 1.4 : 1.2;
const filter = centerIsEmpty ? '' : `filter="url(#${smallShadow})"`;
const shine = centerIsEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
const meta = wallColorMeta(centerCustomIdx);
const patId = ensurePattern(meta);
const fill = invisible ? 'rgba(0,0,0,0.001)' : (centerIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (centerIsEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (centerIsEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = centerIsEmpty || invisible ? '' : `filter="url(#${smallShadow})"`;
const shine = centerIsEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${centerKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${center.x},${center.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shine}
</g>`);
}
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${centerKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${center.x},${center.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
const targets = [p1, p2, p4, p3];
const linkKeys = [`l1-${r}-${c}`, `l2-${r}-${c}`, `l3-${r}-${c}`, `l4-${r}-${c}`];
@ -429,75 +476,106 @@
const linkCustomIdx = linkOverride.mode === 'color' ? linkOverride.idx : null;
const linkIsEmpty = linkOverride.mode === 'empty' || linkCustomIdx === null;
if (linkIsEmpty && !showWireframes) continue;
const invisibleLink = linkIsEmpty && !showWireframes;
const meta = wallColorMeta(linkCustomIdx);
const patId = ensurePattern(meta);
const fill = linkIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex);
const stroke = linkIsEmpty ? '#cbd5e1' : '#d1d5db';
const strokeW = linkIsEmpty ? 1.4 : 1.2;
const filter = linkIsEmpty ? '' : `filter="url(#${bigShadow})"`;
const fill = invisibleLink ? 'rgba(0,0,0,0.001)' : (linkIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
// Always outline X-pattern link ovals; thicken when outline toggle is on.
const stroke = invisibleLink ? 'none' : (showOutline ? '#111827' : '#cbd5e1');
const strokeW = invisibleLink ? 0 : (showOutline ? 0.8 : 0.6);
const filter = invisibleLink || linkIsEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = linkIsEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${linkKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y}) rotate(${angle})">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
}
}
}
// Gap 11" balloons between centers (horizontal/vertical midpoints) inside the grid (include outer rim)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) {
// Gap 11" balloons between centers (horizontal/vertical midpoints) across the X pattern
// Keep gaps at midpoints; skip the top row and the far-left column.
const maxCH = Math.max(0, cols - 1);
// Diamond 5" fillers at original grid intersections (skip border)
for (let r = 1; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) {
const pos = positions.get(`${r}-${c}`);
if (!pos) continue;
const fillerKey = `f-x-${r}-${c}`;
const fillerOverride = customOverride(fillerKey);
const fillerIdx = fillerOverride.mode === 'color' ? fillerOverride.idx : null;
const fillerEmpty = fillerOverride.mode === 'empty' || fillerIdx === null;
const fillerInvisible = fillerEmpty && !showWireframes;
const fillerMeta = wallColorMeta(fillerIdx);
const fillerPat = ensurePattern(fillerMeta);
const fillerFill = fillerInvisible ? 'rgba(0,0,0,0.001)' : (fillerEmpty ? (showWireframes ? 'none' : 'rgba(0,0,0,0.001)') : (fillerPat ? `url(#${fillerPat})` : fillerMeta.hex));
const fillerStroke = fillerInvisible ? 'none' : (fillerEmpty ? (showWireframes ? '#cbd5e1' : 'none') : 'none');
const fillerStrokeW = fillerInvisible ? 0 : (fillerEmpty ? (showWireframes ? 1.2 : 0) : 0);
const fillerFilter = fillerInvisible || fillerEmpty ? '' : `filter="url(#${smallShadow})"`;
const fillerShine = fillerEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, fillerMeta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${fillerKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${pos.x},${pos.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fillerFill}" stroke="${fillerStroke}" stroke-width="${fillerStrokeW}" ${fillerFilter} pointer-events="all" />
${fillerShine}
</g>`);
}
}
for (let r = 1; r < rows - 1; r++) {
for (let c = 0; c < maxCH; c++) {
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r}-${c+1}`);
const mid = { x: (p1.x + p2.x) / 2, y: p1.y };
const key = `g-h-${r}-${c}`;
const override = customOverride(key);
const gapIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || gapIdx === null;
const gapIdx = override.mode === 'color'
? override.idx
: (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null));
const isEmpty = gapIdx === null;
const meta = wallColorMeta(gapIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showGaps;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82;
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${key}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${mid.x},${mid.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${key}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${mid.x},${mid.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shineGap}
</g>`);
}
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols; c++) {
for (let c = 1; c < maxCH; c++) { // start at column 1 to keep far-left clear
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r+1}-${c}`);
const mid = { x: p1.x, y: (p1.y + p2.y) / 2 };
const key = `g-v-${r}-${c}`;
const override = customOverride(key);
const gapIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || gapIdx === null;
const gapIdx = override.mode === 'color'
? override.idx
: (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null));
const isEmpty = gapIdx === null;
const meta = wallColorMeta(gapIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showGaps;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82;
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${key}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${mid.x},${mid.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shineGap}
</g>`);
}
}
}
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb}" width="${width}" height="${height}" aria-label="Balloon wall">
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${width}" height="${height}" aria-label="Balloon wall">
<defs>${defs.join('')}</defs>
<g>${bigNodes.join('')}</g>
<g>${smallNodes.join('')}</g>
@ -545,14 +623,10 @@
}
sw.title = `${item.name || item.hex} (${item.count})`;
sw.addEventListener('click', () => {
if (Number.isInteger(item.idx)) {
selectedColorIdx = item.idx;
if (window.organic && window.organic.updateCurrentColorChip) {
window.organic.updateCurrentColorChip(selectedColorIdx);
}
renderWallPalette();
renderWallUsedPalette();
}
if (!Number.isInteger(item.idx)) return;
setActiveColor(normalizeColorIdx(item.idx));
renderWallPalette();
renderWallUsedPalette();
});
row.appendChild(sw);
});
@ -574,10 +648,38 @@
});
}
// Pick a visible default (first reasonably saturated entry).
function defaultActiveColorIdx() {
if (!Array.isArray(FLAT_COLORS) || !FLAT_COLORS.length) return 0;
const isTooLight = (hex = '') => {
const h = hex.replace('#', '');
if (h.length !== 6) return false;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return (r + g + b) > 640; // avoid near-white/pastel defaults
};
const firstVisible = FLAT_COLORS.find(c => c?.hex && !isTooLight(c.hex));
if (firstVisible) {
const idx = Number.isInteger(firstVisible._idx) ? firstVisible._idx : FLAT_COLORS.indexOf(firstVisible);
if (idx >= 0) return idx;
}
return 0;
}
const normalizeColorIdx = (idx) => {
const fallback = defaultActiveColorIdx();
if (!Number.isInteger(idx)) return fallback;
if (idx < 0) return fallback;
if (Array.isArray(FLAT_COLORS) && FLAT_COLORS.length > 0 && idx >= FLAT_COLORS.length) {
return FLAT_COLORS.length - 1;
}
return idx;
};
function setActiveColor(idx) {
selectedColorIdx = Number.isInteger(idx) ? idx : 0;
selectedColorIdx = normalizeColorIdx(idx);
wallState.activeColorIdx = selectedColorIdx;
console.log('[Wall] setActiveColor', selectedColorIdx);
if (window.organic?.setColor) {
window.organic.setColor(selectedColorIdx);
} else if (window.organic?.updateCurrentColorChip) {
@ -586,6 +688,158 @@
saveWallState();
}
// Sync the wall's active color with the global/organic selection when available.
function syncActiveColorFromOrganic() {
const organicIdx = window.organic?.getColor?.();
if (!Number.isInteger(organicIdx)) return null;
const normalized = normalizeColorIdx(organicIdx);
if (normalized !== selectedColorIdx) {
selectedColorIdx = normalized;
if (wallState) wallState.activeColorIdx = normalized;
saveWallState();
renderWallPalette();
}
return normalized;
}
// Read the current UI-selected color. Prefer the global/organic selection so the active color chip always drives wall clicks.
// Current active color: prefer organic tab, then wall selection, then stored default.
function getActiveWallColorIdx() {
const organicIdx = syncActiveColorFromOrganic();
if (Number.isInteger(organicIdx)) return organicIdx;
if (Number.isInteger(selectedColorIdx)) return normalizeColorIdx(selectedColorIdx);
if (Number.isInteger(wallState?.activeColorIdx)) return normalizeColorIdx(wallState.activeColorIdx);
return defaultActiveColorIdx();
}
// Normalize the stored color for a wall key to either a valid index or null (empty).
function getStoredColorForKey(key) {
if (!wallState?.customColors) return null;
const raw = wallState.customColors[key];
const parsed = Number.isInteger(raw) ? raw : Number.parseInt(raw, 10);
if (!Number.isInteger(parsed)) return null;
if (parsed < 0) return null;
const val = normalizeColorIdx(parsed);
if (val < 0) return null;
wallState.customColors[key] = val; // write back normalized numeric value
return val;
}
// Paint a specific group of nodes with the active color.
function paintWallGroup(group) {
ensureWallGridSize(wallState.rows, wallState.cols);
const idx = getActiveWallColorIdx();
if (!Number.isInteger(idx)) return;
const rows = wallState.rows;
const cols = wallState.cols;
const isGrid = wallState.pattern === 'grid';
const custom = { ...wallState.customColors };
const set = (key) => { custom[key] = idx; };
if (group === 'links') {
if (isGrid) {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) set(`h-${r}-${c}`);
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols; c++) set(`v-${r}-${c}`);
}
} else {
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
['l1', 'l2', 'l3', 'l4'].forEach(l => set(`${l}-${r}-${c}`));
}
}
}
} else if (group === 'small') {
if (isGrid) {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) set(`p-${r}-${c}`);
}
wallState.colors = wallState.colors.map(row => row.map(() => idx));
} else {
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
set(`c-${r}-${c}`);
set(`f-x-${r+1}-${c+1}`); // diamond 5" fillers between link crosses
}
}
}
} else if (group === 'gaps') {
if (isGrid) {
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) set(`g-${r}-${c}`);
}
} else {
const maxCH = Math.max(0, cols - 1);
for (let r = 1; r < rows - 1; r++) {
for (let c = 0; c < maxCH; c++) set(`g-h-${r}-${c}`);
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 1; c < maxCH; c++) set(`g-v-${r}-${c}`);
}
}
} else if (group === 'filler') {
if (!isGrid) {
for (let r = 1; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) set(`f-x-${r}-${c}`);
}
}
} else {
return;
}
wallState.customColors = custom;
saveActivePatternState();
saveWallState();
renderWall();
}
// Apply an immediate visual change to the clicked element so users see feedback even before a full re-render.
function applyImmediateFill(el, colorIdx) {
if (!el) return;
const shape = el.querySelector('circle,ellipse');
if (!shape) return;
const isEmpty = !Number.isInteger(colorIdx);
const meta = wallColorMeta(isEmpty ? null : colorIdx);
const emptyHitFill = wallState.showWireframes ? 'none' : 'rgba(0,0,0,0.001)';
const fill = isEmpty ? emptyHitFill : (meta.hex || WALL_FALLBACK_COLOR);
const stroke = isEmpty
? (wallState.showWireframes ? '#cbd5e1' : 'none')
: (wallState.outline ? '#111827' : 'none');
const strokeW = isEmpty
? (wallState.showWireframes ? 1.2 : 0)
: (wallState.outline ? 0.6 : 0);
shape.setAttribute('fill', fill);
shape.setAttribute('stroke', stroke);
shape.setAttribute('stroke-width', strokeW);
}
// After rendering the SVG, enforce fill/stroke based on current state to avoid any template mismatch.
function paintDomFromState() {
if (!wallDisplay) return;
const nodes = wallDisplay.querySelectorAll('[data-wall-key]');
nodes.forEach(node => {
const key = node.dataset?.wallKey;
const idx = getStoredColorForKey(key);
const shape = node.querySelector('circle,ellipse');
if (!shape) return;
const isEmpty = !Number.isInteger(idx);
const meta = wallColorMeta(isEmpty ? null : idx);
const emptyHitFill = wallState.showWireframes ? 'none' : 'rgba(0,0,0,0.001)';
const fill = isEmpty ? emptyHitFill : (meta.hex || WALL_FALLBACK_COLOR);
const stroke = isEmpty
? (wallState.showWireframes ? '#cbd5e1' : 'none')
: (wallState.outline ? '#111827' : 'none');
const strokeW = isEmpty
? (wallState.showWireframes ? 1.2 : 0)
: (wallState.outline ? 0.6 : 0);
shape.setAttribute('fill', fill);
shape.setAttribute('stroke', stroke);
shape.setAttribute('stroke-width', strokeW);
});
}
async function renderWall() {
if (!wallDisplay) return;
ensureWallGridSize(wallState.rows, wallState.cols);
@ -594,6 +848,7 @@
const { svgString } = await buildWallSvgPayload(false);
wallDisplay.innerHTML = svgString;
renderWallUsedPalette();
// paintDomFromState();
} catch (err) {
console.error('[Wall] render failed', err);
wallDisplay.innerHTML = `<div class="p-4 text-sm text-red-600">Could not render wall.</div>`;
@ -613,7 +868,12 @@
const row = document.createElement('div');
row.className = 'swatch-row';
(group.colors || []).forEach(c => {
const idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family);
const normHex = (c.hex || '').toLowerCase();
let idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family);
if (idx < 0 && window.shared?.HEX_TO_FIRST_IDX?.has(normHex)) {
idx = window.shared.HEX_TO_FIRST_IDX.get(normHex);
}
idx = normalizeColorIdx(idx);
const sw = document.createElement('button');
sw.type = 'button';
sw.className = 'swatch';
@ -628,7 +888,7 @@
if (idx === selectedColorIdx) sw.classList.add('active');
sw.title = c.name;
sw.addEventListener('click', () => {
setActiveColor(idx ?? 0);
setActiveColor(idx);
window.organic?.updateCurrentColorChip?.(selectedColorIdx);
// Also update the global chip explicitly
if (window.organic?.updateCurrentColorChip) {
@ -652,17 +912,20 @@
if (wallSizeLabel) wallSizeLabel.textContent = `${wallState.bigSize} px (fixed)`;
if (wallGridLabel) wallGridLabel.textContent = `${wallState.cols} × ${wallState.rows}`;
if (wallPatternSelect) wallPatternSelect.value = wallState.pattern || 'grid';
if (wallFillGapsCb) wallFillGapsCb.checked = !!wallState.fillGaps;
if (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes !== false;
if (wallOutlineCb) wallOutlineCb.checked = !!wallState.outline;
}
function initWallDesigner() {
if (!ensureShared()) return;
ensureFlatColors();
if (!wallDisplay) return;
wallState = loadWallState();
ensurePatternStore();
if (Number.isInteger(wallState.activeColorIdx)) selectedColorIdx = wallState.activeColorIdx;
else if (window.organic?.getColor) selectedColorIdx = window.organic.getColor();
if (Number.isInteger(wallState.activeColorIdx)) selectedColorIdx = normalizeColorIdx(wallState.activeColorIdx);
else if (window.organic?.getColor) selectedColorIdx = normalizeColorIdx(window.organic.getColor());
else selectedColorIdx = defaultActiveColorIdx();
setActiveColor(selectedColorIdx);
loadPatternState(patternKey());
ensureWallGridSize(wallState.rows, wallState.cols);
syncWallInputs();
@ -694,35 +957,54 @@
renderWall();
syncWallInputs();
});
wallFillGapsCb?.addEventListener('change', () => {
wallState.fillGaps = !!wallFillGapsCb.checked;
saveActivePatternState();
saveWallState();
renderWall();
});
wallShowWireCb?.addEventListener('change', () => {
wallState.showWireframes = !!wallShowWireCb.checked;
saveActivePatternState();
saveWallState();
renderWall();
});
wallOutlineCb?.addEventListener('change', () => {
wallState.outline = !!wallOutlineCb.checked;
saveActivePatternState();
saveWallState();
renderWall();
});
wallPaintLinksBtn?.addEventListener('click', () => paintWallGroup('links'));
wallPaintSmallBtn?.addEventListener('click', () => paintWallGroup('small'));
wallPaintGapsBtn?.addEventListener('click', () => paintWallGroup('gaps'));
const findWallNode = (el) => {
let cur = el;
while (cur && cur !== wallDisplay) {
if (cur.dataset?.wallKey) return cur;
cur = cur.parentNode;
}
return null;
};
wallDisplay?.addEventListener('click', (e) => {
const target = e.target.closest('[data-wall-cell]');
const gapTarget = e.target.closest('[data-wall-gap]');
const key = target?.dataset?.wallKey || gapTarget?.dataset?.wallKey;
const idx = Number.isInteger(selectedColorIdx) ? selectedColorIdx : 0;
const hit = findWallNode(e.target);
const key = hit?.dataset?.wallKey;
if (!key) return;
if (key) {
if (!wallState.customColors) wallState.customColors = {};
wallState.customColors[key] = idx; // always apply active color
saveWallState();
renderWall();
saveActivePatternState();
}
const activeColorIdx = getActiveWallColorIdx();
// Blindly set the color. This removes the toggle logic for diagnostics.
const newCustomColors = { ...wallState.customColors, [key]: activeColorIdx };
// Update the global state.
wallState.customColors = newCustomColors;
// Persist the new state.
saveWallState();
saveActivePatternState();
// Explicitly pass the new state to the render function.
renderWall(newCustomColors);
});
const setHoverCursor = (e) => {
const hit = e.target.closest('[data-wall-cell],[data-wall-gap]');
const hit = findWallNode(e.target);
wallDisplay.style.cursor = hit ? 'crosshair' : 'auto';
};
wallDisplay?.addEventListener('pointermove', setHoverCursor);
@ -732,10 +1014,11 @@
ensureWallGridSize(wallState.rows, wallState.cols);
wallState.colors = wallState.colors.map(row => row.map(() => -1));
wallState.customColors = {};
wallState.fillGaps = false;
wallState.showWireframes = false;
if (wallFillGapsCb) wallFillGapsCb.checked = false;
if (wallShowWireCb) wallShowWireCb.checked = false;
// Preserve outline/wireframe toggles; just clear colors.
wallState.showWireframes = wallState.showWireframes !== false;
wallState.outline = wallState.outline === true;
if (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes;
if (wallOutlineCb) wallOutlineCb.checked = wallState.outline;
saveActivePatternState();
saveWallState();
renderWall();
@ -763,20 +1046,17 @@
custom[`g-${r}-${c}`] = idx; // gap center in grid
}
}
// For X gaps between nodes
if (wallState.pattern === 'grid') {
// For X pattern gaps between nodes (skip top row and far-left column)
if (wallState.pattern === 'x') {
const maxCH = Math.max(0, cols - 1);
for (let r = 1; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) custom[`g-h-${r}-${c}`] = idx;
for (let c = 0; c < maxCH; c++) custom[`g-h-${r}-${c}`] = idx;
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) custom[`g-v-${r}-${c}`] = idx;
for (let c = 1; c < maxCH; c++) custom[`g-v-${r}-${c}`] = idx;
}
} else {
for (let r = 1; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) custom[`g-h-${r}-${c}`] = idx;
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) custom[`g-v-${r}-${c}`] = idx;
for (let c = 1; c < cols - 1; c++) custom[`f-x-${r}-${c}`] = idx;
}
}
wallState.customColors = custom;
@ -795,12 +1075,6 @@
});
wallState.customColors = filtered;
const hasColoredGaps = Object.keys(filtered).some(k => k.startsWith('g'));
if (!hasColoredGaps) {
wallState.fillGaps = false;
if (wallFillGapsCb) wallFillGapsCb.checked = false;
}
const afterCustom = Object.keys(filtered).length;
saveWallState();
renderWall();