exploded-classic #1

Merged
chris merged 15 commits from exploded-classic into main 2025-12-19 09:18:59 -05:00
7 changed files with 3284 additions and 2149 deletions
Showing only changes of commit 22075cadb4 - Show all commits

View File

@ -1103,4 +1103,69 @@ function distinctPaletteSlots(palette) {
window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null };
document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } });
// Export helper for tab-level routing
(function setupClassicExport() {
const { imageUrlToDataUrl, XLINK_NS } = window.shared || {};
if (!imageUrlToDataUrl || !XLINK_NS) return;
function getImageHref(el) { return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href'); }
function setImageHref(el, val) {
el.setAttribute('href', val);
el.setAttributeNS(XLINK_NS, 'xlink:href', val);
}
async function buildClassicSvgPayload() {
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);
let bbox = null;
try {
const temp = clonedSvg.cloneNode(true);
temp.style.position = 'absolute';
temp.style.left = '-99999px';
temp.style.top = '-99999px';
temp.style.width = '0';
temp.style.height = '0';
document.body.appendChild(temp);
const target = temp.querySelector('g') || temp;
bbox = target.getBBox();
temp.remove();
} catch {}
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 imageUrlToDataUrl(href);
if (dataUrl) setImageHref(img, dataUrl);
}));
const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number);
let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000);
if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) {
const pad = 10;
vbX = bbox.x - pad;
vbY = bbox.y - pad;
vbW = Math.max(1, bbox.width + pad * 2);
vbH = Math.max(1, bbox.height + pad * 2);
}
clonedSvg.setAttribute('width', vbW);
clonedSvg.setAttribute('height', vbH);
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827');
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '1');
if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill');
if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke');
});
const svgString = new XMLSerializer().serializeToString(clonedSvg);
return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY };
}
window.ClassicExport = { buildClassicSvgPayload };
})();
})();

View File

@ -38,6 +38,7 @@
<nav id="mode-tabs" class="flex gap-2">
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
<button type="button" class="tab-btn tab-idle" data-target="#tab-wall" aria-pressed="false">Wall</button>
</nav>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1 px-2 py-1 rounded-xl bg-white/70 border border-gray-200 shadow-sm" title="Active Color">
@ -418,6 +419,101 @@
</section>
</section>
<section id="tab-wall" class="hidden flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
<aside id="wall-controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
<div class="panel-header-row">
<h2 class="panel-title">Wall Controls</h2>
</div>
<div class="control-stack" data-mobile-tab="controls">
<div class="panel-heading">Grid</div>
<div class="panel-card grid grid-cols-2 gap-3">
<label class="text-sm font-medium flex flex-col gap-1">Columns
<input id="wall-cols" type="number" min="2" max="20" step="1" value="9" class="w-full px-2 py-1 border rounded">
</label>
<label class="text-sm font-medium flex flex-col gap-1">Rows
<input id="wall-rows" type="number" min="2" max="20" step="1" value="7" class="w-full px-2 py-1 border rounded">
</label>
<label class="text-sm font-medium flex flex-col gap-1 col-span-2">Pattern
<select id="wall-pattern" class="select">
<option value="grid">Square Grid</option>
<option value="x">X / Diamond</option>
</select>
</label>
<div class="text-sm font-medium flex flex-col gap-1 col-span-2">
<span>Spacing</span>
<span class="text-xs text-gray-500" id="wall-spacing-label">75 px (fixed)</span>
</div>
<div class="text-sm font-medium flex flex-col gap-1 col-span-2">
<span>Balloon Size</span>
<span class="text-xs text-gray-500" id="wall-size-label">52 px (fixed)</span>
</div>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-fill-gaps" type="checkbox" class="align-middle">
Fill gaps with 11" balloons
</label>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-show-wire" type="checkbox" class="align-middle" checked>
Show wireframe for empty spots
</label>
</div>
</div>
<div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading mt-4">Used Colors</div>
<div class="panel-card">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-gray-600">Click to pick. Remove unused clears empty/transparent entries.</div>
<button type="button" id="wall-remove-unused" class="btn-yellow text-xs px-2 py-1">Remove Unused</button>
</div>
<div id="wall-used-palette" class="palette-box min-h-[2.4rem]"></div>
</div>
<div class="panel-heading mt-4">Wall Palette</div>
<div class="panel-card">
<div id="wall-palette" class="palette-box min-h-[3rem]"></div>
</div>
<div class="panel-heading mt-4">Replace Colors</div>
<div class="panel-card grid grid-cols-1 gap-2">
<label class="text-sm font-medium flex flex-col gap-1">
Replace
<select id="wall-replace-from" class="select text-sm"></select>
</label>
<label class="text-sm font-medium flex flex-col gap-1">
With
<select id="wall-replace-to" class="select text-sm"></select>
</label>
<button type="button" id="wall-replace-btn" class="btn-dark text-sm">Replace</button>
<div id="wall-replace-msg" class="text-xs text-gray-500"></div>
</div>
</div>
<div class="control-stack" data-mobile-tab="save">
<div class="panel-heading">Save & Export</div>
<div class="panel-card space-y-3">
<div class="flex flex-wrap gap-3">
<button class="btn-dark bg-blue-600" data-export="png">Export PNG</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>
</div>
<div class="flex flex-wrap gap-3">
<button type="button" id="wall-clear" class="btn-danger text-sm px-3 py-2 flex-1">Clear</button>
<button type="button" id="wall-fill-all" class="btn-blue text-sm px-3 py-2 flex-1">Fill All</button>
</div>
</div>
</div>
</aside>
<section id="wall-canvas-panel"
class="order-1 lg:order-2 w-full lg:flex-1 flex flex-col items-stretch rounded-2xl overflow-hidden bg-white/50 shadow-inner ring-1 ring-black/5">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-white/70">
<div class="text-base font-semibold text-slate-700">Balloon Wall</div>
<div class="text-sm text-gray-500">Columns/Rows: <span id="wall-grid-label">9 × 7</span></div>
</div>
<div id="wall-display" class="flex-1 bg-white relative overflow-auto">
<div class="p-6 text-gray-500 text-sm">Wall designer will load here.</div>
</div>
</section>
</section>
</div>
<div id="mobile-tabbar" class="mobile-tabbar">
@ -444,8 +540,10 @@
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js" defer></script>
<script src="shared.js" defer></script>
<script src="script.js" defer></script>
<script src="organic.js" defer></script>
<script src="wall.js" defer></script>
<script src="classic.js" defer></script>
</body>

1919
organic.js Normal file

File diff suppressed because it is too large Load Diff

2267
script.js

File diff suppressed because it is too large Load Diff

211
shared.js Normal file
View File

@ -0,0 +1,211 @@
(() => {
'use strict';
// Shared helpers & palette flattening
document.addEventListener('DOMContentLoaded', () => {
const PX_PER_INCH = 4;
const SIZE_PRESETS = [24, 18, 11, 9, 5];
const TEXTURE_ZOOM_DEFAULT = 1.8;
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
const SWATCH_TEXTURE_ZOOM = 2.5;
const PNG_EXPORT_SCALE = 3;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const clamp01 = v => clamp(v, 0, 1);
const normalizeHex = h => (h || '').toLowerCase();
function hexToRgb(hex) {
const h = normalizeHex(hex).replace('#','');
if (h.length === 3) {
const r = parseInt(h[0] + h[0], 16);
const g = parseInt(h[1] + h[1], 16);
const b = parseInt(h[2] + h[2], 16);
return { r, g, b };
}
if (h.length === 6) {
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 };
}
return { r: 0, g: 0, b: 0 };
}
function luminance(hex) {
const { r, g, b } = hexToRgb(hex || '#000');
const norm = [r,g,b].map(v => {
const c = v / 255;
return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
});
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
}
function shineStyle(colorHex) {
const hex = normalizeHex(colorHex);
const isRetroWhite = hex === '#e8e3d9';
const isPureWhite = hex === '#ffffff';
const lum = luminance(hex);
if (isPureWhite || isRetroWhite) {
return { fill: 'rgba(220,220,220,0.22)', stroke: null };
}
if (lum > 0.7) {
const t = clamp01((lum - 0.7) / 0.3);
const fillAlpha = 0.08 + (0.04 - 0.08) * t;
return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null };
}
const base = 0.20;
const softened = lum > 0.4 ? base * 0.7 : base;
const finalAlpha = isRetroWhite ? softened * 0.6 : softened;
return { fill: `rgba(255,255,255,${finalAlpha})`, stroke: null };
}
const FLAT_COLORS = [];
const NAME_BY_HEX = new Map();
const HEX_TO_FIRST_IDX = new Map();
const allowedSet = new Set();
(function buildFlat() {
if (!Array.isArray(window.PALETTE)) return;
window.PALETTE.forEach(group => {
(group.colors || []).forEach(c => {
if (!c?.hex) return;
const item = { ...c, family: group.family };
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
item.imageFocus = {
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
};
item._idx = FLAT_COLORS.length;
FLAT_COLORS.push(item);
const key = (c.hex || '').toLowerCase();
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
allowedSet.add(key);
});
});
})();
const IMG_CACHE = new Map();
function getImage(path, onLoad) {
if (!path) return null;
let img = IMG_CACHE.get(path);
if (!img) {
img = new Image();
// Avoid CORS issues on file:// by only setting crossOrigin for http/https
const href = (() => { try { return new URL(path, window.location.href); } catch { return null; } })();
const isFile = href?.protocol === 'file:' || window.location.protocol === 'file:';
if (!isFile) img.crossOrigin = 'anonymous';
img.decoding = 'async';
img.loading = 'eager';
img.src = path;
if (onLoad) img.onload = onLoad;
IMG_CACHE.set(path, img);
}
return img;
}
const DATA_URL_CACHE = new Map();
const XLINK_NS = 'http://www.w3.org/1999/xlink';
const blobToDataUrl = blob => new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => resolve(r.result);
r.onerror = reject;
r.readAsDataURL(blob);
});
function imageToDataUrl(img) {
if (!img || !img.complete || img.naturalWidth === 0) return null;
// On file:// origins, drawing may be blocked; return null to fall back to original href.
if (window.location.protocol === 'file:') return null;
try {
const c = document.createElement('canvas');
c.width = img.naturalWidth;
c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0);
return c.toDataURL('image/png');
} catch (err) {
console.warn('[Export] imageToDataUrl failed:', err);
return null;
}
}
async function imageUrlToDataUrl(src) {
if (!src || src.startsWith('data:')) return src;
if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src);
const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
const urlObj = (() => { try { return new URL(abs); } catch { return null; } })();
const isFile = urlObj?.protocol === 'file:';
const cachedImg = IMG_CACHE.get(src);
const cachedUrl = imageToDataUrl(cachedImg);
if (cachedUrl) { DATA_URL_CACHE.set(src, cachedUrl); return cachedUrl; }
let dataUrl = null;
try {
if (isFile) {
// On file:// we cannot safely read pixels; return the original path.
dataUrl = src;
} else {
const resp = await fetch(abs);
if (!resp.ok) throw new Error(`Status ${resp.status}`);
dataUrl = await blobToDataUrl(await resp.blob());
}
} catch (err) {
if (!isFile) console.warn('[Export] Fetch failed for', abs, err);
dataUrl = await new Promise(resolve => {
const img = new Image();
if (!isFile) img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const c = document.createElement('canvas');
c.width = img.naturalWidth || 1;
c.height = img.naturalHeight || 1;
c.getContext('2d').drawImage(img, 0, 0);
resolve(c.toDataURL('image/png'));
} catch (e) {
console.error('[Export] Canvas fallback failed for', abs, e);
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = abs;
});
}
if (!dataUrl) dataUrl = abs;
DATA_URL_CACHE.set(src, dataUrl);
return dataUrl;
}
function download(href, suggestedFilename) {
const a = document.createElement('a');
a.href = href;
a.download = suggestedFilename || 'download';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
window.shared = {
PX_PER_INCH,
SIZE_PRESETS,
TEXTURE_ZOOM_DEFAULT,
TEXTURE_FOCUS_DEFAULT,
SWATCH_TEXTURE_ZOOM,
PNG_EXPORT_SCALE,
clamp,
clamp01,
normalizeHex,
hexToRgb,
shineStyle,
luminance,
FLAT_COLORS,
NAME_BY_HEX,
HEX_TO_FIRST_IDX,
allowedSet,
getImage,
DATA_URL_CACHE,
XLINK_NS,
blobToDataUrl,
imageToDataUrl,
imageUrlToDataUrl,
download,
};
});
})();

View File

@ -109,6 +109,8 @@ body { color: #1f2937; }
.swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; }
.family-title { font-weight:700; color:#334155; margin-top:.25rem; font-size:.9rem; letter-spacing: -0.01em; }
#wall-display { min-height: 60vh; }
#wall-display svg { width: 100%; height: 100%; display: block; }
.badge {
position:absolute;
@ -339,13 +341,17 @@ body { color: #1f2937; }
body { padding-bottom: 0; overflow: auto; }
html, body { height: auto; overflow: auto; }
/* Stack switching: show only the active mobile tab stack across panels */
.control-sheet .control-stack { display: none; }
body[data-mobile-tab="controls"] #controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #controls-panel [data-mobile-tab="colors"],
body[data-mobile-tab="save"] #controls-panel [data-mobile-tab="save"],
body[data-mobile-tab="controls"] #classic-controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #classic-controls-panel [data-mobile-tab="colors"],
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"] {
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"],
body[data-mobile-tab="controls"] #wall-controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #wall-controls-panel [data-mobile-tab="colors"],
body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"] {
display: block;
}
}

845
wall.js Normal file
View File

@ -0,0 +1,845 @@
(() => {
'use strict';
let clamp, clamp01, shineStyle, imageUrlToDataUrl, FLAT_COLORS;
function ensureShared() {
if (clamp) return true;
if (!window.shared) {
console.error('Wall.js requires shared functions from script.js');
return false;
}
({ clamp, clamp01, shineStyle, imageUrlToDataUrl, FLAT_COLORS } = window.shared);
return true;
}
const WALL_STATE_KEY = 'wallDesigner:state:v2';
const WALL_FALLBACK_COLOR = '#e5e7eb';
let wallState = null;
let selectedColorIdx = 0; // This should be synced with organic's selectedColorIdx
// DOM elements
const wallDisplay = document.getElementById('wall-display');
const wallPaletteEl = document.getElementById('wall-palette');
const wallRowsInput = document.getElementById('wall-rows');
const wallColsInput = document.getElementById('wall-cols');
const wallPatternSelect = document.getElementById('wall-pattern');
const wallGridLabel = document.getElementById('wall-grid-label');
const wallFillGapsCb = document.getElementById('wall-fill-gaps');
const wallShowWireCb = document.getElementById('wall-show-wire');
const wallClearBtn = document.getElementById('wall-clear');
const wallFillAllBtn = document.getElementById('wall-fill-all');
const wallUsedPaletteEl = document.getElementById('wall-used-palette');
const wallRemoveUnusedBtn = document.getElementById('wall-remove-unused');
const wallReplaceFromSel = document.getElementById('wall-replace-from');
const wallReplaceToSel = document.getElementById('wall-replace-to');
const wallReplaceBtn = document.getElementById('wall-replace-btn');
const wallReplaceMsg = document.getElementById('wall-replace-msg');
const wallSpacingLabel = document.getElementById('wall-spacing-label');
const wallSizeLabel = document.getElementById('wall-size-label');
const patternKey = () => (wallState.pattern === 'x' ? 'x' : 'grid');
const ensurePatternStore = () => {
if (!wallState.patternStore || typeof wallState.patternStore !== 'object') wallState.patternStore = {};
['grid', 'x'].forEach(k => {
if (!wallState.patternStore[k]) {
wallState.patternStore[k] = { colors: [], customColors: {}, fillGaps: false, showWireframes: false };
}
});
};
function saveActivePatternState() {
ensurePatternStore();
const key = patternKey();
wallState.patternStore[key] = {
colors: wallState.colors,
customColors: wallState.customColors,
fillGaps: wallState.fillGaps,
showWireframes: wallState.showWireframes
};
}
function loadPatternState(key) {
ensurePatternStore();
const st = wallState.patternStore[key] || {};
if (Array.isArray(st.colors) && st.colors.length) {
wallState.colors = st.colors;
}
if (st.customColors && typeof st.customColors === 'object' && Object.keys(st.customColors).length) {
wallState.customColors = st.customColors;
}
if (typeof st.fillGaps === 'boolean') wallState.fillGaps = st.fillGaps;
if (typeof st.showWireframes === 'boolean') wallState.showWireframes = st.showWireframes;
}
function wallDefaultState() {
return { rows: 7, cols: 9, spacing: 75, bigSize: 52, pattern: 'grid', fillGaps: false, showWireframes: false, colors: [], customColors: {}, patternStore: {}, activeColorIdx: 0 };
}
function loadWallState() {
const base = wallDefaultState();
try {
const saved = JSON.parse(localStorage.getItem(WALL_STATE_KEY));
if (saved && typeof saved === 'object') {
base.rows = clamp(saved.rows ?? base.rows, 2, 20);
base.cols = clamp(saved.cols ?? base.cols, 2, 20);
base.spacing = 75; // fixed
base.bigSize = 52; // fixed
base.pattern = saved.pattern === 'x' ? 'x' : 'grid';
base.fillGaps = !!saved.fillGaps;
base.showWireframes = saved.showWireframes !== false;
base.patternStore = saved.patternStore && typeof saved.patternStore === 'object' ? saved.patternStore : {};
base.customColors = (saved.customColors && typeof saved.customColors === 'object') ? saved.customColors : {};
if (Number.isInteger(saved.activeColorIdx)) base.activeColorIdx = saved.activeColorIdx;
if (Array.isArray(saved.colors)) base.colors = saved.colors;
}
} catch {}
return base;
}
function saveWallState() {
try { localStorage.setItem(WALL_STATE_KEY, JSON.stringify(wallState)); } catch {}
}
function ensureWallGridSize(rows, cols) {
const r = clamp(Math.round(rows || 0), 2, 20);
const c = clamp(Math.round(cols || 0), 2, 20);
wallState.rows = r;
wallState.cols = c;
const mainRows = wallState.pattern === 'grid' ? Math.max(1, r - 1) : r;
const mainCols = wallState.pattern === 'grid' ? Math.max(1, c - 1) : c;
if (!Array.isArray(wallState.colors)) wallState.colors = [];
while (wallState.colors.length < mainRows) wallState.colors.push([]);
if (wallState.colors.length > mainRows) wallState.colors.length = mainRows;
for (let i = 0; i < mainRows; i++) {
const row = wallState.colors[i] || [];
while (row.length < mainCols) row.push(-1);
if (row.length > mainCols) row.length = mainCols;
wallState.colors[i] = row;
}
if (!wallState.customColors || typeof wallState.customColors !== 'object') wallState.customColors = {};
const keys = Object.keys(wallState.customColors);
keys.forEach(k => {
const parts = k.split('-');
const type = parts[0];
const parseRC = (ri, ci) => ({ rVal: parseInt(ri, 10), cVal: parseInt(ci, 10) });
if (type === 'g' && (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 || cVal >= c - 1)) delete wallState.customColors[k];
else if (parts[1] === 'v' && (rVal >= r - 1 || cVal >= c)) delete wallState.customColors[k];
return;
}
const { rVal, cVal } = parseRC(parts[1], parts[2]);
if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; }
if (type === 'h' && (rVal >= r || cVal >= c - 1)) delete wallState.customColors[k];
else if (type === 'v' && (rVal >= r - 1 || cVal >= c)) delete wallState.customColors[k];
else if (type === 'g' && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k];
else if ((type === 'c' || type.startsWith('l')) && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k];
});
}
const wallColorMeta = (idx) => (Number.isInteger(idx) && idx >= 0 && FLAT_COLORS[idx]) ? FLAT_COLORS[idx] : { hex: WALL_FALLBACK_COLOR };
async function buildWallSvgPayload(forExport = false) {
if (!ensureShared()) throw new Error('Wall designer shared helpers missing.');
if (!wallState) wallState = loadWallState();
ensurePatternStore();
ensureWallGridSize(wallState.rows, wallState.cols);
const rows = wallState.rows;
const cols = wallState.cols;
const r11 = Math.max(12, (Number(wallState.bigSize) || 54) / 2);
const r5 = Math.max(6, Math.round(r11 * 0.42));
const isGrid = wallState.pattern === 'grid';
const isX = wallState.pattern === 'x';
const spacingBase = Number(wallState.spacing) || 75;
const spacing = clamp(
isGrid
? Math.max(2 * r11 + r5 * 0.15, spacingBase * 0.7)
: spacingBase,
30,
140
); // tighter for grid, nearly touching
const bigR = r11;
const smallR = r5;
const linkDims = { rx: bigR * 0.8, ry: bigR * 0.6 }; // Fatter oval
const fiveInchDims = { rx: smallR, ry: smallR };
const labelPad = 30;
const margin = Math.max(bigR + smallR + 18, 28);
const offsetX = margin + labelPad;
const offsetY = margin + labelPad;
const showWireframes = !!wallState.showWireframes;
const colSpacing = spacing;
const rowStep = spacing;
const showGaps = !!wallState.fillGaps;
const uniqueImages = new Set();
wallState.colors.forEach(row => row.forEach(idx => {
const meta = wallColorMeta(idx);
if (meta.image) uniqueImages.add(meta.image);
}));
Object.values(wallState.customColors || {}).forEach(idx => {
const meta = wallColorMeta(idx);
if (meta.image) uniqueImages.add(meta.image);
});
const dataUrlMap = new Map();
if (forExport && uniqueImages.size) {
await Promise.all([...uniqueImages].map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
}
const defs = [];
const patterns = new Map();
const SVG_PATTERN_ZOOM = 2.5;
const offset = (1 - SVG_PATTERN_ZOOM) / 2;
const ensurePattern = (meta) => {
if (!meta?.image) return null;
const key = `${meta.image}|${meta.hex}`;
if (patterns.has(key)) return patterns.get(key);
const href = forExport ? (dataUrlMap.get(meta.image) || null) : meta.image;
if (!href) return null;
const id = `wallp-${patterns.size}`;
patterns.set(key, id);
defs.push(`<pattern id="${id}" patternContentUnits="objectBoundingBox" width="1" height="1"><image href="${href}" x="${offset}" y="${offset}" width="${SVG_PATTERN_ZOOM}" height="${SVG_PATTERN_ZOOM}" preserveAspectRatio="xMidYMid slice" /></pattern>`);
return id;
};
const shadowFilters = new Map();
const ensureShadowFilter = (dx, dy, blurPx, alpha) => {
const key = `${dx}|${dy}|${blurPx}|${alpha}`;
if (!shadowFilters.has(key)) {
const id = `wall-shadow-${shadowFilters.size}`;
const stdDev = Math.max(0.01, blurPx * 0.5);
const clampedAlpha = clamp01(alpha);
const flood = `<feFlood flood-color="#000000" flood-opacity="${clampedAlpha}" />`;
const blur = `<feGaussianBlur in="SourceAlpha" stdDeviation="${stdDev}" result="blur" />`;
const offsetNode = `<feOffset dx="${dx}" dy="${dy}" in="blur" result="shadow" />`;
const composite = `<feComposite in="shadow" in2="SourceAlpha" operator="in" result="shadow" />`;
const merge = `<feMerge><feMergeNode in="shadow" /><feMergeNode in="SourceGraphic" /></feMerge>`;
defs.push(`<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${flood}${blur}${offsetNode}${composite}${merge}</filter>`);
}
return shadowFilters.get(key);
};
const bigShadow = ensureShadowFilter(0, 3, 8, 0.18);
const smallShadow = ensureShadowFilter(0, 2, 4, 0.14);
const shineShadow = ensureShadowFilter(0, 0, 3, 0.08);
const positions = new Map();
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const gx = offsetX + c * colSpacing;
const gy = offsetY + r * rowStep;
positions.set(`${r}-${c}`, { x: gx, y: gy });
minX = Math.min(minX, gx - bigR);
maxX = Math.max(maxX, gx + bigR);
minY = Math.min(minY, gy - bigR);
maxY = Math.max(maxY, gy + bigR);
}
}
const width = maxX + margin;
const height = maxY + margin;
const vb = `0 0 ${width} ${height}`;
const smallNodes = [];
const bigNodes = [];
const labels = [];
for (let c = 0; c < cols; c++) {
const x = offsetX + c * colSpacing;
labels.push(`<text x="${x}" y="${labelPad - 6}" text-anchor="middle" font-size="18" font-family="Inter, system-ui, -apple-system, sans-serif" fill="#111827">${c + 1}</text>`);
}
for (let r = 0; r < rows; r++) {
const y = offsetY + r * rowStep;
labels.push(`<text x="${labelPad - 8}" y="${y + 6}" text-anchor="end" font-size="18" font-family="Inter, system-ui, -apple-system, sans-serif" fill="#111827">${r + 1}</text>`);
}
const customOverride = (key) => {
const val = wallState.customColors?.[key];
if (val === -1) return { mode: 'empty' };
if (Number.isInteger(val) && val >= 0) return { mode: 'color', idx: val };
return { mode: 'auto' };
};
// Helper to create a shine ellipse with coordinates relative to (0,0)
const shineNodeRelative = (rx, ry, hex, rot = -20) => {
const shine = shineStyle(hex || WALL_FALLBACK_COLOR);
const sx_relative = -rx * 0.25;
const sy_relative = -ry * 0.25;
const shineRx = rx * 0.48;
const shineRy = ry * 0.28;
const stroke = shine.stroke ? `stroke="${shine.stroke}" stroke-width="1"` : '';
const shineFilter = shineShadow ? `filter="url(#${shineShadow})"` : '';
return `<ellipse cx="${sx_relative}" cy="${sy_relative}" rx="${shineRx}" ry="${shineRy}" fill="${shine.fill}" opacity="${shine.opacity ?? 1}" transform="rotate(${rot} ${sx_relative} ${sy_relative})"${stroke}${shineFilter} />`;
};
if (isGrid) {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const pos = positions.get(`${r}-${c}`);
const keyId = `p-${r}-${c}`;
const override = customOverride(keyId);
const customIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || customIdx === null;
if (isEmpty && !showWireframes) continue;
const meta = wallColorMeta(customIdx);
const patId = ensurePattern(meta);
const fill = isEmpty ? (showWireframes ? 'none' : 'transparent') : (patId ? `url(#${patId})` : meta.hex);
const stroke = isEmpty ? (showWireframes ? '#cbd5e1' : 'none') : '#d1d5db';
const strokeW = isEmpty ? (showWireframes ? 1.4 : 0) : 1.2;
const filter = isEmpty ? '' : `filter="url(#${smallShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${pos.x},${pos.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shine}
</g>`);
}
}
// Gap 11" balloons between centers (horizontal/vertical midpoints) inside the grid (exclude outer perimeter)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) {
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r}-${c+1}`);
const mid = { x: (p1.x + p2.x) / 2, y: p1.y };
const keyId = `h-${r}-${c}`;
const override = customOverride(keyId);
const customIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || customIdx === null;
if (isEmpty && !showWireframes) continue;
const meta = wallColorMeta(customIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showWireframes;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y})">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shine}
</g>`);
}
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols; c++) {
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r+1}-${c}`);
const mid = { x: p1.x, y: (p1.y + p2.y) / 2 };
const keyId = `v-${r}-${c}`;
const override = customOverride(keyId);
const customIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || customIdx === null;
if (isEmpty && !showWireframes) continue;
const meta = wallColorMeta(customIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showWireframes;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y}) rotate(90)">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shine}
</g>`);
}
}
// Gap 11" balloons at square centers
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const pTL = positions.get(`${r}-${c}`);
const pBR = positions.get(`${r+1}-${c+1}`);
const center = { x: (pTL.x + pBR.x) / 2, y: (pTL.y + pBR.y) / 2 };
const gapKey = `g-${r}-${c}`;
const override = customOverride(gapKey);
const gapIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || gapIdx === null;
const meta = wallColorMeta(gapIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showGaps;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82; // slightly smaller 11" gap balloon
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${gapKey}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${center.x},${center.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shineGap}
</g>`);
}
}
} else if (isX) {
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r}-${c+1}`);
const p3 = positions.get(`${r+1}-${c}`);
const p4 = positions.get(`${r+1}-${c+1}`);
const center = { x: (p1.x + p4.x) / 2, y: (p1.y + p4.y) / 2 };
const centerKey = `c-${r}-${c}`;
const centerOverride = customOverride(centerKey);
const centerCustomIdx = centerOverride.mode === 'color' ? centerOverride.idx : null;
const centerIsEmpty = centerOverride.mode === 'empty' || centerCustomIdx === null;
if (!centerIsEmpty || showWireframes) {
const meta = wallColorMeta(centerCustomIdx);
const patId = ensurePattern(meta);
const fill = centerIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex);
const stroke = centerIsEmpty ? '#cbd5e1' : '#d1d5db';
const strokeW = centerIsEmpty ? 1.4 : 1.2;
const filter = centerIsEmpty ? '' : `filter="url(#${smallShadow})"`;
const shine = centerIsEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${centerKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${center.x},${center.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shine}
</g>`);
}
const targets = [p1, p2, p4, p3];
const linkKeys = [`l1-${r}-${c}`, `l2-${r}-${c}`, `l3-${r}-${c}`, `l4-${r}-${c}`];
for (let i = 0; i < 4; i++) {
const target = targets[i];
const mid = { x: (center.x + target.x) / 2, y: (center.y + target.y) / 2 };
const angle = Math.atan2(target.y - center.y, target.x - center.x) * 180 / Math.PI;
const linkKey = linkKeys[i];
const linkOverride = customOverride(linkKey);
const linkCustomIdx = linkOverride.mode === 'color' ? linkOverride.idx : null;
const linkIsEmpty = linkOverride.mode === 'empty' || linkCustomIdx === null;
if (linkIsEmpty && !showWireframes) continue;
const meta = wallColorMeta(linkCustomIdx);
const patId = ensurePattern(meta);
const fill = linkIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex);
const stroke = linkIsEmpty ? '#cbd5e1' : '#d1d5db';
const strokeW = linkIsEmpty ? 1.4 : 1.2;
const filter = linkIsEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = linkIsEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${linkKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y}) rotate(${angle})">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shine}
</g>`);
}
}
}
// Gap 11" balloons between centers (horizontal/vertical midpoints) inside the grid (include outer rim)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) {
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r}-${c+1}`);
const mid = { x: (p1.x + p2.x) / 2, y: p1.y };
const key = `g-h-${r}-${c}`;
const override = customOverride(key);
const gapIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || gapIdx === null;
const meta = wallColorMeta(gapIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showGaps;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82;
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${key}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${mid.x},${mid.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shineGap}
</g>`);
}
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols; c++) {
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r+1}-${c}`);
const mid = { x: p1.x, y: (p1.y + p2.y) / 2 };
const key = `g-v-${r}-${c}`;
const override = customOverride(key);
const gapIdx = override.mode === 'color' ? override.idx : null;
const isEmpty = override.mode === 'empty' || gapIdx === null;
const meta = wallColorMeta(gapIdx);
const patId = ensurePattern(meta);
const invisible = isEmpty && !showGaps;
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : '#d1d5db');
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : 1.2);
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82;
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${key}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${mid.x},${mid.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} />
${shineGap}
</g>`);
}
}
}
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb}" width="${width}" height="${height}" aria-label="Balloon wall">
<defs>${defs.join('')}</defs>
<g>${bigNodes.join('')}</g>
<g>${smallNodes.join('')}</g>
<g>${labels.join('')}</g>
</svg>`;
return { svgString, width, height };
}
function wallUsedColors() {
const map = new Map();
const addIdx = (idx) => {
if (!Number.isInteger(idx) || idx < 0 || !FLAT_COLORS[idx]) return;
const meta = FLAT_COLORS[idx];
const key = idx;
const entry = map.get(key) || { idx, hex: meta.hex, image: meta.image, name: meta.name, count: 0 };
entry.count += 1;
map.set(key, entry);
};
wallState.colors.forEach(row => row.forEach(addIdx));
Object.values(wallState.customColors || {}).forEach(addIdx);
return Array.from(map.values()).sort((a, b) => b.count - a.count);
}
function renderWallUsedPalette() {
if (!wallUsedPaletteEl) return;
const used = wallUsedColors();
wallUsedPaletteEl.innerHTML = '';
if (!used.length) {
wallUsedPaletteEl.innerHTML = '<div class="text-xs text-gray-500">No colors yet.</div>';
return;
}
const row = document.createElement('div');
row.className = 'swatch-row';
used.forEach(item => {
const sw = document.createElement('button');
sw.type = 'button';
sw.className = 'swatch';
if (item.image) {
sw.style.backgroundImage = `url("${item.image}")`;
sw.style.backgroundSize = `${100 * 2.5}%`;
} else {
sw.style.backgroundColor = item.hex;
}
sw.title = `${item.name || item.hex} (${item.count})`;
sw.addEventListener('click', () => {
if (Number.isInteger(item.idx)) {
selectedColorIdx = item.idx;
if (window.organic && window.organic.updateCurrentColorChip) {
window.organic.updateCurrentColorChip(selectedColorIdx);
}
renderWallPalette();
renderWallUsedPalette();
}
});
row.appendChild(sw);
});
wallUsedPaletteEl.appendChild(row);
}
function populateWallReplaceSelects() {
const sels = [wallReplaceFromSel, wallReplaceToSel];
sels.forEach(sel => {
if (!sel) return;
sel.innerHTML = '';
FLAT_COLORS.forEach((c, idx) => {
const opt = document.createElement('option');
opt.value = String(idx);
opt.textContent = c.name || c.hex;
opt.style.backgroundColor = c.hex;
sel.appendChild(opt);
});
});
}
function setActiveColor(idx) {
selectedColorIdx = Number.isInteger(idx) ? idx : 0;
wallState.activeColorIdx = selectedColorIdx;
console.log('[Wall] setActiveColor', selectedColorIdx);
if (window.organic?.setColor) {
window.organic.setColor(selectedColorIdx);
} else if (window.organic?.updateCurrentColorChip) {
window.organic.updateCurrentColorChip(selectedColorIdx);
}
saveWallState();
}
async function renderWall() {
if (!wallDisplay) return;
ensureWallGridSize(wallState.rows, wallState.cols);
if (wallGridLabel) wallGridLabel.textContent = `${wallState.cols} × ${wallState.rows}`;
try {
const { svgString } = await buildWallSvgPayload(false);
wallDisplay.innerHTML = svgString;
renderWallUsedPalette();
} catch (err) {
console.error('[Wall] render failed', err);
wallDisplay.innerHTML = `<div class="p-4 text-sm text-red-600">Could not render wall.</div>`;
}
}
function renderWallPalette() {
if (!wallPaletteEl) return;
wallPaletteEl.innerHTML = '';
populateWallReplaceSelects();
(window.PALETTE || []).forEach(group => {
const title = document.createElement('div');
title.className = 'family-title';
title.textContent = group.family;
wallPaletteEl.appendChild(title);
const row = document.createElement('div');
row.className = 'swatch-row';
(group.colors || []).forEach(c => {
const idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family);
const sw = document.createElement('button');
sw.type = 'button';
sw.className = 'swatch';
if (c.image) {
const meta = FLAT_COLORS[idx] || {};
sw.style.backgroundImage = `url("${c.image}")`;
sw.style.backgroundSize = `${100 * 2.5}%`;
sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
} else {
sw.style.backgroundColor = c.hex;
}
if (idx === selectedColorIdx) sw.classList.add('active');
sw.title = c.name;
sw.addEventListener('click', () => {
setActiveColor(idx ?? 0);
window.organic?.updateCurrentColorChip?.(selectedColorIdx);
// Also update the global chip explicitly
if (window.organic?.updateCurrentColorChip) {
window.organic.updateCurrentColorChip(selectedColorIdx);
}
renderWallPalette();
});
row.appendChild(sw);
});
wallPaletteEl.appendChild(row);
});
renderWallUsedPalette();
}
function syncWallInputs() {
if (!wallState) wallState = wallDefaultState();
ensureWallGridSize(wallState.rows, wallState.cols);
if (wallRowsInput) wallRowsInput.value = wallState.rows;
if (wallColsInput) wallColsInput.value = wallState.cols;
if (wallSpacingLabel) wallSpacingLabel.textContent = `${wallState.spacing} px (fixed)`;
if (wallSizeLabel) wallSizeLabel.textContent = `${wallState.bigSize} px (fixed)`;
if (wallGridLabel) wallGridLabel.textContent = `${wallState.cols} × ${wallState.rows}`;
if (wallPatternSelect) wallPatternSelect.value = wallState.pattern || 'grid';
if (wallFillGapsCb) wallFillGapsCb.checked = !!wallState.fillGaps;
if (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes !== false;
}
function initWallDesigner() {
if (!ensureShared()) return;
if (!wallDisplay) return;
wallState = loadWallState();
ensurePatternStore();
if (Number.isInteger(wallState.activeColorIdx)) selectedColorIdx = wallState.activeColorIdx;
else if (window.organic?.getColor) selectedColorIdx = window.organic.getColor();
loadPatternState(patternKey());
ensureWallGridSize(wallState.rows, wallState.cols);
syncWallInputs();
renderWallPalette();
renderWall();
saveActivePatternState();
saveWallState();
wallRowsInput?.addEventListener('change', () => {
const rows = clamp(parseInt(wallRowsInput?.value || '0', 10) || wallState.rows, 2, 20);
ensureWallGridSize(rows, wallState.cols);
saveWallState();
syncWallInputs();
renderWall();
});
wallColsInput?.addEventListener('change',() => {
const cols = clamp(parseInt(wallColsInput?.value || '0', 10) || wallState.cols, 2, 20);
ensureWallGridSize(wallState.rows, cols);
saveWallState();
syncWallInputs();
renderWall();
});
wallPatternSelect?.addEventListener('change', () => {
saveActivePatternState();
wallState.pattern = wallPatternSelect.value === 'x' ? 'x' : 'grid';
loadPatternState(patternKey());
ensureWallGridSize(wallState.rows, wallState.cols);
saveWallState();
renderWall();
syncWallInputs();
});
wallFillGapsCb?.addEventListener('change', () => {
wallState.fillGaps = !!wallFillGapsCb.checked;
saveActivePatternState();
saveWallState();
renderWall();
});
wallShowWireCb?.addEventListener('change', () => {
wallState.showWireframes = !!wallShowWireCb.checked;
saveActivePatternState();
saveWallState();
renderWall();
});
wallDisplay?.addEventListener('click', (e) => {
const target = e.target.closest('[data-wall-cell]');
const gapTarget = e.target.closest('[data-wall-gap]');
const key = target?.dataset?.wallKey || gapTarget?.dataset?.wallKey;
const idx = Number.isInteger(selectedColorIdx) ? selectedColorIdx : 0;
if (key) {
if (!wallState.customColors) wallState.customColors = {};
wallState.customColors[key] = idx; // always apply active color
saveWallState();
renderWall();
saveActivePatternState();
}
});
const setHoverCursor = (e) => {
const hit = e.target.closest('[data-wall-cell],[data-wall-gap]');
wallDisplay.style.cursor = hit ? 'crosshair' : 'auto';
};
wallDisplay?.addEventListener('pointermove', setHoverCursor);
wallDisplay?.addEventListener('pointerleave', () => { wallDisplay.style.cursor = 'auto'; });
wallClearBtn?.addEventListener('click', () => {
ensureWallGridSize(wallState.rows, wallState.cols);
wallState.colors = wallState.colors.map(row => row.map(() => -1));
wallState.customColors = {};
wallState.fillGaps = false;
wallState.showWireframes = false;
if (wallFillGapsCb) wallFillGapsCb.checked = false;
if (wallShowWireCb) wallShowWireCb.checked = false;
saveActivePatternState();
saveWallState();
renderWall();
});
wallFillAllBtn?.addEventListener('click', () => {
if (window.organic?.getColor) setActiveColor(window.organic.getColor());
const idx = Number.isInteger(selectedColorIdx) ? selectedColorIdx : 0;
ensureWallGridSize(wallState.rows, wallState.cols);
wallState.colors = wallState.colors.map(row => row.map(() => idx));
const custom = {};
const rows = wallState.rows;
const cols = wallState.cols;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) custom[`h-${r}-${c}`] = idx;
for (let c = 0; c < cols; c++) custom[`p-${r}-${c}`] = idx;
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols; c++) custom[`v-${r}-${c}`] = idx;
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
custom[`c-${r}-${c}`] = idx;
['l1', 'l2', 'l3', 'l4'].forEach(l => custom[`${l}-${r}-${c}`] = idx);
custom[`g-${r}-${c}`] = idx; // gap center in grid
}
}
// For X gaps between nodes
if (wallState.pattern === 'grid') {
for (let r = 1; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) custom[`g-h-${r}-${c}`] = idx;
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) custom[`g-v-${r}-${c}`] = idx;
}
} else {
for (let r = 1; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) custom[`g-h-${r}-${c}`] = idx;
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) custom[`g-v-${r}-${c}`] = idx;
}
}
wallState.customColors = custom;
saveActivePatternState();
saveWallState();
renderWall();
});
wallRemoveUnusedBtn?.addEventListener('click', () => {
ensureWallGridSize(wallState.rows, wallState.cols);
const beforeCustom = Object.keys(wallState.customColors || {}).length;
const filtered = {};
Object.entries(wallState.customColors || {}).forEach(([k, v]) => {
if (Number.isInteger(v) && v >= 0) filtered[k] = v;
});
wallState.customColors = filtered;
const hasColoredGaps = Object.keys(filtered).some(k => k.startsWith('g'));
if (!hasColoredGaps) {
wallState.fillGaps = false;
if (wallFillGapsCb) wallFillGapsCb.checked = false;
}
const afterCustom = Object.keys(filtered).length;
saveWallState();
renderWall();
if (wallReplaceMsg) {
const changed = (beforeCustom !== afterCustom);
wallReplaceMsg.textContent = changed ? 'Removed unused.' : 'Nothing to remove.';
}
saveActivePatternState();
});
wallReplaceBtn?.addEventListener('click', () => {
if (!wallReplaceFromSel || !wallReplaceToSel) return;
const fromIdx = parseInt(wallReplaceFromSel.value, 10);
const toIdx = parseInt(wallReplaceToSel.value, 10);
if (!Number.isInteger(fromIdx) || !Number.isInteger(toIdx) || fromIdx === toIdx) {
if (wallReplaceMsg) wallReplaceMsg.textContent = 'Choose two different colors.';
return;
}
ensureWallGridSize(wallState.rows, wallState.cols);
wallState.colors = wallState.colors.map(row => row.map(v => (v === fromIdx ? toIdx : v)));
Object.keys(wallState.customColors || {}).forEach(k => {
if (wallState.customColors[k] === fromIdx) wallState.customColors[k] = toIdx;
});
saveWallState();
renderWall();
if (wallReplaceMsg) wallReplaceMsg.textContent = 'Replaced.';
});
}
window.WallDesigner = {
init: initWallDesigner,
buildWallSvgPayload: buildWallSvgPayload
};
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('tab-wall') && !window.__wallInit) {
window.__wallInit = true;
initWallDesigner();
}
});
})();