(() => { '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 wallShowWireCb = document.getElementById('wall-show-wire'); const wallOutlineCb = document.getElementById('wall-outline'); const wallClearBtn = document.getElementById('wall-clear'); const wallFillAllBtn = document.getElementById('wall-fill-all'); const wallUsedPaletteEl = document.getElementById('wall-used-palette'); 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 wallReplaceFromChip = document.getElementById('wall-replace-from-chip'); const wallReplaceToChip = document.getElementById('wall-replace-to-chip'); const wallReplaceCount = document.getElementById('wall-replace-count'); const wallToolPaintBtn = document.getElementById('wall-tool-paint'); const wallToolEraseBtn = document.getElementById('wall-tool-erase'); let wallReplaceFromIdx = null; let wallReplaceToIdx = null; const wallSpacingLabel = document.getElementById('wall-spacing-label'); const wallSizeLabel = document.getElementById('wall-size-label'); const wallPaintLinksBtn = document.getElementById('wall-paint-links'); const wallPaintSmallBtn = document.getElementById('wall-paint-small'); const wallPaintGapsBtn = document.getElementById('wall-paint-gaps'); const wallActiveChip = document.getElementById('wall-active-color-chip'); const wallActiveLabel = document.getElementById('wall-active-color-label'); const patternKey = () => (wallState.pattern === 'x' ? 'x' : 'grid'); const autoGapColorIdx = () => Number.isInteger(wallState?.activeColorIdx) && wallState.activeColorIdx >= 0 ? wallState.activeColorIdx : 0; const ensurePatternStore = () => { if (!wallState.patternStore || typeof wallState.patternStore !== 'object') wallState.patternStore = {}; ['grid', 'x'].forEach(k => { if (!wallState.patternStore[k]) { wallState.patternStore[k] = { colors: [], customColors: {}, fillGaps: false, showWireframes: false, outline: false }; } }); }; const cloneColors = (colors = []) => colors.map(row => Array.isArray(row) ? [...row] : []); function saveActivePatternState() { ensurePatternStore(); const key = patternKey(); wallState.patternStore[key] = { colors: cloneColors(wallState.colors), customColors: { ...(wallState.customColors || {}) }, showWireframes: wallState.showWireframes, outline: wallState.outline }; } function loadPatternState(key) { ensurePatternStore(); const st = wallState.patternStore[key] || {}; wallState.colors = Array.isArray(st.colors) ? cloneColors(st.colors) : []; wallState.customColors = (st.customColors && typeof st.customColors === 'object') ? { ...st.customColors } : {}; if (typeof st.showWireframes === 'boolean') wallState.showWireframes = st.showWireframes; if (typeof st.outline === 'boolean') wallState.outline = st.outline; } function wallDefaultState() { // 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: true, 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() { 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 = false; base.showWireframes = saved.showWireframes !== false; base.patternStore = (saved.patternStore && typeof saved.patternStore === 'object') ? saved.patternStore : {}; base.customColors = (saved.customColors && typeof saved.customColors === 'object') ? saved.customColors : {}; if (Number.isInteger(saved.activeColorIdx)) base.activeColorIdx = saved.activeColorIdx; if (Array.isArray(saved.colors)) base.colors = cloneColors(saved.colors); if (typeof saved.outline === 'boolean') base.outline = saved.outline; } } 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; } if (type === 'f') { // f-h/f-v/f-x if (parts[1] === 'h' || parts[1] === 'v') { const { rVal, cVal } = parseRC(parts[2], parts[3]); if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; } if (parts[1] === 'h' && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k]; else if (parts[1] === 'v' && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k]; return; } if (parts[1] === 'x') { const { rVal, cVal } = parseRC(parts[2], parts[3]); if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; } if (rVal <= 0 || cVal <= 0 || rVal >= r - 1 || cVal >= c - 1) delete wallState.customColors[k]; return; } } const { rVal, cVal } = parseRC(parts[1], parts[2]); if (!Number.isInteger(rVal) || !Number.isInteger(cVal)) { delete wallState.customColors[k]; return; } if (type === 'h' && (rVal >= r || cVal >= c - 1)) delete wallState.customColors[k]; else if (type === 'v' && (rVal >= r - 1 || cVal >= c)) delete wallState.customColors[k]; else if (type === 'g' && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k]; else if ((type === 'c' || type.startsWith('l') || type === 'f') && (rVal >= r - 1 || cVal >= c - 1)) delete wallState.customColors[k]; }); } const wallColorMeta = (idx) => { const meta = (Number.isInteger(idx) && idx >= 0 && FLAT_COLORS[idx]) ? FLAT_COLORS[idx] : { hex: WALL_FALLBACK_COLOR }; return meta; }; async function buildWallSvgPayload(forExport = false, customColorsOverride = null) { ensureFlatColors(); const customColors = customColorsOverride || wallState.customColors; 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 showOutline = !!wallState.outline; const colSpacing = spacing; const rowStep = spacing; const showGaps = false; const uniqueImages = new Set(); wallState.colors.forEach(row => row.forEach(idx => { const meta = wallColorMeta(idx); if (meta.image) uniqueImages.add(meta.image); })); Object.values(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(``); 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 = ``; const blur = ``; const offsetNode = ``; const composite = ``; const merge = ``; defs.push(`${flood}${blur}${offsetNode}${composite}${merge}`); shadowFilters.set(key, id); } 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(`${c + 1}`); } for (let r = 0; r < rows; r++) { const y = offsetY + r * rowStep; labels.push(`${r + 1}`); } const customOverride = (key) => { const raw = customColors?.[key]; const parsed = Number.isInteger(raw) ? raw : Number.parseInt(raw, 10); if (parsed === -1) return { mode: 'empty' }; if (Number.isInteger(parsed) && parsed >= 0) { return { mode: 'color', idx: normalizeColorIdx(parsed) }; } return { mode: 'auto' }; }; // Shared stroke helpers: // - Outline only when filled AND outline is enabled. // - Wireframe only when empty AND wireframes are enabled. const strokeFor = (isEmpty, { outline = '#111827', wire = '#cbd5e1' } = {}) => { if (isEmpty) return showWireframes ? wire : 'none'; return showOutline ? outline : 'none'; }; const strokeWidthFor = (isEmpty, { outline = 0.6, wire = 1.4 } = {}) => { if (isEmpty) return showWireframes ? wire : 0; return showOutline ? outline : 0; }; // 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 ``; }; 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; const invisible = isEmpty && !showWireframes; const hitFill = 'rgba(0,0,0,0.001)'; const meta = wallColorMeta(customIdx); const patId = ensurePattern(meta); const fill = invisible ? hitFill : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex)); const stroke = invisible ? 'none' : strokeFor(isEmpty); const strokeW = invisible ? 0 : strokeWidthFor(isEmpty); const filter = (isEmpty || invisible) ? '' : `filter="url(#${smallShadow})"`; const shine = isEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex); const displayIdx = isEmpty ? -1 : (customIdx ?? -1); smallNodes.push(` ${shine} `); } } // Gap 11" balloons between centers (horizontal/vertical midpoints) inside the grid (exclude only right edge for horizontals, bottom edge for verticals) for (let r = 0; r < rows; r++) { for (let c = 0; c < cols - 1; c++) { const p1 = positions.get(`${r}-${c}`); 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; const invisible = isEmpty && !showWireframes; const hitFill = 'rgba(0,0,0,0.001)'; const meta = wallColorMeta(customIdx); const patId = ensurePattern(meta); const fill = invisible ? hitFill : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex)); const stroke = invisible ? 'none' : strokeFor(isEmpty); const strokeW = invisible ? 0 : strokeWidthFor(isEmpty, { outline: 0.6, wire: 1.4 }); const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`; const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex); const displayIdx = isEmpty ? -1 : (customIdx ?? -1); bigNodes.push(` ${shine} `); } } 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; const invisible = isEmpty && !showWireframes; const hitFill = 'rgba(0,0,0,0.001)'; const meta = wallColorMeta(customIdx); const patId = ensurePattern(meta); const fill = invisible ? hitFill : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex)); const stroke = invisible ? 'none' : strokeFor(isEmpty, { outline: '#111827', wire: '#cbd5e1' }); const strokeW = invisible ? 0 : strokeWidthFor(isEmpty, { outline: 0.6, wire: 1.4 }); const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`; const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex); const displayIdx = isEmpty ? -1 : (customIdx ?? -1); bigNodes.push(` ${shine} `); } } // 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 : (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null)); const isEmpty = gapIdx === null; const hitFill = 'rgba(0,0,0,0.001)'; // Hide wireframes for 11" gap balloons; keep them clickable with a hit target. const invisible = isEmpty; const meta = wallColorMeta(gapIdx); const patId = ensurePattern(meta); const fill = invisible ? hitFill : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex)); const stroke = invisible ? 'none' : strokeFor(isEmpty); const strokeW = invisible ? 0 : strokeWidthFor(isEmpty, { outline: 0.6, wire: 1.4 }); 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); const displayIdx = isEmpty ? -1 : (gapIdx ?? -1); bigNodes.push(` ${shineGap} `); } } } 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; const invisible = centerIsEmpty && !showWireframes; const meta = wallColorMeta(centerCustomIdx); const patId = ensurePattern(meta); const fill = invisible ? 'rgba(0,0,0,0.001)' : (centerIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex)); const stroke = invisible ? 'none' : strokeFor(centerIsEmpty); const strokeW = invisible ? 0 : strokeWidthFor(centerIsEmpty, { outline: 0.6, wire: 1.4 }); const filter = centerIsEmpty || invisible ? '' : `filter="url(#${smallShadow})"`; const shine = centerIsEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex); const displayIdxCenter = centerCustomIdx ?? -1; smallNodes.push(` ${shine} `); 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; const meta = wallColorMeta(linkCustomIdx); const patId = ensurePattern(meta); const fill = linkIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex); // Wireframe shows hit area when empty; outline shows only when filled and outline enabled. const stroke = linkIsEmpty ? (showWireframes ? '#cbd5e1' : 'none') : (showOutline ? '#111827' : 'none'); const strokeW = linkIsEmpty ? (showWireframes ? 1.0 : 0) : (showOutline ? 0.9 : 0); const filter = linkIsEmpty ? '' : `filter="url(#${bigShadow})"`; const shine = linkIsEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex); const displayIdxLink = linkIsEmpty ? -1 : (linkCustomIdx ?? -1); bigNodes.push(` ${shine} `); } } } // Gap 11" balloons between centers (horizontal/vertical midpoints) across the X pattern // Keep gaps at midpoints; skip the top row and the far-left column. const maxCH = Math.max(0, cols - 1); // Diamond 5" fillers at original grid intersections (skip border) for (let r = 1; r < rows - 1; r++) { for (let c = 1; c < cols - 1; c++) { const pos = positions.get(`${r}-${c}`); if (!pos) continue; const fillerKey = `f-x-${r}-${c}`; const fillerOverride = customOverride(fillerKey); const fillerIdx = fillerOverride.mode === 'color' ? fillerOverride.idx : null; const fillerEmpty = fillerOverride.mode === 'empty' || fillerIdx === null; const fillerInvisible = fillerEmpty && !showWireframes; const fillerMeta = wallColorMeta(fillerIdx); const fillerPat = ensurePattern(fillerMeta); const fillerFill = fillerInvisible ? 'rgba(0,0,0,0.001)' : (fillerEmpty ? 'none' : (fillerPat ? `url(#${fillerPat})` : fillerMeta.hex)); const fillerStroke = fillerInvisible ? 'none' : strokeFor(fillerEmpty); const fillerStrokeW = fillerInvisible ? 0 : strokeWidthFor(fillerEmpty, { outline: 0.6, wire: 1.2 }); const fillerFilter = fillerInvisible || fillerEmpty ? '' : `filter="url(#${smallShadow})"`; const fillerShine = fillerEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, fillerMeta.hex); smallNodes.push(` ${fillerShine} `); } } for (let r = 1; r < rows - 1; r++) { for (let c = 0; c < maxCH; c++) { const p1 = positions.get(`${r}-${c}`); const p2 = positions.get(`${r}-${c+1}`); const mid = { x: (p1.x + p2.x) / 2, y: p1.y }; const key = `g-h-${r}-${c}`; const override = customOverride(key); const gapIdx = override.mode === 'color' ? override.idx : (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null)); const isEmpty = gapIdx === null; const meta = wallColorMeta(gapIdx); const patId = ensurePattern(meta); const invisible = isEmpty && !showGaps; const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex)); const stroke = invisible ? 'none' : strokeFor(isEmpty); const strokeW = invisible ? 0 : strokeWidthFor(isEmpty, { outline: 0.6, wire: 1.4 }); const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`; const rGap = bigR * 0.82; const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex); const displayIdx = isEmpty ? -1 : (gapIdx ?? -1); bigNodes.push(` ${shineGap} `); } } for (let r = 0; r < rows - 1; r++) { for (let c = 1; c < maxCH; c++) { // start at column 1 to keep far-left clear const p1 = positions.get(`${r}-${c}`); const p2 = positions.get(`${r+1}-${c}`); const mid = { x: p1.x, y: (p1.y + p2.y) / 2 }; const key = `g-v-${r}-${c}`; const override = customOverride(key); const gapIdx = override.mode === 'color' ? override.idx : (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null)); const isEmpty = gapIdx === null; const meta = wallColorMeta(gapIdx); const patId = ensurePattern(meta); const invisible = isEmpty && !showGaps; const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex)); const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none')); const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0)); const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`; const rGap = bigR * 0.82; const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex); const displayIdx = isEmpty ? -1 : (gapIdx ?? -1); bigNodes.push(` ${shineGap} `); } } } const svgString = ` ${defs.join('')} ${bigNodes.join('')} ${smallNodes.join('')} ${labels.join('')} `; return { svgString, width, height }; } function wallUsedColors() { ensureFlatColors(); if (!wallState) wallState = loadWallState(); ensureWallGridSize(wallState.rows, wallState.cols); 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; wallUsedPaletteEl.innerHTML = '
Palette opens in modal.
'; populateWallReplaceSelects(); updateWallReplacePreview(); } function populateWallReplaceSelects() { ensureFlatColors(); // "From" = only colors currently used on the wall if (wallReplaceFromSel) { wallReplaceFromSel.innerHTML = ''; const used = wallUsedColors(); used.forEach(u => { const opt = document.createElement('option'); opt.value = String(u.idx); opt.textContent = `${u.name || u.hex} (${u.count})`; wallReplaceFromSel.appendChild(opt); }); if (used.length) { const val = wallReplaceFromIdx ?? used[0].idx; wallReplaceFromSel.value = String(val); wallReplaceFromIdx = val; } } // "To" = colors from the wall palette if (wallReplaceToSel) { wallReplaceToSel.innerHTML = ''; (window.PALETTE || []).forEach(group => { (group.colors || []).forEach(c => { const idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family); if (idx < 0) return; const opt = document.createElement('option'); opt.value = String(idx); opt.textContent = c.name || c.hex; wallReplaceToSel.appendChild(opt); }); }); if (wallReplaceToSel.options.length) { const val = wallReplaceToIdx ?? parseInt(wallReplaceToSel.options[0].value, 10); wallReplaceToSel.value = String(val); wallReplaceToIdx = val; } } } const wallSetChip = (chip, meta) => { if (!chip) return; if (meta?.image) { chip.style.backgroundImage = `url("${meta.image}")`; chip.style.backgroundColor = meta.hex || WALL_FALLBACK_COLOR; chip.style.backgroundSize = `${100 * 2.5}%`; chip.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`; } else { chip.style.backgroundImage = 'none'; chip.style.backgroundColor = meta?.hex || WALL_FALLBACK_COLOR; } }; const wallCountMatches = (idx) => { if (!Number.isInteger(idx) || idx < 0) return 0; ensureWallGridSize(wallState.rows, wallState.cols); let count = 0; wallState.colors.forEach(row => row.forEach(v => { if (v === idx) count++; })); Object.values(wallState.customColors || {}).forEach(v => { if (Number.isInteger(v) && v === idx) count++; }); return count; }; const updateWallReplacePreview = () => { let fromIdx = Number.isInteger(wallReplaceFromIdx) ? wallReplaceFromIdx : parseInt(wallReplaceFromSel?.value || '-1', 10); let toIdx = Number.isInteger(wallReplaceToIdx) ? wallReplaceToIdx : parseInt(wallReplaceToSel?.value || '-1', 10); if ((!Number.isInteger(fromIdx) || fromIdx < 0) && wallReplaceFromSel?.options?.length) { fromIdx = parseInt(wallReplaceFromSel.options[0].value, 10); wallReplaceFromSel.value = String(fromIdx); wallReplaceFromIdx = fromIdx; } if ((!Number.isInteger(toIdx) || toIdx < 0) && wallReplaceToSel?.options?.length) { toIdx = parseInt(wallReplaceToSel.options[0].value, 10); wallReplaceToSel.value = String(toIdx); wallReplaceToIdx = toIdx; } wallSetChip(wallReplaceFromChip, wallColorMeta(fromIdx)); wallSetChip(wallReplaceToChip, wallColorMeta(toIdx)); const cnt = wallCountMatches(fromIdx); if (wallReplaceCount) wallReplaceCount.textContent = cnt ? `${cnt} match${cnt === 1 ? '' : 'es'}` : '0 matches'; return cnt; }; const openWallReplacePicker = (mode = 'from') => { const picker = window.openColorPicker; if (!picker) return; populateWallReplaceSelects(); if (mode === 'from') { const used = wallUsedColors(); const items = used.map(u => ({ label: u.name || u.hex, metaText: `${u.count} in wall`, idx: u.idx })); if (!items.length) { if (wallReplaceMsg) wallReplaceMsg.textContent = 'No colors on the wall yet.'; return; } picker({ title: 'Replace: From color', subtitle: 'Pick a color currently used in the wall', items, onSelect: (item) => { if (!wallReplaceFromSel) return; wallReplaceFromSel.value = String(item.idx); wallReplaceFromIdx = item.idx; updateWallReplacePreview(); } }); } else { ensureFlatColors(); const items = []; (window.PALETTE || []).forEach(group => { (group.colors || []).forEach(c => { const idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family); if (idx >= 0) { items.push({ label: c.name || c.hex, metaText: group.family || '', idx }); } }); }); if (!items.length) { if (wallReplaceMsg) wallReplaceMsg.textContent = 'No palette colors available.'; return; } picker({ title: 'Replace: To color', subtitle: 'Choose any color from the wall palette', items, onSelect: (item) => { if (!wallReplaceToSel) return; wallReplaceToSel.value = String(item.idx); wallReplaceToIdx = item.idx; updateWallReplacePreview(); } }); } }; // Pick a visible default (first reasonably saturated entry). function defaultActiveColorIdx() { if (!Array.isArray(FLAT_COLORS) || !FLAT_COLORS.length) return 0; const isTooLight = (hex = '') => { const h = hex.replace('#', ''); if (h.length !== 6) return false; const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return (r + g + b) > 640; // avoid near-white/pastel defaults }; const firstVisible = FLAT_COLORS.find(c => c?.hex && !isTooLight(c.hex)); if (firstVisible) { const idx = Number.isInteger(firstVisible._idx) ? firstVisible._idx : FLAT_COLORS.indexOf(firstVisible); if (idx >= 0) return idx; } return 0; } const normalizeColorIdx = (idx) => { const fallback = defaultActiveColorIdx(); if (!Number.isInteger(idx)) return fallback; if (idx < 0) return fallback; if (Array.isArray(FLAT_COLORS) && FLAT_COLORS.length > 0 && idx >= FLAT_COLORS.length) { return FLAT_COLORS.length - 1; } return idx; }; function setActiveColor(idx) { selectedColorIdx = normalizeColorIdx(idx); wallState.activeColorIdx = selectedColorIdx; updateWallActiveChip(selectedColorIdx); if (window.organic?.setColor) { window.organic.setColor(selectedColorIdx); } else if (window.organic?.updateCurrentColorChip) { window.organic.updateCurrentColorChip(selectedColorIdx); } 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(); updateWallActiveChip(normalized); } 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; } // Resolve current color (custom override only). function getCurrentColorIdxForKey(key) { if (!wallState) wallState = wallDefaultState(); ensureWallGridSize(wallState.rows, wallState.cols); 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; return normalizeColorIdx(parsed); } function updateWallActiveChip(idx) { if (!wallActiveChip || !wallActiveLabel) return; ensureFlatColors(); const meta = wallColorMeta(idx); if (meta.image) { wallActiveChip.style.backgroundImage = `url("${meta.image}")`; const zoom = Math.max(1, meta.imageZoom ?? 2.5); wallActiveChip.style.backgroundSize = `${100 * zoom}%`; wallActiveChip.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`; wallActiveChip.style.backgroundColor = '#fff'; } else { wallActiveChip.style.backgroundImage = 'none'; wallActiveChip.style.backgroundSize = ''; wallActiveChip.style.backgroundPosition = ''; wallActiveChip.style.backgroundColor = meta.hex || WALL_FALLBACK_COLOR; } wallActiveLabel.textContent = meta.name || meta.hex || ''; } // 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(); } async function renderWall() { if (!wallDisplay) return; ensureWallGridSize(wallState.rows, wallState.cols); if (wallGridLabel) wallGridLabel.textContent = `${wallState.cols} × ${wallState.rows}`; try { console.info('[Wall] render start'); const { svgString } = await buildWallSvgPayload(false); wallDisplay.innerHTML = svgString; // Force a reflow to ensure the browser repaints the new SVG. void wallDisplay.offsetWidth; renderWallUsedPalette(); updateWallReplacePreview(); console.info('[Wall] render done'); } catch (err) { console.error('[Wall] render failed', err?.stack || err); wallDisplay.innerHTML = `
Could not render wall.
`; } } function renderWallPalette() { if (!wallPaletteEl) return; wallPaletteEl.innerHTML = ''; populateWallReplaceSelects(); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn-dark w-full'; btn.textContent = 'Choose color'; btn.addEventListener('click', () => { if (!window.openColorPicker) return; window.openColorPicker({ title: 'Choose active wall color', subtitle: 'Applies to wall fill tools', items: (FLAT_COLORS || []).map((c, idx) => ({ label: c.name || c.hex, metaText: c.family || '', idx })), onSelect: (item) => { if (!Number.isInteger(item.idx)) return; setActiveColor(item.idx); updateWallActiveChip(getActiveWallColorIdx()); updateWallReplacePreview(); } }); }); wallPaletteEl.appendChild(btn); renderWallUsedPalette(); updateWallActiveChip(getActiveWallColorIdx()); updateWallReplacePreview(); } 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 (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes !== false; if (wallOutlineCb) wallOutlineCb.checked = !!wallState.outline; } function initWallDesigner() { if (!ensureShared()) return; ensureFlatColors(); if (!wallDisplay) return; wallState = loadWallState(); ensurePatternStore(); loadPatternState(patternKey()); if (Number.isInteger(wallState.activeColorIdx)) selectedColorIdx = normalizeColorIdx(wallState.activeColorIdx); else if (window.organic?.getColor) selectedColorIdx = normalizeColorIdx(window.organic.getColor()); else selectedColorIdx = defaultActiveColorIdx(); setActiveColor(selectedColorIdx); // Hide legacy paint/erase toggles; behavior is always click-to-paint, click-again-to-clear. if (wallToolPaintBtn) { wallToolPaintBtn.classList.add('hidden'); wallToolPaintBtn.setAttribute('aria-hidden', 'true'); wallToolPaintBtn.tabIndex = -1; } if (wallToolEraseBtn) { wallToolEraseBtn.classList.add('hidden'); wallToolEraseBtn.setAttribute('aria-hidden', 'true'); wallToolEraseBtn.tabIndex = -1; } // Hide legacy paint/erase toggles; always use click-to-paint/click-again-to-clear. if (wallToolPaintBtn) { wallToolPaintBtn.classList.add('hidden'); wallToolPaintBtn.setAttribute('aria-hidden', 'true'); wallToolPaintBtn.tabIndex = -1; } if (wallToolEraseBtn) { wallToolEraseBtn.classList.add('hidden'); wallToolEraseBtn.setAttribute('aria-hidden', 'true'); wallToolEraseBtn.tabIndex = -1; } // Allow picking active wall color by clicking the chip. if (wallActiveChip && window.openColorPicker) { wallActiveChip.style.cursor = 'pointer'; wallActiveChip.addEventListener('click', () => { window.openColorPicker({ title: 'Choose wall color', subtitle: 'Sets the active wall color', items: (FLAT_COLORS || []).map((c, idx) => ({ label: c.name || c.hex, metaText: c.family || '', idx })), onSelect: (item) => { if (!Number.isInteger(item.idx)) return; setActiveColor(item.idx); renderWallPalette(); renderWallUsedPalette(); renderWall(); } }); }); } 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(); renderWallUsedPalette(); updateWallReplacePreview(); }); wallShowWireCb?.addEventListener('change', () => { wallState.showWireframes = !!wallShowWireCb.checked; saveActivePatternState(); saveWallState(); renderWall(); }); wallOutlineCb?.addEventListener('change', () => { wallState.outline = !!wallOutlineCb.checked; saveActivePatternState(); saveWallState(); renderWall(); }); wallPaintLinksBtn?.addEventListener('click', () => paintWallGroup('links')); wallPaintSmallBtn?.addEventListener('click', () => paintWallGroup('small')); wallPaintGapsBtn?.addEventListener('click', () => paintWallGroup('gaps')); wallReplaceFromSel?.addEventListener('change', updateWallReplacePreview); wallReplaceToSel?.addEventListener('change', updateWallReplacePreview); wallReplaceFromChip?.addEventListener('click', () => openWallReplacePicker('from')); wallReplaceToChip?.addEventListener('click', () => openWallReplacePicker('to')); // Remove explicit paint/erase toggles; behavior is always click-to-paint, click-again-to-clear. const findWallNode = (el) => { let cur = el; while (cur && cur !== wallDisplay) { if (cur.dataset?.wallKey) return cur; cur = cur.parentNode; } return null; }; const setHoverCursor = (e) => { const hit = findWallNode(e.target); wallDisplay.style.cursor = hit ? 'crosshair' : 'auto'; }; wallDisplay?.addEventListener('pointermove', setHoverCursor); wallDisplay?.addEventListener('pointerleave', () => { wallDisplay.style.cursor = 'auto'; }); wallDisplay.addEventListener('click', (e) => { const hit = findWallNode(e.target); if (!hit) return; const key = hit.dataset.wallKey; if (!key) return; const activeColor = normalizeColorIdx(getActiveWallColorIdx()); if (!Number.isInteger(activeColor)) return; const datasetColor = Number.parseInt(hit.dataset.wallColor ?? '', 10); const currentColor = Number.isInteger(datasetColor) ? datasetColor : getCurrentColorIdxForKey(key); const hasCurrent = Number.isInteger(currentColor) && currentColor >= 0; if (e.altKey) { if (Number.isInteger(storedColor)) { setActiveColor(storedColor); renderWallPalette(); renderWallUsedPalette(); } return; } // Simple toggle: click paints with active; clicking again with the same active clears it. const sameAsActive = hasCurrent && currentColor === activeColor; wallState.customColors[key] = sameAsActive ? -1 : activeColor; saveActivePatternState(); saveWallState(); renderWall(); }); wallClearBtn?.addEventListener('click', () => { ensureWallGridSize(wallState.rows, wallState.cols); wallState.colors = wallState.colors.map(row => row.map(() => -1)); wallState.customColors = {}; // Preserve outline/wireframe toggles; just clear colors. wallState.showWireframes = wallState.showWireframes !== false; wallState.outline = wallState.outline === true; if (wallShowWireCb) wallShowWireCb.checked = wallState.showWireframes; if (wallOutlineCb) wallOutlineCb.checked = wallState.outline; saveActivePatternState(); saveWallState(); renderWall(); }); 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 pattern gaps between nodes (skip top row and far-left column) if (wallState.pattern === 'x') { const maxCH = Math.max(0, cols - 1); for (let r = 1; r < rows - 1; r++) { for (let c = 0; c < maxCH; c++) custom[`g-h-${r}-${c}`] = idx; } for (let r = 0; r < rows - 1; r++) { for (let c = 1; c < maxCH; c++) custom[`g-v-${r}-${c}`] = idx; } for (let r = 1; r < rows - 1; r++) { for (let c = 1; c < cols - 1; c++) custom[`f-x-${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 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 = normalizeColorIdx(Number.isInteger(wallReplaceFromIdx) ? wallReplaceFromIdx : parseInt(wallReplaceFromSel.value, 10)); const toIdx = normalizeColorIdx(Number.isInteger(wallReplaceToIdx) ? wallReplaceToIdx : parseInt(wallReplaceToSel.value, 10)); if (!Number.isInteger(fromIdx) || !Number.isInteger(toIdx) || fromIdx === toIdx) { if (wallReplaceMsg) wallReplaceMsg.textContent = 'Choose two different colors.'; return; } const matches = updateWallReplacePreview(); if (!matches) { if (wallReplaceMsg) wallReplaceMsg.textContent = 'No matches to replace.'; return; } if (matches > 120) { const ok = window.confirm(`Replace ${matches} balloons? This cannot be undone except via undo/reload.`); if (!ok) return; } ensureWallGridSize(wallState.rows, wallState.cols); let replaced = 0; wallState.colors = wallState.colors.map(row => row.map(v => { const val = Number.isInteger(v) ? v : parseInt(v, 10); if (val === fromIdx) { replaced++; return toIdx; } return v; })); Object.keys(wallState.customColors || {}).forEach(k => { const raw = wallState.customColors[k]; const val = Number.isInteger(raw) ? raw : parseInt(raw, 10); if (val === fromIdx) { wallState.customColors[k] = toIdx; replaced++; } }); // Keep pattern store in sync for this pattern saveActivePatternState(); saveWallState(); renderWall(); renderWallUsedPalette(); updateWallReplacePreview(); if (wallReplaceMsg) wallReplaceMsg.textContent = replaced ? `Replaced ${replaced} item${replaced === 1 ? '' : 's'}.` : 'Nothing to replace.'; }); } window.WallDesigner = { init: initWallDesigner, buildWallSvgPayload: buildWallSvgPayload }; document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('tab-wall') && !window.__wallInit) { window.__wallInit = true; initWallDesigner(); } }); })();