Checkpoint: export fixes and mobile controls
This commit is contained in:
parent
22075cadb4
commit
57423a1d88
18
index.html
18
index.html
@ -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" Nodes</button>
|
||||
<button type="button" id="wall-paint-gaps" class="btn-blue text-xs px-2 py-2">Paint 11" 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>
|
||||
|
||||
169
organic.js
169
organic.js
@ -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
223
script.js
@ -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
530
wall.js
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user