balloonDesign/wall.js

1274 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
'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
let wallToolMode = 'paint';
// 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(`<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>`);
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(`<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 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' };
};
// 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;
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 ? hitFill : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? (showWireframes ? '#cbd5e1' : 'none') : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? (showWireframes ? 1.4 : 0) : (showOutline ? 0.6 : 0));
const filter = (isEmpty || invisible) ? '' : `filter="url(#${smallShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${pos.x},${pos.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
}
}
// 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 ? hitFill : (patId ? `url(#${patId})` : meta.hex));
console.log(`h-r-c: keyId: ${keyId}, customIdx: ${customIdx}, isEmpty: ${isEmpty}, invisible: ${invisible}, fill: ${fill}, meta:`, meta);
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y})">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${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;
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 ? hitFill : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = isEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${keyId}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y}) rotate(90)">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${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
: (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 : (patId ? `url(#${patId})` : meta.hex);
const stroke = invisible || isEmpty ? 'none' : (showOutline ? '#111827' : 'none');
const strokeW = invisible || isEmpty ? 0 : (showOutline ? 0.6 : 0);
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82; // slightly smaller 11" gap balloon
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${gapKey}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${center.x},${center.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${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;
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));
console.log(`c-r-c: keyId: ${centerKey}, customIdx: ${centerCustomIdx}, isEmpty: ${centerIsEmpty}, invisible: ${invisible}, fill: ${fill}, meta:`, meta);
const stroke = invisible ? 'none' : (centerIsEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (centerIsEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const filter = centerIsEmpty || invisible ? '' : `filter="url(#${smallShadow})"`;
const shine = centerIsEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, meta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${centerKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${center.x},${center.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
const targets = [p1, p2, p4, p3];
const linkKeys = [`l1-${r}-${c}`, `l2-${r}-${c}`, `l3-${r}-${c}`, `l4-${r}-${c}`];
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 invisibleLink = linkIsEmpty && !showWireframes;
const meta = wallColorMeta(linkCustomIdx);
const patId = ensurePattern(meta);
const fill = invisibleLink ? 'rgba(0,0,0,0.001)' : (linkIsEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
console.log(`l#-r-c: keyId: ${linkKey}, customIdx: ${linkCustomIdx}, isEmpty: ${linkIsEmpty}, invisible: ${invisibleLink}, fill: ${fill}, meta:`, meta);
// Outline only when filled; light wireframe when empty and wireframes shown.
const stroke = invisibleLink ? 'none' : (linkIsEmpty ? (showWireframes ? '#cbd5e1' : 'none') : (showOutline ? '#111827' : 'none'));
const strokeW = invisibleLink ? 0 : (linkIsEmpty ? (showWireframes ? 1.2 : 0) : (showOutline ? 0.8 : 0));
const filter = invisibleLink || linkIsEmpty ? '' : `filter="url(#${bigShadow})"`;
const shine = linkIsEmpty ? '' : shineNodeRelative(linkDims.rx, linkDims.ry, meta.hex);
bigNodes.push(`<g data-wall-cell="1" data-wall-key="${linkKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${mid.x},${mid.y}) rotate(${angle})">
<ellipse cx="0" cy="0" rx="${linkDims.rx}" ry="${linkDims.ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shine}
</g>`);
}
}
}
// Gap 11" balloons between centers (horizontal/vertical midpoints) across the X pattern
// Keep gaps at midpoints; skip the top row and the far-left column.
const maxCH = Math.max(0, cols - 1);
// Diamond 5" fillers at original grid intersections (skip border)
for (let r = 1; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) {
const pos = positions.get(`${r}-${c}`);
if (!pos) continue;
const fillerKey = `f-x-${r}-${c}`;
const fillerOverride = customOverride(fillerKey);
const fillerIdx = fillerOverride.mode === 'color' ? fillerOverride.idx : null;
const fillerEmpty = fillerOverride.mode === 'empty' || fillerIdx === null;
const fillerInvisible = fillerEmpty && !showWireframes;
const fillerMeta = wallColorMeta(fillerIdx);
const fillerPat = ensurePattern(fillerMeta);
const fillerFill = fillerInvisible ? 'rgba(0,0,0,0.001)' : (fillerEmpty ? (showWireframes ? 'none' : 'rgba(0,0,0,0.001)') : (fillerPat ? `url(#${fillerPat})` : fillerMeta.hex));
const fillerStroke = fillerInvisible ? 'none' : (fillerEmpty ? (showWireframes ? '#cbd5e1' : 'none') : 'none');
const fillerStrokeW = fillerInvisible ? 0 : (fillerEmpty ? (showWireframes ? 1.2 : 0) : 0);
const fillerFilter = fillerInvisible || fillerEmpty ? '' : `filter="url(#${smallShadow})"`;
const fillerShine = fillerEmpty ? '' : shineNodeRelative(fiveInchDims.rx, fiveInchDims.ry, fillerMeta.hex);
smallNodes.push(`<g data-wall-cell="1" data-wall-key="${fillerKey}" style="cursor:pointer; pointer-events:all;" transform="translate(${pos.x},${pos.y})">
<circle cx="0" cy="0" r="${fiveInchDims.rx}" fill="${fillerFill}" stroke="${fillerStroke}" stroke-width="${fillerStrokeW}" ${fillerFilter} pointer-events="all" />
${fillerShine}
</g>`);
}
}
for (let r = 1; r < rows - 1; r++) {
for (let c = 0; c < maxCH; c++) {
const p1 = positions.get(`${r}-${c}`);
const p2 = positions.get(`${r}-${c+1}`);
const mid = { x: (p1.x + p2.x) / 2, y: p1.y };
const key = `g-h-${r}-${c}`;
const override = customOverride(key);
const gapIdx = override.mode === 'color'
? override.idx
: (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);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${key}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${mid.x},${mid.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shineGap}
</g>`);
}
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 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);
bigNodes.push(`<g data-wall-gap="1" data-wall-key="${key}" style="cursor:pointer; pointer-events:all; cursor:crosshair;" transform="translate(${mid.x},${mid.y})">
<circle cx="0" cy="0" r="${rGap}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}" ${filter} pointer-events="all" />
${shineGap}
</g>`);
}
}
}
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${width}" height="${height}" aria-label="Balloon wall">
<defs>${defs.join('')}</defs>
<g>${bigNodes.join('')}</g>
<g>${smallNodes.join('')}</g>
<g>${labels.join('')}</g>
</svg>`;
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 = '<div class="text-xs text-gray-500">Palette opens in modal.</div>';
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;
}
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 || '';
}
function setWallToolMode(mode) {
wallToolMode = mode === 'erase' ? 'erase' : 'paint';
if (wallToolPaintBtn && wallToolEraseBtn) {
const isErase = wallToolMode === 'erase';
wallToolPaintBtn.setAttribute('aria-pressed', String(!isErase));
wallToolEraseBtn.setAttribute('aria-pressed', String(isErase));
wallToolPaintBtn.classList.toggle('tab-active', !isErase);
wallToolEraseBtn.classList.toggle('tab-active', isErase);
wallToolPaintBtn.classList.toggle('tab-idle', isErase);
wallToolEraseBtn.classList.toggle('tab-idle', !isErase);
}
}
// 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 = `<div class="p-4 text-sm text-red-600">Could not render wall.</div>`;
}
}
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);
setWallToolMode('paint');
// 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'));
wallToolPaintBtn?.addEventListener('click', () => setWallToolMode('paint'));
wallToolEraseBtn?.addEventListener('click', () => setWallToolMode('erase'));
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 = getActiveWallColorIdx();
if (!Number.isInteger(activeColor)) return;
const rawStored = wallState.customColors?.[key];
const parsedStored = Number.isInteger(rawStored) ? rawStored : Number.parseInt(rawStored, 10);
const storedColor = Number.isInteger(parsedStored) && parsedStored >= 0 ? normalizeColorIdx(parsedStored) : null;
const hasStoredColor = Number.isInteger(storedColor) && storedColor >= 0;
if (e.altKey) {
if (Number.isInteger(storedColor)) {
setActiveColor(storedColor);
renderWallPalette();
renderWallUsedPalette();
}
return;
}
// Paint/erase based on tool mode; modifiers still erase.
const isEraseClick = wallToolMode === 'erase' || e.shiftKey || e.metaKey || e.ctrlKey;
wallState.customColors[key] = isEraseClick ? -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();
}
});
})();