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>Balloon Size</span>
|
||||||
<span class="text-xs text-gray-500" id="wall-size-label">52 px (fixed)</span>
|
<span class="text-xs text-gray-500" id="wall-size-label">52 px (fixed)</span>
|
||||||
</div>
|
</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">
|
<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>
|
<input id="wall-show-wire" type="checkbox" class="align-middle" checked>
|
||||||
Show wireframe for empty spots
|
Show wireframe for empty spots
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -494,6 +494,15 @@
|
|||||||
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</button>
|
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</button>
|
||||||
<p class="hint w-full">Exports the current wall view.</p>
|
<p class="hint w-full">Exports the current wall view.</p>
|
||||||
</div>
|
</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">
|
<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-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>
|
<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>
|
<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="shared.js" defer></script>
|
||||||
<script src="script.js" defer></script>
|
<script src="script.js" defer></script>
|
||||||
<script src="organic.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 shareLinkOutput = document.getElementById('share-link-output');
|
||||||
const copyMessage = document.getElementById('copy-message');
|
const copyMessage = document.getElementById('copy-message');
|
||||||
const clearCanvasBtnTop = document.getElementById('clear-canvas-btn-top');
|
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
|
// messages
|
||||||
const messageModal = document.getElementById('message-modal');
|
const messageModal = document.getElementById('message-modal');
|
||||||
@ -207,6 +213,10 @@
|
|||||||
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
|
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
|
||||||
let garlandMainIdx = [0, 0, 0, 0];
|
let garlandMainIdx = [0, 0, 0, 0];
|
||||||
let garlandAccentIdx = 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
|
// History for Undo/Redo
|
||||||
const historyStack = [];
|
const historyStack = [];
|
||||||
@ -333,6 +343,13 @@
|
|||||||
y: (e.clientY - r.top) / view.s - view.ty
|
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)
|
// ====== Global shine sync (shared with Classic)
|
||||||
window.syncAppShine = function(isEnabled) {
|
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 => {
|
canvas.addEventListener('pointerdown', e => {
|
||||||
e.preventDefault();
|
if (e.pointerType === 'touch') e.preventDefault();
|
||||||
canvas.setPointerCapture?.(e.pointerId);
|
canvas.setPointerCapture?.(e.pointerId);
|
||||||
mouseInside = true;
|
mouseInside = true;
|
||||||
mousePos = getMousePos(e);
|
mousePos = getMousePos(e);
|
||||||
|
evtStats.down += 1;
|
||||||
|
evtStats.lastType = e.pointerType || '';
|
||||||
|
|
||||||
if (e.altKey || mode === 'eyedropper') {
|
if (e.altKey || mode === 'eyedropper') {
|
||||||
pickColorAt(mousePos.x, mousePos.y);
|
pickColorAt(mousePos.x, mousePos.y);
|
||||||
@ -505,8 +585,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// draw mode: add
|
// draw mode: add
|
||||||
|
pointerDown = true;
|
||||||
|
evtStats.down += 1;
|
||||||
|
evtStats.lastType = e.pointerType || '';
|
||||||
addBalloon(mousePos.x, mousePos.y);
|
addBalloon(mousePos.x, mousePos.y);
|
||||||
pointerDown = true; // track for potential continuous drawing or other gestures?
|
evtStats.addBalloon += 1;
|
||||||
|
addedThisPointer = true;
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
canvas.addEventListener('pointermove', e => {
|
canvas.addEventListener('pointermove', e => {
|
||||||
@ -561,13 +645,20 @@
|
|||||||
if (mode === 'erase') requestDraw();
|
if (mode === 'erase') requestDraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function commitGarlandPath() {
|
||||||
|
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
|
||||||
|
garlandPath = [];
|
||||||
|
requestDraw();
|
||||||
|
lastCommitMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
canvas.addEventListener('pointerup', e => {
|
canvas.addEventListener('pointerup', e => {
|
||||||
pointerDown = false;
|
pointerDown = false;
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
evtStats.up += 1;
|
||||||
|
evtStats.lastType = e.pointerType || '';
|
||||||
if (mode === 'garland') {
|
if (mode === 'garland') {
|
||||||
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
|
commitGarlandPath();
|
||||||
garlandPath = [];
|
|
||||||
requestDraw();
|
|
||||||
canvas.releasePointerCapture?.(e.pointerId);
|
canvas.releasePointerCapture?.(e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -591,11 +682,18 @@
|
|||||||
refreshAll(); // update palette/persist once after the stroke
|
refreshAll(); // update palette/persist once after the stroke
|
||||||
pushHistory();
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
if (mode === 'draw' && !addedThisPointer) {
|
||||||
|
addBalloon(mousePos.x, mousePos.y);
|
||||||
|
evtStats.addBalloon += 1;
|
||||||
|
lastAddStatus = 'balloon:up';
|
||||||
|
}
|
||||||
|
addedThisPointer = false;
|
||||||
erasingActive = false;
|
erasingActive = false;
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
eraseChanged = false;
|
eraseChanged = false;
|
||||||
marqueeActive = false;
|
marqueeActive = false;
|
||||||
canvas.releasePointerCapture?.(e.pointerId);
|
canvas.releasePointerCapture?.(e.pointerId);
|
||||||
|
requestDraw();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
canvas.addEventListener('pointerleave', () => {
|
canvas.addEventListener('pointerleave', () => {
|
||||||
@ -603,12 +701,44 @@
|
|||||||
marqueeActive = false;
|
marqueeActive = false;
|
||||||
if (mode === 'garland') {
|
if (mode === 'garland') {
|
||||||
pointerDown = false;
|
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 = [];
|
garlandPath = [];
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}
|
}
|
||||||
if (mode === 'erase') requestDraw();
|
};
|
||||||
|
window.addEventListener('pointerup', () => {
|
||||||
|
pointerDown = false;
|
||||||
|
if (mode === 'draw') addedThisPointer = false;
|
||||||
|
commitIfGarland();
|
||||||
}, { passive: true });
|
}, { 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 ======
|
// ====== Canvas & Drawing ======
|
||||||
function resizeCanvas() {
|
function resizeCanvas() {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
@ -806,6 +936,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
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);
|
new ResizeObserver(() => resizeCanvas()).observe(canvas.parentElement);
|
||||||
@ -1041,8 +1184,13 @@
|
|||||||
|
|
||||||
function addBalloon(x, y) {
|
function addBalloon(x, y) {
|
||||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
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));
|
balloons.push(buildBalloon(meta, x, y, currentRadius));
|
||||||
|
lastAddStatus = 'balloon';
|
||||||
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
|
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
pushHistory();
|
pushHistory();
|
||||||
@ -1144,10 +1292,10 @@
|
|||||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
const nodes = computeGarlandNodes(path).sort((a, b) => b.radius - a.radius); // draw larger first so small accents sit on top
|
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 available = Math.max(0, MAX_BALLOONS - balloons.length);
|
||||||
const limitedNodes = available ? nodes.slice(0, available) : [];
|
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 newIds = [];
|
||||||
const rng = makeSeededRng(garlandSeed(path) + 101);
|
const rng = makeSeededRng(garlandSeed(path) + 101);
|
||||||
const metaFromIdx = idx => {
|
const metaFromIdx = idx => {
|
||||||
@ -1170,6 +1318,7 @@
|
|||||||
balloons.push(b);
|
balloons.push(b);
|
||||||
newIds.push(b.id);
|
newIds.push(b.id);
|
||||||
});
|
});
|
||||||
|
lastAddStatus = `garland:${newIds.length}`;
|
||||||
if (newIds.length) {
|
if (newIds.length) {
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
@ -1398,7 +1547,7 @@
|
|||||||
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
|
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
|
||||||
|
|
||||||
const bounds = balloonsBounds();
|
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 width = bounds.w + pad * 2;
|
||||||
const height = bounds.h + pad * 2;
|
const height = bounds.h + pad * 2;
|
||||||
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');
|
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');
|
||||||
|
|||||||
223
script.js
223
script.js
@ -35,6 +35,17 @@
|
|||||||
// Export buttons
|
// Export buttons
|
||||||
const exportPngBtn = document.querySelector('[data-export="png"]');
|
const exportPngBtn = document.querySelector('[data-export="png"]');
|
||||||
const exportSvgBtn = document.querySelector('[data-export="svg"]');
|
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
|
// Update export buttons visibility depending on tab/design
|
||||||
window.updateExportButtonVisibility = () => {
|
window.updateExportButtonVisibility = () => {
|
||||||
@ -48,9 +59,139 @@
|
|||||||
if (isClassic) enable(exportSvgBtn, hasClassic); else if (isWall) enable(exportSvgBtn, hasWall); else enable(exportSvgBtn, true);
|
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
|
// 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) {
|
async function svgStringToPng(svgString, width, height) {
|
||||||
const { PNG_EXPORT_SCALE } = window.shared;
|
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();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
const scale = PNG_EXPORT_SCALE;
|
const scale = PNG_EXPORT_SCALE;
|
||||||
@ -67,16 +208,22 @@
|
|||||||
img.onerror = () => reject(new Error('Could not rasterize SVG.'));
|
img.onerror = () => reject(new Error('Could not rasterize SVG.'));
|
||||||
img.src = src;
|
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 {
|
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);
|
await loadSrc(dataUrl);
|
||||||
} finally {
|
} catch (e) {
|
||||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
// 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);
|
ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
|
||||||
return canvasEl.toDataURL('image/png');
|
return canvasEl.toDataURL('image/png');
|
||||||
@ -94,13 +241,15 @@
|
|||||||
try {
|
try {
|
||||||
const tab = detectCurrentTab();
|
const tab = detectCurrentTab();
|
||||||
if (tab === '#tab-classic') {
|
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);
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
||||||
window.shared.download(pngUrl, 'classic_design.png');
|
window.shared.download(pngUrl, 'classic_design.png');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tab === '#tab-wall') {
|
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);
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
||||||
window.shared.download(pngUrl, 'wall_design.png');
|
window.shared.download(pngUrl, 'wall_design.png');
|
||||||
return;
|
return;
|
||||||
@ -118,7 +267,9 @@
|
|||||||
try {
|
try {
|
||||||
const tab = detectCurrentTab();
|
const tab = detectCurrentTab();
|
||||||
if (tab === '#tab-classic') {
|
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 {
|
try {
|
||||||
const pngUrl = await svgStringToPng(svgString, width, height);
|
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}">
|
const cleanSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
@ -132,7 +283,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tab === '#tab-wall') {
|
if (tab === '#tab-wall') {
|
||||||
const { svgString } = await window.WallDesigner.buildWallSvgPayload(true);
|
const { svgString, width, height } = await buildWallSvgExportFromDom();
|
||||||
downloadSvg(svgString, 'wall_design.svg');
|
downloadSvg(svgString, 'wall_design.svg');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -186,14 +337,17 @@
|
|||||||
const wallPanel = document.getElementById('wall-controls-panel');
|
const wallPanel = document.getElementById('wall-controls-panel');
|
||||||
const currentTab = detectCurrentTab();
|
const currentTab = detectCurrentTab();
|
||||||
const panel = currentTab === '#tab-classic' ? claPanel : (currentTab === '#tab-wall' ? wallPanel : orgPanel);
|
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 isHidden = document.body?.dataset?.controlsHidden === '1';
|
||||||
|
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const stacks = Array.from(panel.querySelectorAll('.control-stack'));
|
const stacks = Array.from(panel.querySelectorAll('.control-stack'));
|
||||||
if (!stacks.length) return;
|
if (!stacks.length) return;
|
||||||
stacks.forEach(stack => {
|
stacks.forEach(stack => {
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
stack.style.display = 'none';
|
stack.style.display = 'none';
|
||||||
|
} else if (isDesktop) {
|
||||||
|
stack.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
const show = stack.dataset.mobileTab === target;
|
const show = stack.dataset.mobileTab === target;
|
||||||
stack.style.display = show ? 'block' : 'none';
|
stack.style.display = show ? 'block' : 'none';
|
||||||
@ -201,13 +355,25 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMobileTab(tab) {
|
function setMobileTab(tab, mainTabId, skipPersist = false) {
|
||||||
const name = tab || 'controls';
|
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;
|
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
||||||
if (document.body) {
|
if (document.body) {
|
||||||
document.body.dataset.mobileTab = name;
|
document.body.dataset.mobileTab = name;
|
||||||
delete document.body.dataset.controlsHidden;
|
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();
|
updateSheets();
|
||||||
updateMobileStacks(name);
|
updateMobileStacks(name);
|
||||||
const buttons = document.querySelectorAll('#mobile-tabbar .mobile-tab-btn');
|
const buttons = document.querySelectorAll('#mobile-tabbar .mobile-tab-btn');
|
||||||
@ -312,7 +478,12 @@
|
|||||||
document.getElementById('clear-canvas-btn-top')?.classList.toggle('hidden', !isOrganic);
|
document.getElementById('clear-canvas-btn-top')?.classList.toggle('hidden', !isOrganic);
|
||||||
const headerActiveSwatch = document.getElementById('current-color-chip-global')?.closest('.flex');
|
const headerActiveSwatch = document.getElementById('current-color-chip-global')?.closest('.flex');
|
||||||
headerActiveSwatch?.classList.toggle('hidden', !showHeaderColor);
|
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');
|
orgSheet?.classList.toggle('hidden', id !== '#tab-organic');
|
||||||
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
|
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
|
||||||
wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
|
wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
|
||||||
@ -328,8 +499,14 @@
|
|||||||
try { savedTab = localStorage.getItem(ACTIVE_TAB_KEY); } catch {}
|
try { savedTab = localStorage.getItem(ACTIVE_TAB_KEY); } catch {}
|
||||||
setTab(savedTab || '#tab-organic', true);
|
setTab(savedTab || '#tab-organic', true);
|
||||||
window.__whichTab = () => current;
|
window.__whichTab = () => current;
|
||||||
if (!document.body?.dataset?.mobileTab) document.body.dataset.mobileTab = 'controls';
|
if (!document.body?.dataset?.mobileTab) {
|
||||||
setMobileTab(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();
|
updateSheets();
|
||||||
updateMobileStacks(document.body.dataset.mobileTab);
|
updateMobileStacks(document.body.dataset.mobileTab);
|
||||||
}
|
}
|
||||||
@ -359,16 +536,12 @@
|
|||||||
});
|
});
|
||||||
const mq = window.matchMedia('(min-width: 1024px)');
|
const mq = window.matchMedia('(min-width: 1024px)');
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
if (mq.matches) {
|
const current = document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT;
|
||||||
document.body?.removeAttribute('data-mobile-tab');
|
setMobileTab(current, detectCurrentTab(), true);
|
||||||
updateMobileStacks('controls');
|
|
||||||
} else {
|
|
||||||
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
|
|
||||||
}
|
|
||||||
updateFloatingNudge();
|
updateFloatingNudge();
|
||||||
};
|
};
|
||||||
mq.addEventListener('change', sync);
|
mq.addEventListener('change', sync);
|
||||||
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
|
setMobileTab(document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT, detectCurrentTab(), true);
|
||||||
sync();
|
sync();
|
||||||
const nudgeToggle = document.getElementById('floating-nudge-toggle');
|
const nudgeToggle = document.getElementById('floating-nudge-toggle');
|
||||||
nudgeToggle?.addEventListener('click', () => {
|
nudgeToggle?.addEventListener('click', () => {
|
||||||
|
|||||||
530
wall.js
530
wall.js
@ -26,8 +26,8 @@
|
|||||||
const wallColsInput = document.getElementById('wall-cols');
|
const wallColsInput = document.getElementById('wall-cols');
|
||||||
const wallPatternSelect = document.getElementById('wall-pattern');
|
const wallPatternSelect = document.getElementById('wall-pattern');
|
||||||
const wallGridLabel = document.getElementById('wall-grid-label');
|
const wallGridLabel = document.getElementById('wall-grid-label');
|
||||||
const wallFillGapsCb = document.getElementById('wall-fill-gaps');
|
|
||||||
const wallShowWireCb = document.getElementById('wall-show-wire');
|
const wallShowWireCb = document.getElementById('wall-show-wire');
|
||||||
|
const wallOutlineCb = document.getElementById('wall-outline');
|
||||||
const wallClearBtn = document.getElementById('wall-clear');
|
const wallClearBtn = document.getElementById('wall-clear');
|
||||||
const wallFillAllBtn = document.getElementById('wall-fill-all');
|
const wallFillAllBtn = document.getElementById('wall-fill-all');
|
||||||
const wallUsedPaletteEl = document.getElementById('wall-used-palette');
|
const wallUsedPaletteEl = document.getElementById('wall-used-palette');
|
||||||
@ -38,14 +38,22 @@
|
|||||||
const wallReplaceMsg = document.getElementById('wall-replace-msg');
|
const wallReplaceMsg = document.getElementById('wall-replace-msg');
|
||||||
const wallSpacingLabel = document.getElementById('wall-spacing-label');
|
const wallSpacingLabel = document.getElementById('wall-spacing-label');
|
||||||
const wallSizeLabel = document.getElementById('wall-size-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 patternKey = () => (wallState.pattern === 'x' ? 'x' : 'grid');
|
||||||
|
|
||||||
|
const autoGapColorIdx = () =>
|
||||||
|
Number.isInteger(wallState?.activeColorIdx) && wallState.activeColorIdx >= 0
|
||||||
|
? wallState.activeColorIdx
|
||||||
|
: 0;
|
||||||
|
|
||||||
const ensurePatternStore = () => {
|
const ensurePatternStore = () => {
|
||||||
if (!wallState.patternStore || typeof wallState.patternStore !== 'object') wallState.patternStore = {};
|
if (!wallState.patternStore || typeof wallState.patternStore !== 'object') wallState.patternStore = {};
|
||||||
['grid', 'x'].forEach(k => {
|
['grid', 'x'].forEach(k => {
|
||||||
if (!wallState.patternStore[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] = {
|
wallState.patternStore[key] = {
|
||||||
colors: wallState.colors,
|
colors: wallState.colors,
|
||||||
customColors: wallState.customColors,
|
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) {
|
if (st.customColors && typeof st.customColors === 'object' && Object.keys(st.customColors).length) {
|
||||||
wallState.customColors = st.customColors;
|
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.showWireframes === 'boolean') wallState.showWireframes = st.showWireframes;
|
||||||
|
if (typeof st.outline === 'boolean') wallState.outline = st.outline;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wallDefaultState() {
|
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() {
|
function loadWallState() {
|
||||||
@ -88,12 +112,13 @@
|
|||||||
base.spacing = 75; // fixed
|
base.spacing = 75; // fixed
|
||||||
base.bigSize = 52; // fixed
|
base.bigSize = 52; // fixed
|
||||||
base.pattern = saved.pattern === 'x' ? 'x' : 'grid';
|
base.pattern = saved.pattern === 'x' ? 'x' : 'grid';
|
||||||
base.fillGaps = !!saved.fillGaps;
|
base.fillGaps = false;
|
||||||
base.showWireframes = saved.showWireframes !== false;
|
base.showWireframes = saved.showWireframes !== false;
|
||||||
base.patternStore = saved.patternStore && typeof saved.patternStore === 'object' ? saved.patternStore : {};
|
base.patternStore = saved.patternStore && typeof saved.patternStore === 'object' ? saved.patternStore : {};
|
||||||
base.customColors = (saved.customColors && typeof saved.customColors === 'object') ? saved.customColors : {};
|
base.customColors = (saved.customColors && typeof saved.customColors === 'object') ? saved.customColors : {};
|
||||||
if (Number.isInteger(saved.activeColorIdx)) base.activeColorIdx = saved.activeColorIdx;
|
if (Number.isInteger(saved.activeColorIdx)) base.activeColorIdx = saved.activeColorIdx;
|
||||||
if (Array.isArray(saved.colors)) base.colors = saved.colors;
|
if (Array.isArray(saved.colors)) base.colors = saved.colors;
|
||||||
|
if (typeof saved.outline === 'boolean') base.outline = saved.outline;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return base;
|
return base;
|
||||||
@ -133,17 +158,35 @@
|
|||||||
else if (parts[1] === 'v' && (rVal >= r - 1 || cVal >= c)) delete wallState.customColors[k];
|
else if (parts[1] === 'v' && (rVal >= r - 1 || cVal >= c)) delete wallState.customColors[k];
|
||||||
return;
|
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]);
|
const { rVal, cVal } = parseRC(parts[1], parts[2]);
|
||||||
if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; }
|
if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; }
|
||||||
if (type === 'h' && (rVal >= r || cVal >= c - 1)) delete wallState.customColors[k];
|
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 === '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 === '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 };
|
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 (!ensureShared()) throw new Error('Wall designer shared helpers missing.');
|
||||||
if (!wallState) wallState = loadWallState();
|
if (!wallState) wallState = loadWallState();
|
||||||
ensurePatternStore();
|
ensurePatternStore();
|
||||||
@ -174,16 +217,17 @@
|
|||||||
const offsetX = margin + labelPad;
|
const offsetX = margin + labelPad;
|
||||||
const offsetY = margin + labelPad;
|
const offsetY = margin + labelPad;
|
||||||
const showWireframes = !!wallState.showWireframes;
|
const showWireframes = !!wallState.showWireframes;
|
||||||
|
const showOutline = !!wallState.outline;
|
||||||
const colSpacing = spacing;
|
const colSpacing = spacing;
|
||||||
const rowStep = spacing;
|
const rowStep = spacing;
|
||||||
const showGaps = !!wallState.fillGaps;
|
const showGaps = false;
|
||||||
|
|
||||||
const uniqueImages = new Set();
|
const uniqueImages = new Set();
|
||||||
wallState.colors.forEach(row => row.forEach(idx => {
|
wallState.colors.forEach(row => row.forEach(idx => {
|
||||||
const meta = wallColorMeta(idx);
|
const meta = wallColorMeta(idx);
|
||||||
if (meta.image) uniqueImages.add(meta.image);
|
if (meta.image) uniqueImages.add(meta.image);
|
||||||
}));
|
}));
|
||||||
Object.values(wallState.customColors || {}).forEach(idx => {
|
Object.values(customColors || {}).forEach(idx => {
|
||||||
const meta = wallColorMeta(idx);
|
const meta = wallColorMeta(idx);
|
||||||
if (meta.image) uniqueImages.add(meta.image);
|
if (meta.image) uniqueImages.add(meta.image);
|
||||||
});
|
});
|
||||||
@ -222,6 +266,7 @@
|
|||||||
const composite = `<feComposite in="shadow" in2="SourceAlpha" operator="in" result="shadow" />`;
|
const composite = `<feComposite in="shadow" in2="SourceAlpha" operator="in" result="shadow" />`;
|
||||||
const merge = `<feMerge><feMergeNode in="shadow" /><feMergeNode in="SourceGraphic" /></feMerge>`;
|
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>`);
|
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);
|
return shadowFilters.get(key);
|
||||||
};
|
};
|
||||||
@ -260,9 +305,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customOverride = (key) => {
|
const customOverride = (key) => {
|
||||||
const val = wallState.customColors?.[key];
|
const raw = customColors?.[key];
|
||||||
if (val === -1) return { mode: 'empty' };
|
const parsed = Number.isInteger(raw) ? raw : Number.parseInt(raw, 10);
|
||||||
if (Number.isInteger(val) && val >= 0) return { mode: 'color', idx: val };
|
if (parsed === -1) return { mode: 'empty' };
|
||||||
|
if (Number.isInteger(parsed) && parsed >= 0) {
|
||||||
|
return { mode: 'color', idx: normalizeColorIdx(parsed) };
|
||||||
|
}
|
||||||
return { mode: 'auto' };
|
return { mode: 'auto' };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -286,25 +334,25 @@
|
|||||||
const override = customOverride(keyId);
|
const override = customOverride(keyId);
|
||||||
const customIdx = override.mode === 'color' ? override.idx : null;
|
const customIdx = override.mode === 'color' ? override.idx : null;
|
||||||
const isEmpty = override.mode === 'empty' || customIdx === null;
|
const isEmpty = override.mode === 'empty' || customIdx === null;
|
||||||
|
const invisible = isEmpty && !showWireframes;
|
||||||
if (isEmpty && !showWireframes) continue;
|
const hitFill = 'rgba(0,0,0,0.001)';
|
||||||
|
|
||||||
const meta = wallColorMeta(customIdx);
|
const meta = wallColorMeta(customIdx);
|
||||||
const patId = ensurePattern(meta);
|
const patId = ensurePattern(meta);
|
||||||
const fill = isEmpty ? (showWireframes ? 'none' : 'transparent') : (patId ? `url(#${patId})` : meta.hex);
|
const fill = invisible ? hitFill : (isEmpty ? hitFill : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const stroke = isEmpty ? (showWireframes ? '#cbd5e1' : 'none') : '#d1d5db';
|
const stroke = invisible ? 'none' : (isEmpty ? (showWireframes ? '#cbd5e1' : 'none') : (showOutline ? '#111827' : 'none'));
|
||||||
const strokeW = isEmpty ? (showWireframes ? 1.4 : 0) : 1.2;
|
const strokeW = invisible ? 0 : (isEmpty ? (showWireframes ? 1.4 : 0) : (showOutline ? 0.6 : 0));
|
||||||
const filter = isEmpty ? '' : `filter="url(#${smallShadow})"`;
|
const filter = (isEmpty || invisible) ? '' : `filter="url(#${smallShadow})"`;
|
||||||
const shine = isEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
|
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})">
|
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}
|
${shine}
|
||||||
</g>`);
|
</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 r = 0; r < rows; r++) {
|
||||||
for (let c = 0; c < cols - 1; c++) {
|
for (let c = 0; c < cols - 1; c++) {
|
||||||
const p1 = positions.get(`${r}-${c}`);
|
const p1 = positions.get(`${r}-${c}`);
|
||||||
@ -315,20 +363,19 @@
|
|||||||
const override = customOverride(keyId);
|
const override = customOverride(keyId);
|
||||||
const customIdx = override.mode === 'color' ? override.idx : null;
|
const customIdx = override.mode === 'color' ? override.idx : null;
|
||||||
const isEmpty = override.mode === 'empty' || customIdx === null;
|
const isEmpty = override.mode === 'empty' || customIdx === null;
|
||||||
|
const invisible = isEmpty && !showWireframes;
|
||||||
if (isEmpty && !showWireframes) continue;
|
const hitFill = 'rgba(0,0,0,0.001)';
|
||||||
|
|
||||||
const meta = wallColorMeta(customIdx);
|
const meta = wallColorMeta(customIdx);
|
||||||
const patId = ensurePattern(meta);
|
const patId = ensurePattern(meta);
|
||||||
const invisible = isEmpty && !showWireframes;
|
const fill = invisible ? hitFill : (isEmpty ? hitFill : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
|
||||||
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
|
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
|
||||||
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
|
|
||||||
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
||||||
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
|
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})">
|
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}
|
${shine}
|
||||||
</g>`);
|
</g>`);
|
||||||
}
|
}
|
||||||
@ -344,20 +391,19 @@
|
|||||||
const override = customOverride(keyId);
|
const override = customOverride(keyId);
|
||||||
const customIdx = override.mode === 'color' ? override.idx : null;
|
const customIdx = override.mode === 'color' ? override.idx : null;
|
||||||
const isEmpty = override.mode === 'empty' || customIdx === null;
|
const isEmpty = override.mode === 'empty' || customIdx === null;
|
||||||
|
const invisible = isEmpty && !showWireframes;
|
||||||
if (isEmpty && !showWireframes) continue;
|
const hitFill = 'rgba(0,0,0,0.001)';
|
||||||
|
|
||||||
const meta = wallColorMeta(customIdx);
|
const meta = wallColorMeta(customIdx);
|
||||||
const patId = ensurePattern(meta);
|
const patId = ensurePattern(meta);
|
||||||
const invisible = isEmpty && !showWireframes;
|
const fill = invisible ? hitFill : (isEmpty ? hitFill : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
|
||||||
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
|
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
|
||||||
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
|
|
||||||
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
||||||
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
|
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)">
|
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}
|
${shine}
|
||||||
</g>`);
|
</g>`);
|
||||||
}
|
}
|
||||||
@ -370,19 +416,21 @@
|
|||||||
const center = { x: (pTL.x + pBR.x) / 2, y: (pTL.y + pBR.y) / 2 };
|
const center = { x: (pTL.x + pBR.x) / 2, y: (pTL.y + pBR.y) / 2 };
|
||||||
const gapKey = `g-${r}-${c}`;
|
const gapKey = `g-${r}-${c}`;
|
||||||
const override = customOverride(gapKey);
|
const override = customOverride(gapKey);
|
||||||
const gapIdx = override.mode === 'color' ? override.idx : null;
|
const gapIdx = override.mode === 'color'
|
||||||
const isEmpty = override.mode === 'empty' || gapIdx === null;
|
? override.idx
|
||||||
|
: (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null));
|
||||||
|
const isEmpty = gapIdx === null;
|
||||||
const meta = wallColorMeta(gapIdx);
|
const meta = wallColorMeta(gapIdx);
|
||||||
const patId = ensurePattern(meta);
|
const patId = ensurePattern(meta);
|
||||||
const invisible = isEmpty && !showGaps;
|
const invisible = isEmpty && !showGaps;
|
||||||
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
|
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
|
||||||
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
|
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
|
||||||
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
||||||
const rGap = bigR * 0.82; // slightly smaller 11" gap balloon
|
const rGap = bigR * 0.82; // slightly smaller 11" gap balloon
|
||||||
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
|
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})">
|
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}
|
${shineGap}
|
||||||
</g>`);
|
</g>`);
|
||||||
}
|
}
|
||||||
@ -400,21 +448,20 @@
|
|||||||
const centerOverride = customOverride(centerKey);
|
const centerOverride = customOverride(centerKey);
|
||||||
const centerCustomIdx = centerOverride.mode === 'color' ? centerOverride.idx : null;
|
const centerCustomIdx = centerOverride.mode === 'color' ? centerOverride.idx : null;
|
||||||
const centerIsEmpty = centerOverride.mode === 'empty' || centerCustomIdx === null;
|
const centerIsEmpty = centerOverride.mode === 'empty' || centerCustomIdx === null;
|
||||||
|
const invisible = centerIsEmpty && !showWireframes;
|
||||||
|
|
||||||
if (!centerIsEmpty || showWireframes) {
|
const meta = wallColorMeta(centerCustomIdx);
|
||||||
const meta = wallColorMeta(centerCustomIdx);
|
const patId = ensurePattern(meta);
|
||||||
const patId = ensurePattern(meta);
|
const fill = invisible ? 'rgba(0,0,0,0.001)' : (centerIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const fill = centerIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex);
|
const stroke = invisible ? 'none' : (centerIsEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
|
||||||
const stroke = centerIsEmpty ? '#cbd5e1' : '#d1d5db';
|
const strokeW = invisible ? 0 : (centerIsEmpty ? 1.4 : (showOutline ? 0.6 : 0));
|
||||||
const strokeW = centerIsEmpty ? 1.4 : 1.2;
|
const filter = centerIsEmpty || invisible ? '' : `filter="url(#${smallShadow})"`;
|
||||||
const filter = centerIsEmpty ? '' : `filter="url(#${smallShadow})"`;
|
const shine = centerIsEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
|
||||||
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})">
|
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} />
|
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
|
||||||
${shine}
|
${shine}
|
||||||
</g>`);
|
</g>`);
|
||||||
}
|
|
||||||
|
|
||||||
const targets = [p1, p2, p4, p3];
|
const targets = [p1, p2, p4, p3];
|
||||||
const linkKeys = [`l1-${r}-${c}`, `l2-${r}-${c}`, `l3-${r}-${c}`, `l4-${r}-${c}`];
|
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 linkCustomIdx = linkOverride.mode === 'color' ? linkOverride.idx : null;
|
||||||
const linkIsEmpty = linkOverride.mode === 'empty' || linkCustomIdx === null;
|
const linkIsEmpty = linkOverride.mode === 'empty' || linkCustomIdx === null;
|
||||||
|
|
||||||
if (linkIsEmpty && !showWireframes) continue;
|
const invisibleLink = linkIsEmpty && !showWireframes;
|
||||||
|
|
||||||
const meta = wallColorMeta(linkCustomIdx);
|
const meta = wallColorMeta(linkCustomIdx);
|
||||||
const patId = ensurePattern(meta);
|
const patId = ensurePattern(meta);
|
||||||
const fill = linkIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex);
|
const fill = invisibleLink ? 'rgba(0,0,0,0.001)' : (linkIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const stroke = linkIsEmpty ? '#cbd5e1' : '#d1d5db';
|
// Always outline X-pattern link ovals; thicken when outline toggle is on.
|
||||||
const strokeW = linkIsEmpty ? 1.4 : 1.2;
|
const stroke = invisibleLink ? 'none' : (showOutline ? '#111827' : '#cbd5e1');
|
||||||
const filter = linkIsEmpty ? '' : `filter="url(#${bigShadow})"`;
|
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);
|
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})">
|
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}
|
${shine}
|
||||||
</g>`);
|
</g>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Gap 11" balloons between centers (horizontal/vertical midpoints) inside the grid (include outer rim)
|
|
||||||
for (let r = 0; r < rows; r++) {
|
// Gap 11" balloons between centers (horizontal/vertical midpoints) across the X pattern
|
||||||
for (let c = 0; c < cols - 1; c++) {
|
// 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 p1 = positions.get(`${r}-${c}`);
|
||||||
const p2 = positions.get(`${r}-${c+1}`);
|
const p2 = positions.get(`${r}-${c+1}`);
|
||||||
const mid = { x: (p1.x + p2.x) / 2, y: p1.y };
|
const mid = { x: (p1.x + p2.x) / 2, y: p1.y };
|
||||||
const key = `g-h-${r}-${c}`;
|
const key = `g-h-${r}-${c}`;
|
||||||
const override = customOverride(key);
|
const override = customOverride(key);
|
||||||
const gapIdx = override.mode === 'color' ? override.idx : null;
|
const gapIdx = override.mode === 'color'
|
||||||
const isEmpty = override.mode === 'empty' || gapIdx === null;
|
? override.idx
|
||||||
|
: (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null));
|
||||||
|
const isEmpty = gapIdx === null;
|
||||||
const meta = wallColorMeta(gapIdx);
|
const meta = wallColorMeta(gapIdx);
|
||||||
const patId = ensurePattern(meta);
|
const patId = ensurePattern(meta);
|
||||||
const invisible = isEmpty && !showGaps;
|
const invisible = isEmpty && !showGaps;
|
||||||
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
|
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
|
||||||
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
|
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
|
||||||
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
||||||
const rGap = bigR * 0.82;
|
const rGap = bigR * 0.82;
|
||||||
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
|
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})">
|
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}
|
${shineGap}
|
||||||
</g>`);
|
</g>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let r = 0; r < rows - 1; r++) {
|
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 p1 = positions.get(`${r}-${c}`);
|
||||||
const p2 = positions.get(`${r+1}-${c}`);
|
const p2 = positions.get(`${r+1}-${c}`);
|
||||||
const mid = { x: p1.x, y: (p1.y + p2.y) / 2 };
|
const mid = { x: p1.x, y: (p1.y + p2.y) / 2 };
|
||||||
const key = `g-v-${r}-${c}`;
|
const key = `g-v-${r}-${c}`;
|
||||||
const override = customOverride(key);
|
const override = customOverride(key);
|
||||||
const gapIdx = override.mode === 'color' ? override.idx : null;
|
const gapIdx = override.mode === 'color'
|
||||||
const isEmpty = override.mode === 'empty' || gapIdx === null;
|
? override.idx
|
||||||
|
: (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null));
|
||||||
|
const isEmpty = gapIdx === null;
|
||||||
const meta = wallColorMeta(gapIdx);
|
const meta = wallColorMeta(gapIdx);
|
||||||
const patId = ensurePattern(meta);
|
const patId = ensurePattern(meta);
|
||||||
const invisible = isEmpty && !showGaps;
|
const invisible = isEmpty && !showGaps;
|
||||||
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
|
||||||
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
|
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
|
||||||
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
|
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
|
||||||
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
|
||||||
const rGap = bigR * 0.82;
|
const rGap = bigR * 0.82;
|
||||||
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
|
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})">
|
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}
|
${shineGap}
|
||||||
</g>`);
|
</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>
|
<defs>${defs.join('')}</defs>
|
||||||
<g>${bigNodes.join('')}</g>
|
<g>${bigNodes.join('')}</g>
|
||||||
<g>${smallNodes.join('')}</g>
|
<g>${smallNodes.join('')}</g>
|
||||||
@ -545,14 +623,10 @@
|
|||||||
}
|
}
|
||||||
sw.title = `${item.name || item.hex} (${item.count})`;
|
sw.title = `${item.name || item.hex} (${item.count})`;
|
||||||
sw.addEventListener('click', () => {
|
sw.addEventListener('click', () => {
|
||||||
if (Number.isInteger(item.idx)) {
|
if (!Number.isInteger(item.idx)) return;
|
||||||
selectedColorIdx = item.idx;
|
setActiveColor(normalizeColorIdx(item.idx));
|
||||||
if (window.organic && window.organic.updateCurrentColorChip) {
|
renderWallPalette();
|
||||||
window.organic.updateCurrentColorChip(selectedColorIdx);
|
renderWallUsedPalette();
|
||||||
}
|
|
||||||
renderWallPalette();
|
|
||||||
renderWallUsedPalette();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
row.appendChild(sw);
|
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) {
|
function setActiveColor(idx) {
|
||||||
selectedColorIdx = Number.isInteger(idx) ? idx : 0;
|
selectedColorIdx = normalizeColorIdx(idx);
|
||||||
wallState.activeColorIdx = selectedColorIdx;
|
wallState.activeColorIdx = selectedColorIdx;
|
||||||
console.log('[Wall] setActiveColor', selectedColorIdx);
|
|
||||||
if (window.organic?.setColor) {
|
if (window.organic?.setColor) {
|
||||||
window.organic.setColor(selectedColorIdx);
|
window.organic.setColor(selectedColorIdx);
|
||||||
} else if (window.organic?.updateCurrentColorChip) {
|
} else if (window.organic?.updateCurrentColorChip) {
|
||||||
@ -586,6 +688,158 @@
|
|||||||
saveWallState();
|
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() {
|
async function renderWall() {
|
||||||
if (!wallDisplay) return;
|
if (!wallDisplay) return;
|
||||||
ensureWallGridSize(wallState.rows, wallState.cols);
|
ensureWallGridSize(wallState.rows, wallState.cols);
|
||||||
@ -594,6 +848,7 @@
|
|||||||
const { svgString } = await buildWallSvgPayload(false);
|
const { svgString } = await buildWallSvgPayload(false);
|
||||||
wallDisplay.innerHTML = svgString;
|
wallDisplay.innerHTML = svgString;
|
||||||
renderWallUsedPalette();
|
renderWallUsedPalette();
|
||||||
|
// paintDomFromState();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Wall] render failed', err);
|
console.error('[Wall] render failed', err);
|
||||||
wallDisplay.innerHTML = `<div class="p-4 text-sm text-red-600">Could not render wall.</div>`;
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'swatch-row';
|
row.className = 'swatch-row';
|
||||||
(group.colors || []).forEach(c => {
|
(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');
|
const sw = document.createElement('button');
|
||||||
sw.type = 'button';
|
sw.type = 'button';
|
||||||
sw.className = 'swatch';
|
sw.className = 'swatch';
|
||||||
@ -628,7 +888,7 @@
|
|||||||
if (idx === selectedColorIdx) sw.classList.add('active');
|
if (idx === selectedColorIdx) sw.classList.add('active');
|
||||||
sw.title = c.name;
|
sw.title = c.name;
|
||||||
sw.addEventListener('click', () => {
|
sw.addEventListener('click', () => {
|
||||||
setActiveColor(idx ?? 0);
|
setActiveColor(idx);
|
||||||
window.organic?.updateCurrentColorChip?.(selectedColorIdx);
|
window.organic?.updateCurrentColorChip?.(selectedColorIdx);
|
||||||
// Also update the global chip explicitly
|
// Also update the global chip explicitly
|
||||||
if (window.organic?.updateCurrentColorChip) {
|
if (window.organic?.updateCurrentColorChip) {
|
||||||
@ -652,17 +912,20 @@
|
|||||||
if (wallSizeLabel) wallSizeLabel.textContent = `${wallState.bigSize} px (fixed)`;
|
if (wallSizeLabel) wallSizeLabel.textContent = `${wallState.bigSize} px (fixed)`;
|
||||||
if (wallGridLabel) wallGridLabel.textContent = `${wallState.cols} × ${wallState.rows}`;
|
if (wallGridLabel) wallGridLabel.textContent = `${wallState.cols} × ${wallState.rows}`;
|
||||||
if (wallPatternSelect) wallPatternSelect.value = wallState.pattern || 'grid';
|
if (wallPatternSelect) wallPatternSelect.value = wallState.pattern || 'grid';
|
||||||
if (wallFillGapsCb) wallFillGapsCb.checked = !!wallState.fillGaps;
|
|
||||||
if (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes !== false;
|
if (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes !== false;
|
||||||
|
if (wallOutlineCb) wallOutlineCb.checked = !!wallState.outline;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initWallDesigner() {
|
function initWallDesigner() {
|
||||||
if (!ensureShared()) return;
|
if (!ensureShared()) return;
|
||||||
|
ensureFlatColors();
|
||||||
if (!wallDisplay) return;
|
if (!wallDisplay) return;
|
||||||
wallState = loadWallState();
|
wallState = loadWallState();
|
||||||
ensurePatternStore();
|
ensurePatternStore();
|
||||||
if (Number.isInteger(wallState.activeColorIdx)) selectedColorIdx = wallState.activeColorIdx;
|
if (Number.isInteger(wallState.activeColorIdx)) selectedColorIdx = normalizeColorIdx(wallState.activeColorIdx);
|
||||||
else if (window.organic?.getColor) selectedColorIdx = window.organic.getColor();
|
else if (window.organic?.getColor) selectedColorIdx = normalizeColorIdx(window.organic.getColor());
|
||||||
|
else selectedColorIdx = defaultActiveColorIdx();
|
||||||
|
setActiveColor(selectedColorIdx);
|
||||||
loadPatternState(patternKey());
|
loadPatternState(patternKey());
|
||||||
ensureWallGridSize(wallState.rows, wallState.cols);
|
ensureWallGridSize(wallState.rows, wallState.cols);
|
||||||
syncWallInputs();
|
syncWallInputs();
|
||||||
@ -694,35 +957,54 @@
|
|||||||
renderWall();
|
renderWall();
|
||||||
syncWallInputs();
|
syncWallInputs();
|
||||||
});
|
});
|
||||||
wallFillGapsCb?.addEventListener('change', () => {
|
|
||||||
wallState.fillGaps = !!wallFillGapsCb.checked;
|
|
||||||
saveActivePatternState();
|
|
||||||
saveWallState();
|
|
||||||
renderWall();
|
|
||||||
});
|
|
||||||
wallShowWireCb?.addEventListener('change', () => {
|
wallShowWireCb?.addEventListener('change', () => {
|
||||||
wallState.showWireframes = !!wallShowWireCb.checked;
|
wallState.showWireframes = !!wallShowWireCb.checked;
|
||||||
saveActivePatternState();
|
saveActivePatternState();
|
||||||
saveWallState();
|
saveWallState();
|
||||||
renderWall();
|
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) => {
|
wallDisplay?.addEventListener('click', (e) => {
|
||||||
const target = e.target.closest('[data-wall-cell]');
|
const hit = findWallNode(e.target);
|
||||||
const gapTarget = e.target.closest('[data-wall-gap]');
|
const key = hit?.dataset?.wallKey;
|
||||||
const key = target?.dataset?.wallKey || gapTarget?.dataset?.wallKey;
|
if (!key) return;
|
||||||
const idx = Number.isInteger(selectedColorIdx) ? selectedColorIdx : 0;
|
|
||||||
|
|
||||||
if (key) {
|
const activeColorIdx = getActiveWallColorIdx();
|
||||||
if (!wallState.customColors) wallState.customColors = {};
|
|
||||||
wallState.customColors[key] = idx; // always apply active color
|
// Blindly set the color. This removes the toggle logic for diagnostics.
|
||||||
saveWallState();
|
const newCustomColors = { ...wallState.customColors, [key]: activeColorIdx };
|
||||||
renderWall();
|
|
||||||
saveActivePatternState();
|
// 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 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.style.cursor = hit ? 'crosshair' : 'auto';
|
||||||
};
|
};
|
||||||
wallDisplay?.addEventListener('pointermove', setHoverCursor);
|
wallDisplay?.addEventListener('pointermove', setHoverCursor);
|
||||||
@ -732,10 +1014,11 @@
|
|||||||
ensureWallGridSize(wallState.rows, wallState.cols);
|
ensureWallGridSize(wallState.rows, wallState.cols);
|
||||||
wallState.colors = wallState.colors.map(row => row.map(() => -1));
|
wallState.colors = wallState.colors.map(row => row.map(() => -1));
|
||||||
wallState.customColors = {};
|
wallState.customColors = {};
|
||||||
wallState.fillGaps = false;
|
// Preserve outline/wireframe toggles; just clear colors.
|
||||||
wallState.showWireframes = false;
|
wallState.showWireframes = wallState.showWireframes !== false;
|
||||||
if (wallFillGapsCb) wallFillGapsCb.checked = false;
|
wallState.outline = wallState.outline === true;
|
||||||
if (wallShowWireCb) wallShowWireCb.checked = false;
|
if (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes;
|
||||||
|
if (wallOutlineCb) wallOutlineCb.checked = wallState.outline;
|
||||||
saveActivePatternState();
|
saveActivePatternState();
|
||||||
saveWallState();
|
saveWallState();
|
||||||
renderWall();
|
renderWall();
|
||||||
@ -763,20 +1046,17 @@
|
|||||||
custom[`g-${r}-${c}`] = idx; // gap center in grid
|
custom[`g-${r}-${c}`] = idx; // gap center in grid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For X gaps between nodes
|
// For X pattern gaps between nodes (skip top row and far-left column)
|
||||||
if (wallState.pattern === 'grid') {
|
if (wallState.pattern === 'x') {
|
||||||
|
const maxCH = Math.max(0, cols - 1);
|
||||||
for (let r = 1; r < rows - 1; r++) {
|
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 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 r = 1; r < rows - 1; r++) {
|
||||||
for (let c = 0; c < cols - 1; c++) custom[`g-h-${r}-${c}`] = idx;
|
for (let c = 1; c < cols - 1; c++) custom[`f-x-${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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wallState.customColors = custom;
|
wallState.customColors = custom;
|
||||||
@ -795,12 +1075,6 @@
|
|||||||
});
|
});
|
||||||
wallState.customColors = filtered;
|
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;
|
const afterCustom = Object.keys(filtered).length;
|
||||||
saveWallState();
|
saveWallState();
|
||||||
renderWall();
|
renderWall();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user