balloonDesign/wall.js

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