exploded-classic #1
65
classic.js
65
classic.js
@ -1103,4 +1103,69 @@ function distinctPaletteSlots(palette) {
|
||||
|
||||
window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null };
|
||||
document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } });
|
||||
|
||||
// Export helper for tab-level routing
|
||||
(function setupClassicExport() {
|
||||
const { imageUrlToDataUrl, XLINK_NS } = window.shared || {};
|
||||
if (!imageUrlToDataUrl || !XLINK_NS) return;
|
||||
function getImageHref(el) { return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href'); }
|
||||
function setImageHref(el, val) {
|
||||
el.setAttribute('href', val);
|
||||
el.setAttributeNS(XLINK_NS, 'xlink:href', val);
|
||||
}
|
||||
async function buildClassicSvgPayload() {
|
||||
const svgElement = document.querySelector('#classic-display svg');
|
||||
if (!svgElement) throw new Error('Classic design not found. Please create a design first.');
|
||||
const clonedSvg = svgElement.cloneNode(true);
|
||||
let bbox = null;
|
||||
try {
|
||||
const temp = clonedSvg.cloneNode(true);
|
||||
temp.style.position = 'absolute';
|
||||
temp.style.left = '-99999px';
|
||||
temp.style.top = '-99999px';
|
||||
temp.style.width = '0';
|
||||
temp.style.height = '0';
|
||||
document.body.appendChild(temp);
|
||||
const target = temp.querySelector('g') || temp;
|
||||
bbox = target.getBBox();
|
||||
temp.remove();
|
||||
} catch {}
|
||||
|
||||
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
|
||||
await Promise.all(allImages.map(async img => {
|
||||
const href = getImageHref(img);
|
||||
if (!href || href.startsWith('data:')) return;
|
||||
const dataUrl = await imageUrlToDataUrl(href);
|
||||
if (dataUrl) setImageHref(img, dataUrl);
|
||||
}));
|
||||
|
||||
const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number);
|
||||
let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
|
||||
let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
|
||||
let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
|
||||
let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000);
|
||||
if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) {
|
||||
const pad = 10;
|
||||
vbX = bbox.x - pad;
|
||||
vbY = bbox.y - pad;
|
||||
vbW = Math.max(1, bbox.width + pad * 2);
|
||||
vbH = Math.max(1, bbox.height + pad * 2);
|
||||
}
|
||||
clonedSvg.setAttribute('width', vbW);
|
||||
clonedSvg.setAttribute('height', vbH);
|
||||
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
|
||||
|
||||
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
|
||||
if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827');
|
||||
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '1');
|
||||
if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill');
|
||||
if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke');
|
||||
});
|
||||
|
||||
const svgString = new XMLSerializer().serializeToString(clonedSvg);
|
||||
return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY };
|
||||
}
|
||||
window.ClassicExport = { buildClassicSvgPayload };
|
||||
})();
|
||||
})();
|
||||
|
||||
100
index.html
100
index.html
@ -38,6 +38,7 @@
|
||||
<nav id="mode-tabs" class="flex gap-2">
|
||||
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
|
||||
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
|
||||
<button type="button" class="tab-btn tab-idle" data-target="#tab-wall" aria-pressed="false">Wall</button>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1 px-2 py-1 rounded-xl bg-white/70 border border-gray-200 shadow-sm" title="Active Color">
|
||||
@ -418,6 +419,101 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="tab-wall" class="hidden flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
|
||||
<aside id="wall-controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
|
||||
<div class="panel-header-row">
|
||||
<h2 class="panel-title">Wall Controls</h2>
|
||||
</div>
|
||||
<div class="control-stack" data-mobile-tab="controls">
|
||||
<div class="panel-heading">Grid</div>
|
||||
<div class="panel-card grid grid-cols-2 gap-3">
|
||||
<label class="text-sm font-medium flex flex-col gap-1">Columns
|
||||
<input id="wall-cols" type="number" min="2" max="20" step="1" value="9" class="w-full px-2 py-1 border rounded">
|
||||
</label>
|
||||
<label class="text-sm font-medium flex flex-col gap-1">Rows
|
||||
<input id="wall-rows" type="number" min="2" max="20" step="1" value="7" class="w-full px-2 py-1 border rounded">
|
||||
</label>
|
||||
<label class="text-sm font-medium flex flex-col gap-1 col-span-2">Pattern
|
||||
<select id="wall-pattern" class="select">
|
||||
<option value="grid">Square Grid</option>
|
||||
<option value="x">X / Diamond</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="text-sm font-medium flex flex-col gap-1 col-span-2">
|
||||
<span>Spacing</span>
|
||||
<span class="text-xs text-gray-500" id="wall-spacing-label">75 px (fixed)</span>
|
||||
</div>
|
||||
<div class="text-sm font-medium flex flex-col gap-1 col-span-2">
|
||||
<span>Balloon Size</span>
|
||||
<span class="text-xs text-gray-500" id="wall-size-label">52 px (fixed)</span>
|
||||
</div>
|
||||
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
|
||||
<input id="wall-fill-gaps" type="checkbox" class="align-middle">
|
||||
Fill gaps with 11" balloons
|
||||
</label>
|
||||
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
|
||||
<input id="wall-show-wire" type="checkbox" class="align-middle" checked>
|
||||
Show wireframe for empty spots
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-stack" data-mobile-tab="colors">
|
||||
<div class="panel-heading mt-4">Used Colors</div>
|
||||
<div class="panel-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-xs text-gray-600">Click to pick. Remove unused clears empty/transparent entries.</div>
|
||||
<button type="button" id="wall-remove-unused" class="btn-yellow text-xs px-2 py-1">Remove Unused</button>
|
||||
</div>
|
||||
<div id="wall-used-palette" class="palette-box min-h-[2.4rem]"></div>
|
||||
</div>
|
||||
<div class="panel-heading mt-4">Wall Palette</div>
|
||||
<div class="panel-card">
|
||||
<div id="wall-palette" class="palette-box min-h-[3rem]"></div>
|
||||
</div>
|
||||
<div class="panel-heading mt-4">Replace Colors</div>
|
||||
<div class="panel-card grid grid-cols-1 gap-2">
|
||||
<label class="text-sm font-medium flex flex-col gap-1">
|
||||
Replace
|
||||
<select id="wall-replace-from" class="select text-sm"></select>
|
||||
</label>
|
||||
<label class="text-sm font-medium flex flex-col gap-1">
|
||||
With
|
||||
<select id="wall-replace-to" class="select text-sm"></select>
|
||||
</label>
|
||||
<button type="button" id="wall-replace-btn" class="btn-dark text-sm">Replace</button>
|
||||
<div id="wall-replace-msg" class="text-xs text-gray-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-stack" data-mobile-tab="save">
|
||||
<div class="panel-heading">Save & Export</div>
|
||||
<div class="panel-card space-y-3">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button class="btn-dark bg-blue-600" data-export="png">Export PNG</button>
|
||||
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</button>
|
||||
<p class="hint w-full">Exports the current wall view.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" id="wall-clear" class="btn-danger text-sm px-3 py-2 flex-1">Clear</button>
|
||||
<button type="button" id="wall-fill-all" class="btn-blue text-sm px-3 py-2 flex-1">Fill All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section id="wall-canvas-panel"
|
||||
class="order-1 lg:order-2 w-full lg:flex-1 flex flex-col items-stretch rounded-2xl overflow-hidden bg-white/50 shadow-inner ring-1 ring-black/5">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-white/70">
|
||||
<div class="text-base font-semibold text-slate-700">Balloon Wall</div>
|
||||
<div class="text-sm text-gray-500">Columns/Rows: <span id="wall-grid-label">9 × 7</span></div>
|
||||
</div>
|
||||
<div id="wall-display" class="flex-1 bg-white relative overflow-auto">
|
||||
<div class="p-6 text-gray-500 text-sm">Wall designer will load here.</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="mobile-tabbar" class="mobile-tabbar">
|
||||
@ -444,8 +540,10 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js" defer></script>
|
||||
|
||||
<script src="shared.js" defer></script>
|
||||
<script src="script.js" defer></script>
|
||||
|
||||
<script src="organic.js" defer></script>
|
||||
<script src="wall.js" defer></script>
|
||||
<script src="classic.js" defer></script>
|
||||
|
||||
</body>
|
||||
|
||||
1919
organic.js
Normal file
1919
organic.js
Normal file
File diff suppressed because it is too large
Load Diff
211
shared.js
Normal file
211
shared.js
Normal file
@ -0,0 +1,211 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
// Shared helpers & palette flattening
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const PX_PER_INCH = 4;
|
||||
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
||||
const TEXTURE_ZOOM_DEFAULT = 1.8;
|
||||
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
|
||||
const SWATCH_TEXTURE_ZOOM = 2.5;
|
||||
const PNG_EXPORT_SCALE = 3;
|
||||
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const clamp01 = v => clamp(v, 0, 1);
|
||||
const normalizeHex = h => (h || '').toLowerCase();
|
||||
function hexToRgb(hex) {
|
||||
const h = normalizeHex(hex).replace('#','');
|
||||
if (h.length === 3) {
|
||||
const r = parseInt(h[0] + h[0], 16);
|
||||
const g = parseInt(h[1] + h[1], 16);
|
||||
const b = parseInt(h[2] + h[2], 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
if (h.length === 6) {
|
||||
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 };
|
||||
}
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
function luminance(hex) {
|
||||
const { r, g, b } = hexToRgb(hex || '#000');
|
||||
const norm = [r,g,b].map(v => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
|
||||
});
|
||||
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
|
||||
}
|
||||
function shineStyle(colorHex) {
|
||||
const hex = normalizeHex(colorHex);
|
||||
const isRetroWhite = hex === '#e8e3d9';
|
||||
const isPureWhite = hex === '#ffffff';
|
||||
const lum = luminance(hex);
|
||||
if (isPureWhite || isRetroWhite) {
|
||||
return { fill: 'rgba(220,220,220,0.22)', stroke: null };
|
||||
}
|
||||
if (lum > 0.7) {
|
||||
const t = clamp01((lum - 0.7) / 0.3);
|
||||
const fillAlpha = 0.08 + (0.04 - 0.08) * t;
|
||||
return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null };
|
||||
}
|
||||
const base = 0.20;
|
||||
const softened = lum > 0.4 ? base * 0.7 : base;
|
||||
const finalAlpha = isRetroWhite ? softened * 0.6 : softened;
|
||||
return { fill: `rgba(255,255,255,${finalAlpha})`, stroke: null };
|
||||
}
|
||||
|
||||
const FLAT_COLORS = [];
|
||||
const NAME_BY_HEX = new Map();
|
||||
const HEX_TO_FIRST_IDX = new Map();
|
||||
const allowedSet = new Set();
|
||||
|
||||
(function buildFlat() {
|
||||
if (!Array.isArray(window.PALETTE)) return;
|
||||
window.PALETTE.forEach(group => {
|
||||
(group.colors || []).forEach(c => {
|
||||
if (!c?.hex) return;
|
||||
const item = { ...c, family: group.family };
|
||||
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
|
||||
item.imageFocus = {
|
||||
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
|
||||
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
|
||||
};
|
||||
item._idx = FLAT_COLORS.length;
|
||||
FLAT_COLORS.push(item);
|
||||
|
||||
const key = (c.hex || '').toLowerCase();
|
||||
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
|
||||
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
|
||||
allowedSet.add(key);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
const IMG_CACHE = new Map();
|
||||
function getImage(path, onLoad) {
|
||||
if (!path) return null;
|
||||
let img = IMG_CACHE.get(path);
|
||||
if (!img) {
|
||||
img = new Image();
|
||||
// Avoid CORS issues on file:// by only setting crossOrigin for http/https
|
||||
const href = (() => { try { return new URL(path, window.location.href); } catch { return null; } })();
|
||||
const isFile = href?.protocol === 'file:' || window.location.protocol === 'file:';
|
||||
if (!isFile) img.crossOrigin = 'anonymous';
|
||||
img.decoding = 'async';
|
||||
img.loading = 'eager';
|
||||
img.src = path;
|
||||
if (onLoad) img.onload = onLoad;
|
||||
IMG_CACHE.set(path, img);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
const DATA_URL_CACHE = new Map();
|
||||
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
||||
const blobToDataUrl = blob => new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onloadend = () => resolve(r.result);
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
function imageToDataUrl(img) {
|
||||
if (!img || !img.complete || img.naturalWidth === 0) return null;
|
||||
// On file:// origins, drawing may be blocked; return null to fall back to original href.
|
||||
if (window.location.protocol === 'file:') return null;
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.naturalWidth;
|
||||
c.height = img.naturalHeight;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
return c.toDataURL('image/png');
|
||||
} catch (err) {
|
||||
console.warn('[Export] imageToDataUrl failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function imageUrlToDataUrl(src) {
|
||||
if (!src || src.startsWith('data:')) return src;
|
||||
if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src);
|
||||
const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
|
||||
const urlObj = (() => { try { return new URL(abs); } catch { return null; } })();
|
||||
const isFile = urlObj?.protocol === 'file:';
|
||||
|
||||
const cachedImg = IMG_CACHE.get(src);
|
||||
const cachedUrl = imageToDataUrl(cachedImg);
|
||||
if (cachedUrl) { DATA_URL_CACHE.set(src, cachedUrl); return cachedUrl; }
|
||||
|
||||
let dataUrl = null;
|
||||
try {
|
||||
if (isFile) {
|
||||
// On file:// we cannot safely read pixels; return the original path.
|
||||
dataUrl = src;
|
||||
} else {
|
||||
const resp = await fetch(abs);
|
||||
if (!resp.ok) throw new Error(`Status ${resp.status}`);
|
||||
dataUrl = await blobToDataUrl(await resp.blob());
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isFile) console.warn('[Export] Fetch failed for', abs, err);
|
||||
dataUrl = await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
if (!isFile) img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.naturalWidth || 1;
|
||||
c.height = img.naturalHeight || 1;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
resolve(c.toDataURL('image/png'));
|
||||
} catch (e) {
|
||||
console.error('[Export] Canvas fallback failed for', abs, e);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = abs;
|
||||
});
|
||||
}
|
||||
if (!dataUrl) dataUrl = abs;
|
||||
DATA_URL_CACHE.set(src, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
function download(href, suggestedFilename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = suggestedFilename || 'download';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
window.shared = {
|
||||
PX_PER_INCH,
|
||||
SIZE_PRESETS,
|
||||
TEXTURE_ZOOM_DEFAULT,
|
||||
TEXTURE_FOCUS_DEFAULT,
|
||||
SWATCH_TEXTURE_ZOOM,
|
||||
PNG_EXPORT_SCALE,
|
||||
clamp,
|
||||
clamp01,
|
||||
normalizeHex,
|
||||
hexToRgb,
|
||||
shineStyle,
|
||||
luminance,
|
||||
FLAT_COLORS,
|
||||
NAME_BY_HEX,
|
||||
HEX_TO_FIRST_IDX,
|
||||
allowedSet,
|
||||
getImage,
|
||||
DATA_URL_CACHE,
|
||||
XLINK_NS,
|
||||
blobToDataUrl,
|
||||
imageToDataUrl,
|
||||
imageUrlToDataUrl,
|
||||
download,
|
||||
};
|
||||
});
|
||||
})();
|
||||
14
style.css
14
style.css
@ -109,6 +109,8 @@ body { color: #1f2937; }
|
||||
|
||||
.swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; }
|
||||
.family-title { font-weight:700; color:#334155; margin-top:.25rem; font-size:.9rem; letter-spacing: -0.01em; }
|
||||
#wall-display { min-height: 60vh; }
|
||||
#wall-display svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.badge {
|
||||
position:absolute;
|
||||
@ -339,13 +341,17 @@ body { color: #1f2937; }
|
||||
body { padding-bottom: 0; overflow: auto; }
|
||||
html, body { height: auto; overflow: auto; }
|
||||
|
||||
/* Stack switching: show only the active mobile tab stack across panels */
|
||||
.control-sheet .control-stack { display: none; }
|
||||
body[data-mobile-tab="controls"] #controls-panel [data-mobile-tab="controls"],
|
||||
body[data-mobile-tab="colors"] #controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #controls-panel [data-mobile-tab="save"],
|
||||
body[data-mobile-tab="colors"] #controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #controls-panel [data-mobile-tab="save"],
|
||||
body[data-mobile-tab="controls"] #classic-controls-panel [data-mobile-tab="controls"],
|
||||
body[data-mobile-tab="colors"] #classic-controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"] {
|
||||
body[data-mobile-tab="colors"] #classic-controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"],
|
||||
body[data-mobile-tab="controls"] #wall-controls-panel [data-mobile-tab="controls"],
|
||||
body[data-mobile-tab="colors"] #wall-controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
845
wall.js
Normal file
845
wall.js
Normal file
@ -0,0 +1,845 @@
|
||||
(() => {
|
||||
'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();
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user