846 lines
37 KiB
JavaScript
846 lines
37 KiB
JavaScript
(() => {
|
||
'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();
|
||
}
|
||
});
|
||
|
||
})();
|