1325 lines
59 KiB
JavaScript
1325 lines
59 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
|
||
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;
|
||
const used = wallUsedColors();
|
||
wallUsedPaletteEl.innerHTML = '';
|
||
if (!used.length) {
|
||
wallUsedPaletteEl.innerHTML = '<div class="text-xs text-gray-500">No colors yet.</div>';
|
||
populateWallReplaceSelects();
|
||
updateWallReplacePreview();
|
||
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)) return;
|
||
setActiveColor(normalizeColorIdx(item.idx));
|
||
renderWallPalette();
|
||
renderWallUsedPalette();
|
||
});
|
||
row.appendChild(sw);
|
||
});
|
||
wallUsedPaletteEl.appendChild(row);
|
||
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();
|
||
(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 normHex = (c.hex || '').toLowerCase();
|
||
let idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family);
|
||
if (idx < 0 && window.shared?.HEX_TO_FIRST_IDX?.has(normHex)) {
|
||
idx = window.shared.HEX_TO_FIRST_IDX.get(normHex);
|
||
}
|
||
idx = normalizeColorIdx(idx);
|
||
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);
|
||
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();
|
||
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();
|
||
}
|
||
});
|
||
|
||
})();
|