2545 lines
121 KiB
JavaScript
2545 lines
121 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
// -------- helpers ----------
|
||
const log = (...a) => console.log('[Classic]', ...a);
|
||
const debug = (...a) => console.debug('[Classic/manual]', ...a);
|
||
const fail = (msg) => {
|
||
console.error('[Classic ERROR]', msg);
|
||
const d = document.getElementById('classic-display');
|
||
if (d) d.innerHTML = `<div style="padding:1rem;color:#b91c1c;font-family:system-ui,Arial">
|
||
<strong>Classic failed:</strong> ${String(msg)}
|
||
</div>`;
|
||
};
|
||
const normHex = (h) => (String(h || '')).trim().toLowerCase();
|
||
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
||
function hexToRgb(hex) {
|
||
const h = normHex(hex).replace('#', '');
|
||
if (h.length === 3) {
|
||
return {
|
||
r: parseInt(h[0] + h[0], 16) || 0,
|
||
g: parseInt(h[1] + h[1], 16) || 0,
|
||
b: parseInt(h[2] + h[2], 16) || 0
|
||
};
|
||
}
|
||
if (h.length === 6) {
|
||
return {
|
||
r: parseInt(h.slice(0,2), 16) || 0,
|
||
g: parseInt(h.slice(2,4), 16) || 0,
|
||
b: parseInt(h.slice(4,6), 16) || 0
|
||
};
|
||
}
|
||
return { r: 0, g: 0, b: 0 };
|
||
}
|
||
function luminance(hex) {
|
||
const { r, g, b } = hexToRgb(hex);
|
||
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];
|
||
}
|
||
let manualModeState = false;
|
||
let classicZoom = 1;
|
||
const clampZoom = (z) => Math.min(2.2, Math.max(0.5, z));
|
||
let currentPatternName = '';
|
||
let currentRowCount = 0;
|
||
let manualUndoStack = [];
|
||
let manualRedoStack = [];
|
||
function classicShineStyle(colorInfo) {
|
||
const hex = normHex(colorInfo?.hex || colorInfo?.colour || '');
|
||
if (hex.startsWith('#')) {
|
||
const lum = luminance(hex);
|
||
// For bright hues (yellows, pastels) avoid darkening which can skew green; use a light, soft highlight instead.
|
||
if (lum > 0.7) {
|
||
// Slightly stronger highlight for bright hues while staying neutral
|
||
return { fill: 'rgba(255,255,255,0.4)', opacity: 1, stroke: null };
|
||
}
|
||
// Deep shades keep a stronger white highlight.
|
||
if (lum < 0.2) {
|
||
return { fill: 'rgba(255,255,255,0.55)', opacity: 1, stroke: null };
|
||
}
|
||
}
|
||
return { fill: '#ffffff', opacity: 0.45, stroke: null };
|
||
}
|
||
function textStyleForColor(colorInfo) {
|
||
if (!colorInfo) return { color: '#0f172a', shadow: 'none' };
|
||
if (colorInfo.image) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.55)' };
|
||
const hex = normHex(colorInfo.hex);
|
||
if (hex.startsWith('#')) {
|
||
const lum = luminance(hex);
|
||
if (lum < 0.5) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.6)' };
|
||
return { color: '#0f172a', shadow: '0 1px 2px rgba(255,255,255,0.7)' };
|
||
}
|
||
return { color: '#0f172a', shadow: 'none' };
|
||
}
|
||
function outlineColorFor(colorInfo) {
|
||
// Mirrors the topper chip text color choice for consistent contrast.
|
||
const chipStyle = textStyleForColor(colorInfo || { hex: '#ffffff' });
|
||
return chipStyle.color || '#0f172a';
|
||
}
|
||
|
||
// -------- persistent color selection (now supports image textures) ----------
|
||
const PALETTE_KEY = 'classic:colors:v2';
|
||
const TOPPER_COLOR_KEY = 'classic:topperColor:v2';
|
||
const CLASSIC_STATE_KEY = 'classic:state:v1';
|
||
const MANUAL_MODE_KEY = 'classic:manualMode:v1';
|
||
const MANUAL_OVERRIDES_KEY = 'classic:manualOverrides:v1';
|
||
const MANUAL_EXPANDED_KEY = 'classic:manualExpanded:v1';
|
||
const NUMBER_IMAGE_MAP = {
|
||
'0': 'output_webp/0.webp',
|
||
'1': 'output_webp/1.webp',
|
||
'2': 'output_webp/2.webp',
|
||
'3': 'output_webp/3.webp',
|
||
'4': 'output_webp/4.webp',
|
||
'5': 'output_webp/5.webp',
|
||
'6': 'output_webp/6.webp',
|
||
'7': 'output_webp/7.webp',
|
||
'8': 'output_webp/8.webp',
|
||
'9': 'output_webp/9.webp'
|
||
};
|
||
|
||
const MAX_SLOTS = 20;
|
||
const SLOT_COUNT_KEY = 'classic:slotCount:v1';
|
||
const defaultColors = () => [
|
||
{ hex: '#d92e3a', image: null }, { hex: '#ffffff', image: null },
|
||
{ hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null },
|
||
{ hex: '#fcd34d', image: null }
|
||
];
|
||
const defaultTopper = () => ({ hex: '#E32636', image: 'images/chrome-gold.webp' }); // Classic gold
|
||
const numberSpriteSet = new Set(Object.values(NUMBER_IMAGE_MAP));
|
||
const sanitizeTopperColor = (colorObj = {}) => {
|
||
const base = defaultTopper();
|
||
const hex = normHex(colorObj.hex || base.hex);
|
||
const img = colorObj.image || null;
|
||
const image = (img && !numberSpriteSet.has(img)) ? img : null;
|
||
return { hex, image };
|
||
};
|
||
|
||
function getClassicColors() {
|
||
let arr = defaultColors();
|
||
try {
|
||
const savedJSON = localStorage.getItem(PALETTE_KEY);
|
||
if (!savedJSON) return arr;
|
||
const saved = JSON.parse(savedJSON);
|
||
if (Array.isArray(saved) && saved.length > 0) {
|
||
if (typeof saved[0] === 'string') {
|
||
arr = saved.slice(0, MAX_SLOTS).map(hex => ({ hex: normHex(hex), image: null }));
|
||
} else if (typeof saved[0] === 'object' && saved[0] !== null) {
|
||
arr = saved.slice(0, MAX_SLOTS);
|
||
}
|
||
}
|
||
while (arr.length < 5) arr.push({ hex: '#ffffff', image: null });
|
||
if (arr.length > MAX_SLOTS) arr = arr.slice(0, MAX_SLOTS);
|
||
} catch (e) { console.error('Failed to parse classic colors:', e); }
|
||
return arr;
|
||
}
|
||
|
||
function setClassicColors(arr) {
|
||
const clean = (arr || []).slice(0, MAX_SLOTS).map(c => ({
|
||
hex: normHex(c.hex), image: c.image || null
|
||
}));
|
||
while (clean.length < 5) clean.push({ hex: '#ffffff', image: null });
|
||
try { localStorage.setItem(PALETTE_KEY, JSON.stringify(clean)); } catch {}
|
||
return clean;
|
||
}
|
||
|
||
function getTopperColor() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(TOPPER_COLOR_KEY));
|
||
if (saved && saved.hex) return sanitizeTopperColor(saved);
|
||
} catch {}
|
||
return sanitizeTopperColor(defaultTopper());
|
||
}
|
||
|
||
function setTopperColor(colorObj) {
|
||
const clean = sanitizeTopperColor(colorObj);
|
||
try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {}
|
||
}
|
||
function loadManualMode() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(MANUAL_MODE_KEY));
|
||
if (typeof saved === 'boolean') return saved;
|
||
} catch {}
|
||
return false; // default to pattern-driven mode; manual is opt-in
|
||
}
|
||
function saveManualMode(on) {
|
||
try { localStorage.setItem(MANUAL_MODE_KEY, JSON.stringify(!!on)); } catch {}
|
||
}
|
||
function loadManualExpanded() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(MANUAL_EXPANDED_KEY));
|
||
if (typeof saved === 'boolean') return saved;
|
||
} catch {}
|
||
return true;
|
||
}
|
||
function saveManualExpanded(on) {
|
||
try { localStorage.setItem(MANUAL_EXPANDED_KEY, JSON.stringify(!!on)); } catch {}
|
||
}
|
||
function loadManualOverrides() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(MANUAL_OVERRIDES_KEY));
|
||
if (saved && typeof saved === 'object') return saved;
|
||
} catch {}
|
||
return {};
|
||
}
|
||
function saveManualOverrides(map) {
|
||
try { localStorage.setItem(MANUAL_OVERRIDES_KEY, JSON.stringify(map || {})); } catch {}
|
||
}
|
||
function manualKey(patternName, rowCount) {
|
||
return `${patternName || ''}::${rowCount || 0}`;
|
||
}
|
||
const manualOverrides = loadManualOverrides();
|
||
function manualOverrideCount(patternName, rowCount) {
|
||
const key = manualKey(patternName, rowCount);
|
||
const entry = manualOverrides[key];
|
||
return entry ? Object.keys(entry).length : 0;
|
||
}
|
||
function getManualOverride(patternName, rowCount, x, y) {
|
||
const key = manualKey(patternName, rowCount);
|
||
const entry = manualOverrides[key];
|
||
if (!entry) return undefined;
|
||
return entry[`${x},${y}`];
|
||
}
|
||
function setManualOverride(patternName, rowCount, x, y, value) {
|
||
const key = manualKey(patternName, rowCount);
|
||
if (!manualOverrides[key]) manualOverrides[key] = {};
|
||
manualOverrides[key][`${x},${y}`] = value;
|
||
saveManualOverrides(manualOverrides);
|
||
}
|
||
function clearManualOverride(patternName, rowCount, x, y) {
|
||
const key = manualKey(patternName, rowCount);
|
||
const entry = manualOverrides[key];
|
||
if (entry) {
|
||
delete entry[`${x},${y}`];
|
||
saveManualOverrides(manualOverrides);
|
||
}
|
||
}
|
||
function manualUsedColorsFor(patternName, rowCount) {
|
||
const key = manualKey(patternName, rowCount);
|
||
const overrides = manualOverrides[key] || {};
|
||
const palette = buildClassicPalette();
|
||
const out = [];
|
||
const seen = new Set();
|
||
Object.values(overrides).forEach(val => {
|
||
let hex = null, image = null;
|
||
if (val && typeof val === 'object') {
|
||
hex = normHex(val.hex || val.colour || '');
|
||
image = val.image || null;
|
||
} else if (typeof val === 'number') {
|
||
const info = palette[val] || null;
|
||
hex = normHex(info?.colour || info?.hex || '');
|
||
image = info?.image || null;
|
||
}
|
||
if (!hex && !image) return;
|
||
const keyStr = `${image || ''}|${hex || ''}`;
|
||
if (seen.has(keyStr)) return;
|
||
seen.add(keyStr);
|
||
out.push({ hex, image, label: hex || (image ? 'Texture' : 'Color') });
|
||
});
|
||
return out;
|
||
}
|
||
// Manual palette (used in Manual mode project palette)
|
||
let projectPaletteBox = null;
|
||
let renderProjectPalette = () => {};
|
||
let manualActiveColorGlobal = (window.shared?.getActiveColor?.()) || { hex: '#ffffff', image: null };
|
||
function getTopperTypeSafe() {
|
||
try { return (window.ClassicDesigner?.lastTopperType) || null; } catch { return null; }
|
||
}
|
||
function loadClassicState() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(CLASSIC_STATE_KEY));
|
||
if (saved && typeof saved === 'object') return saved;
|
||
} catch {}
|
||
return null;
|
||
}
|
||
function saveClassicState(state) {
|
||
try { localStorage.setItem(CLASSIC_STATE_KEY, JSON.stringify(state || {})); } catch {}
|
||
}
|
||
|
||
function buildClassicPalette() {
|
||
const colors = getClassicColors();
|
||
const palette = { 0: { colour: '#FFFFFF', name: 'No Colour', image: null } };
|
||
colors.forEach((c, i) => {
|
||
palette[i + 1] = { colour: c.hex, image: c.image };
|
||
});
|
||
return palette;
|
||
}
|
||
|
||
function flattenPalette() {
|
||
const out = [];
|
||
if (Array.isArray(window.PALETTE)) {
|
||
window.PALETTE.forEach(group => {
|
||
(group.colors || []).forEach(c => {
|
||
if (!c?.hex) return;
|
||
out.push({
|
||
hex: normHex(c.hex), name: c.name || c.hex,
|
||
family: group.family || '', image: c.image || null
|
||
});
|
||
});
|
||
});
|
||
}
|
||
const seen = new Set();
|
||
return out.filter(c => {
|
||
const key = `${c.image || ''}|${c.hex}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// -------- tiny grid engine (Mithril) ----------
|
||
function GridCalculator() {
|
||
if (typeof window.m === 'undefined') throw new Error('Mithril (m) not loaded');
|
||
|
||
let pxUnit = 10;
|
||
let clusters = 10;
|
||
let reverse = false;
|
||
let topperEnabled = false;
|
||
let topperType = 'round';
|
||
let topperOffsetX_Px = 0;
|
||
let topperOffsetY_Px = 0;
|
||
let topperSizeMultiplier = 1;
|
||
let shineEnabled = true;
|
||
let borderEnabled = false;
|
||
let manualMode = loadManualMode();
|
||
let explodedScale = 1.18;
|
||
let explodedGapPx = pxUnit * 1.6;
|
||
let explodedStaggerPx = pxUnit * 0.6;
|
||
let manualFocusStart = 0;
|
||
let manualFocusSize = 8;
|
||
let manualFocusEnabled = false;
|
||
let manualFloatingQuad = null;
|
||
|
||
const patterns = {};
|
||
const api = {
|
||
patterns,
|
||
initialPattern: 'Arch 4',
|
||
controller: (el) => makeController(el),
|
||
setClusters(n) { clusters = Math.max(1, (Number(n)|0) || 10); },
|
||
setReverse(on){ reverse = !!on; },
|
||
setTopperEnabled(on) { topperEnabled = !!on; },
|
||
setTopperType(type) { topperType = type || 'round'; },
|
||
setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; },
|
||
setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; },
|
||
setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; },
|
||
setShineEnabled(on) { shineEnabled = !!on; },
|
||
setBorderEnabled(on) { borderEnabled = !!on; },
|
||
setManualMode(on) { manualMode = !!on; saveManualMode(manualMode); },
|
||
setExplodedSettings({ scale, gapPx, staggerPx } = {}) {
|
||
if (Number.isFinite(scale)) explodedScale = scale;
|
||
if (Number.isFinite(gapPx)) explodedGapPx = gapPx;
|
||
if (Number.isFinite(staggerPx)) explodedStaggerPx = staggerPx;
|
||
},
|
||
setManualFocus({ start, size, enabled }) {
|
||
if (Number.isFinite(start)) manualFocusStart = Math.max(0, start|0);
|
||
if (Number.isFinite(size)) manualFocusSize = Math.max(1, size|0);
|
||
if (typeof enabled === 'boolean') manualFocusEnabled = enabled;
|
||
},
|
||
setManualFloatingQuad(val) { manualFloatingQuad = (val === null ? null : (val|0)); }
|
||
};
|
||
|
||
const svg = (tag, attrs, children) => m(tag, attrs, children);
|
||
|
||
function extend(p){
|
||
const parentName = p.deriveFrom; if (!parentName) return;
|
||
const base = patterns[parentName]; if (!base) return;
|
||
if (base.deriveFrom) extend(base);
|
||
Object.keys(base).forEach(k => { if (!(k in p)) p[k] = base[k]; });
|
||
p.parent = base;
|
||
}
|
||
|
||
function BBox(){ this.min={x:Infinity,y:Infinity}; this.max={x:-Infinity,y:-Infinity}; }
|
||
BBox.prototype.add = function(x,y){ if(isNaN(x)||isNaN(y)) return this;
|
||
this.min.x=Math.min(this.min.x,x); this.min.y=Math.min(this.min.y,y);
|
||
this.max.x=Math.max(this.max.x,x); this.max.y=Math.max(this.max.y,y); return this; };
|
||
BBox.prototype.w=function(){return this.max.x-this.min.x;};
|
||
BBox.prototype.h=function(){return this.max.y-this.min.y;};
|
||
|
||
const balloonSize = (cell)=> (cell.shape.size ?? 1);
|
||
const cellScale = (cell)=> balloonSize(cell) * pxUnit;
|
||
|
||
function cellView(cell, id, explicitFill, model, colorInfo, opts = {}){
|
||
const shape = cell.shape;
|
||
const base = shape.base || {};
|
||
const scale = cellScale(cell);
|
||
const expandedOn = model.manualMode && (model.explodedGapPx || 0) > 0;
|
||
// Keep arch geometry consistent when expanded; only scale the alpha (wireframe) ring slightly to improve hit targets.
|
||
const manualScale = expandedOn && model.patternName?.toLowerCase().includes('arch') ? 1 : (expandedOn ? 1.15 : 1);
|
||
const transform = [(base.transform||''), `scale(${scale * manualScale})`].join(' ');
|
||
const isUnpainted = !colorInfo || explicitFill === 'none';
|
||
const wireframe = !!opts.wireframe || (model.manualMode && isUnpainted);
|
||
const wantsOutlineOnly = model.manualMode && isUnpainted;
|
||
const wireStroke = '#94a3b8';
|
||
const commonAttrs = {
|
||
'vector-effect': 'non-scaling-stroke',
|
||
stroke: wireframe ? wireStroke : (borderEnabled ? '#111827' : (wantsOutlineOnly ? wireStroke : 'none')),
|
||
'stroke-width': wireframe ? 1.1 : (borderEnabled ? 0.8 : (wantsOutlineOnly ? 1.1 : 0)),
|
||
'paint-order': 'stroke fill', class: 'balloon',
|
||
fill: isUnpainted ? 'none' : (explicitFill || '#cccccc'),
|
||
'pointer-events': 'all'
|
||
};
|
||
if (cell.isTopper) {
|
||
commonAttrs['data-is-topper'] = true;
|
||
} else {
|
||
commonAttrs['data-color-code'] = cell.colorCode || 0;
|
||
commonAttrs['data-quad-number'] = cell.y + 1;
|
||
}
|
||
|
||
const kids = [];
|
||
const fillRule = base.fillRule || base['fill-rule'] || null;
|
||
const isNumTopper = cell.isTopper && (model.topperType || '').startsWith('num-');
|
||
if (base.image) {
|
||
const w = base.width || 1, h = base.height || 1;
|
||
if (isNumTopper) {
|
||
const maskId = base.maskId || 'classic-num-mask';
|
||
const fillVal = (colorInfo && colorInfo.image)
|
||
? 'url(#classic-pattern-topper)'
|
||
: (colorInfo?.hex || '#ffffff');
|
||
const lum = luminance(colorInfo?.hex || colorInfo?.colour || '#ffffff');
|
||
const outlineFilterId = lum >= 0.55 ? 'classic-num-outline-dark' : 'classic-num-outline-light';
|
||
kids.push(svg('image', {
|
||
href: base.image,
|
||
x: -w/2,
|
||
y: -h/2,
|
||
width: w,
|
||
height: h,
|
||
preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet',
|
||
style: 'pointer-events:none',
|
||
filter: `url(#${outlineFilterId})`,
|
||
mask: `url(#${maskId})`
|
||
}));
|
||
kids.push(svg('rect', {
|
||
x: -w/2,
|
||
y: -h/2,
|
||
width: w,
|
||
height: h,
|
||
fill: fillVal,
|
||
mask: `url(#${maskId})`,
|
||
style: 'pointer-events:none'
|
||
}));
|
||
} else {
|
||
kids.push(svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' }));
|
||
}
|
||
} else if (Array.isArray(base.paths)) {
|
||
base.paths.forEach(p => {
|
||
kids.push(svg('path', { ...commonAttrs, d: p.d, 'fill-rule': p.fillRule || fillRule || 'nonzero' }));
|
||
});
|
||
} else if (base.type === 'path' || base.d) {
|
||
kids.push(svg('path', { ...commonAttrs, d: base.d, 'fill-rule': fillRule || 'nonzero' }));
|
||
} else {
|
||
kids.push(svg('ellipse', { ...commonAttrs, cx:0, cy:0, rx:0.5, ry:0.5 }));
|
||
}
|
||
|
||
const allowShine = base.allowShine !== false;
|
||
const applyShine = !wireframe && model.shineEnabled && (!cell.isTopper || (allowShine && !isNumTopper));
|
||
if (applyShine) {
|
||
const shine = classicShineStyle(colorInfo);
|
||
const shineAttrs = {
|
||
class: 'shine', cx: -0.15, cy: -0.15, rx: 0.22, ry: 0.13,
|
||
fill: shine.fill, opacity: shine.opacity, transform: 'rotate(-25)', 'pointer-events': 'none'
|
||
};
|
||
kids.push(svg('ellipse', {
|
||
...shineAttrs
|
||
}));
|
||
}
|
||
return svg('g', { id, transform }, kids);
|
||
}
|
||
|
||
function gridPos(x,y,z,inflate,pattern,model){
|
||
const base = patterns[model.patternName].parent || patterns[model.patternName];
|
||
const rel = (pattern.baseBalloonSize && base.baseBalloonSize) ? pattern.baseBalloonSize/base.baseBalloonSize : 1;
|
||
let p = { x: pattern.gridX(model.pattern.cellsPerRow > 1 ? y : x, x), y: pattern.gridY(y,x) };
|
||
if (pattern.transform) p = pattern.transform(p,x,y,model);
|
||
let xPx = p.x * rel * pxUnit;
|
||
let yPx = p.y * rel * pxUnit;
|
||
if (model.manualMode && (model.explodedGapPx || 0) > 0) {
|
||
const rowIndex = y;
|
||
const gap = model.explodedGapPx || 0;
|
||
const isArch = (model.patternName || '').toLowerCase().includes('arch');
|
||
if (isArch) {
|
||
// Move outward along the radial vector and add a tangential nudge for even spread; push ends a bit more.
|
||
const dist = Math.hypot(xPx, yPx) || 1;
|
||
const maxRow = Math.max(1, (pattern.cellsPerRow * model.rowCount) - 1);
|
||
const t = Math.max(0, Math.min(1, y / maxRow)); // 0 first row, 1 last row
|
||
const radialPush = gap * (1.6 + Math.abs(t - 0.5) * 1.6); // ends > crown
|
||
const tangentialPush = (t - 0.5) * (gap * 0.8); // small along-arc spread
|
||
const nx = xPx / dist;
|
||
const ny = yPx / dist;
|
||
const tx = -ny;
|
||
const ty = nx;
|
||
xPx += nx * radialPush + tx * tangentialPush;
|
||
yPx += ny * radialPush + ty * tangentialPush;
|
||
} else {
|
||
yPx += rowIndex * gap; // columns: separate along the vertical path
|
||
}
|
||
}
|
||
return { x: xPx, y: yPx };
|
||
}
|
||
|
||
// === Spiral coloring helpers (shared by 4- and 5-balloon clusters) ===
|
||
function distinctPaletteSlots(palette) {
|
||
// Collapse visually identical slots so 3-color spirals work even if you filled 5 slots.
|
||
const seen = new Set(), out = [];
|
||
for (let s = 1; s <= 5; s++) {
|
||
const c = palette[s];
|
||
if (!c) continue;
|
||
const key = (c.image || '') + '|' + String(c.colour || '').toLowerCase();
|
||
if (!seen.has(key)) { seen.add(key); out.push(s); }
|
||
}
|
||
return out.length ? out : [1,2,3,4,5];
|
||
}
|
||
|
||
|
||
|
||
function newGrid(pattern, cells, container, model){
|
||
const kids = [], layers = [], bbox = new BBox(), focusBox = new BBox(), resetDots = new Map();
|
||
let floatingAnchor = null;
|
||
let overrideCount = manualOverrideCount(model.patternName, model.rowCount);
|
||
const balloonsPerCluster = pattern.balloonsPerCluster || 4;
|
||
const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse));
|
||
const rowColorPatterns = {};
|
||
const wireframeMode = false; // per-balloon wireframe handled in cellView for unpainted balloons
|
||
const stackedSlots = (() => {
|
||
const slots = distinctPaletteSlots(model.palette);
|
||
const limit = Math.max(1, Math.min(slots.length, balloonsPerCluster));
|
||
return slots.slice(0, limit);
|
||
})();
|
||
|
||
const colorBlock4 = [
|
||
[1, 2, 4, 3],
|
||
[4, 1, 3, 2],
|
||
[3, 4, 2, 1],
|
||
[2, 3, 1, 4],
|
||
];
|
||
const colorBlock5 =
|
||
[
|
||
[5, 2, 3, 4, 1],
|
||
[2, 3, 4, 5, 1],
|
||
[2, 4, 5, 1, 3],
|
||
[4, 5, 1, 2, 3],
|
||
[4, 1, 2, 3, 5],
|
||
];
|
||
|
||
const isManual = !!model.manualMode;
|
||
const manualImagePatterns = new Map();
|
||
const getManualPatternId = (img) => {
|
||
if (!img) return null;
|
||
if (manualImagePatterns.has(img)) return manualImagePatterns.get(img);
|
||
const id = `classic-manual-${manualImagePatterns.size + 1}`;
|
||
manualImagePatterns.set(img, id);
|
||
return id;
|
||
};
|
||
|
||
const expandedOn = model.manualMode && (model.explodedGapPx || 0) > 0;
|
||
|
||
for (let cell of cells) {
|
||
let c, fill, colorInfo;
|
||
if (cell.isTopper) {
|
||
const topRowYIndex = 0, topClusterY = pattern.gridY(topRowYIndex, 0) * pxUnit;
|
||
const regularBalloonRadius = (pattern.balloonShapes['front'] || pattern.balloonShapes['penta'] || pattern.balloonShapes['middle']).size * pxUnit * 0.5;
|
||
const highestPoint = topClusterY - regularBalloonRadius;
|
||
const topperRadius = cell.shape.size * pxUnit * cell.shape.base.radius;
|
||
const topperY = highestPoint - topperRadius - (pxUnit * 0.5) + topperOffsetY_Px;
|
||
c = { x: topperOffsetX_Px, y: topperY };
|
||
fill = model.topperColor.image ? `url(#classic-pattern-topper)` : model.topperColor.hex;
|
||
colorInfo = model.topperColor;
|
||
} else {
|
||
c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model);
|
||
if (expandedOn) {
|
||
const lift = (cell.shape.zIndex || 0) * 6.2;
|
||
c.y -= lift;
|
||
}
|
||
const manualOverride = isManual ? getManualOverride(model.patternName, model.rowCount, cell.x, cell.y) : undefined;
|
||
const manualSlot = (typeof manualOverride === 'number') ? manualOverride : undefined;
|
||
const rowIndex = cell.y;
|
||
if (!rowColorPatterns[rowIndex]) {
|
||
const totalRows = model.rowCount * (pattern.cellsPerRow || 1);
|
||
const isRightHalf = false; // mirror mode removed
|
||
const baseRow = rowIndex;
|
||
const qEff = baseRow + 1;
|
||
let pat;
|
||
|
||
if (pattern.colorMode === 'stacked') {
|
||
const slot = stackedSlots[(rowIndex) % stackedSlots.length] || stackedSlots[0] || 1;
|
||
pat = new Array(balloonsPerCluster).fill(slot);
|
||
} else if (balloonsPerCluster === 5) {
|
||
const base = (qEff - 1) % 5;
|
||
pat = colorBlock5[base].slice();
|
||
} else {
|
||
const base = Math.floor((qEff - 1) / 2);
|
||
pat = colorBlock4[base % 4].slice();
|
||
if (qEff % 2 === 0) {
|
||
pat = [pat[0], pat[2], pat[1], pat[3]];
|
||
}
|
||
}
|
||
|
||
// Swap left/right emphasis every 5 clusters to break repetition (per template override)
|
||
if (balloonsPerCluster === 5) {
|
||
const SWAP_EVERY = 5;
|
||
const blockIndex = Math.floor(rowIndex / SWAP_EVERY);
|
||
if (blockIndex % 2 === 1) {
|
||
[pat[0], pat[4]] = [pat[4], pat[0]];
|
||
}
|
||
}
|
||
|
||
if (pat.length > 1) {
|
||
let shouldReverse;
|
||
shouldReverse = reversed;
|
||
if (shouldReverse) pat.reverse();
|
||
}
|
||
|
||
rowColorPatterns[rowIndex] = pat;
|
||
}
|
||
|
||
const patternSlot = rowColorPatterns[rowIndex][cell.balloonIndexInCluster];
|
||
const manualColorInfo = (manualOverride && typeof manualOverride === 'object') ? manualOverride : null;
|
||
const colorCode = (manualSlot !== undefined) ? manualSlot : patternSlot;
|
||
cell.colorCode = colorCode;
|
||
colorInfo = manualColorInfo || model.palette[colorCode];
|
||
if (manualColorInfo) {
|
||
const pid = manualColorInfo.image ? getManualPatternId(manualColorInfo.image) : null;
|
||
fill = pid ? `url(#${pid})` : (manualColorInfo.hex || manualColorInfo.colour || 'transparent');
|
||
if (manualColorInfo.hex && !manualColorInfo.colour) {
|
||
colorInfo = { ...manualColorInfo, colour: manualColorInfo.hex };
|
||
}
|
||
} else if (isManual) {
|
||
// In manual mode, leave unpainted balloons transparent with an outline only.
|
||
fill = 'none';
|
||
colorInfo = null;
|
||
} else {
|
||
fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent';
|
||
}
|
||
}
|
||
if (wireframeMode) {
|
||
fill = 'none';
|
||
colorInfo = colorInfo || {};
|
||
}
|
||
|
||
const scale = cellScale(cell), shapeRadius = cell.shape.base.radius || 0.5, size = shapeRadius * scale;
|
||
const inFocus = model.manualFocusSize && model.manualFocusStart >= 0
|
||
? (cell.y >= model.manualFocusStart && cell.y < model.manualFocusStart + model.manualFocusSize)
|
||
: false;
|
||
const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model, colorInfo, { wireframe: wireframeMode });
|
||
if (!cell.isTopper) {
|
||
v.attrs = v.attrs || {};
|
||
v.attrs['data-quad-number'] = cell.y + 1;
|
||
}
|
||
const depthLift = expandedOn ? ((cell.shape.zIndex || 0) * 1.8) : 0;
|
||
const floatingOut = model.manualMode && model.manualFloatingQuad === cell.y;
|
||
if (floatingOut) {
|
||
if (!resetDots.has(cell.y)) resetDots.set(cell.y, { x: c.x, y: c.y });
|
||
const isArch = (model.patternName || '').toLowerCase().includes('arch');
|
||
let slideX = 80;
|
||
let slideY = 0;
|
||
const idx = typeof cell.balloonIndexInCluster === 'number' ? cell.balloonIndexInCluster : 0;
|
||
const spread = idx - 1.5;
|
||
if (isArch) {
|
||
// Radial slide outward; preserve layout.
|
||
const dist = Math.hypot(c.x, c.y) || 1;
|
||
const offset = (model.manualMode && (model.explodedGapPx || 0) > 0) ? 120 : 80;
|
||
const nx = c.x / dist, ny = c.y / dist;
|
||
slideX = nx * offset;
|
||
slideY = ny * offset;
|
||
// Slight tangent spread (~5px) to separate balloons without reshaping the quad.
|
||
const txDirX = -ny;
|
||
const txDirY = nx;
|
||
const fan = spread * ((model.manualMode && (model.explodedGapPx || 0) > 0) ? 16 : 10);
|
||
slideX += txDirX * fan;
|
||
slideY += txDirY * fan;
|
||
}
|
||
let tx = c.x + slideX;
|
||
let ty = c.y + slideY;
|
||
// Keep shape intact; only fan columns slightly.
|
||
if (isArch) {
|
||
// no fan/scale for arches; preserve layout
|
||
} else {
|
||
tx += spread * 4;
|
||
ty += spread * 4;
|
||
}
|
||
const fanScale = 1;
|
||
// Nudge the top pair down slightly in columns so they remain easily clickable.
|
||
if (!isArch && typeof cell.balloonIndexInCluster === 'number' && cell.balloonIndexInCluster <= 1) {
|
||
ty += 6;
|
||
}
|
||
|
||
// CRITICAL: Re-add NaN checks to prevent coordinates from breaking SVG
|
||
if (!Number.isFinite(tx) || !Number.isFinite(ty)) {
|
||
console.error('[Classic] Floating Quad: Non-finite coordinates detected. Falling back to original position.', {row: cell.y, cx: c.x, cy: c.y, slideX, slideY, tx, ty});
|
||
tx = c.x;
|
||
ty = c.y;
|
||
}
|
||
|
||
// Correctly construct transform: Translate first, then Base Transform, then Scale to pixel size.
|
||
// We use cellScale(cell) directly (1.0x) to match the true wireframe size perfectly,
|
||
// removing any "pop" or expansion that caused the size mismatch overlay effect.
|
||
// Reuse the original balloon transform so the floated cluster matches the stacked layout exactly.
|
||
// Only prepend the slide offset (plus any depth lift) to move it aside.
|
||
const baseTransform = v.attrs.transform || '';
|
||
const yPos = ty + depthLift;
|
||
const scaleStr = fanScale !== 1 ? ` scale(${fanScale})` : '';
|
||
const tiltDeg = isArch ? 0 : 0; // remove tilt for columns
|
||
const rotationStr = tiltDeg ? ` rotate(${tiltDeg})` : '';
|
||
v.attrs.transform = `translate(${tx},${yPos})${rotationStr} ${baseTransform}${scaleStr}`;
|
||
|
||
// Standard z-index for top visibility, removed drop-shadow; no animation to avoid layout flicker.
|
||
v.attrs.style = `${v.attrs.style || ''}; z-index: 1000;`;
|
||
|
||
// Boost shine visibility on floating balloons to compensate for smaller scale
|
||
if (Array.isArray(v.children)) {
|
||
const shineNode = v.children.find(c => c.attrs && c.attrs.class === 'shine');
|
||
if (shineNode && shineNode.attrs) {
|
||
shineNode.attrs.opacity = 0.65;
|
||
}
|
||
}
|
||
// Suppress outlines on the floated preview to avoid the dark halo.
|
||
v.attrs.stroke = 'none';
|
||
v.attrs['stroke-width'] = 0;
|
||
|
||
bbox.add(tx - size, yPos - size);
|
||
bbox.add(tx + size, yPos + size);
|
||
if (inFocus) {
|
||
focusBox.add(tx - size, yPos - size);
|
||
focusBox.add(tx + size, yPos + size);
|
||
}
|
||
} else {
|
||
const yPos = c.y + depthLift;
|
||
v.attrs.transform = `translate(${c.x},${yPos}) ${v.attrs.transform || ''}`;
|
||
v.attrs.style = `${v.attrs.style || ''};`;
|
||
bbox.add(c.x - size, yPos - size);
|
||
bbox.add(c.x + size, yPos + size);
|
||
if (inFocus) {
|
||
focusBox.add(c.x - size, yPos - size);
|
||
focusBox.add(c.x + size, yPos + size);
|
||
}
|
||
}
|
||
// Keep stacking order stable even when the quad is floated.
|
||
const baseZi = cell.isTopper ? 102 : (100 + (cell.shape.zIndex || 0));
|
||
const zi = floatingOut ? (1000 + baseZi) : baseZi;
|
||
(layers[zi] ||= []).push(v);
|
||
};
|
||
|
||
layers.forEach(layer => layer && layer.forEach(v => kids.push(v)));
|
||
// Add reset dots for floated quads (one per floating row) at their original position.
|
||
if (resetDots.size) {
|
||
resetDots.forEach(({ x, y }) => {
|
||
kids.push(svg('g', { transform: `translate(${x},${y})`, style: 'cursor:pointer' , onclick: 'window.ClassicDesigner?.resetFloatingQuad?.()' }, [
|
||
svg('circle', { cx: 0, cy: 0, r: 10, fill: 'rgba(37,99,235,0.12)', stroke: '#2563eb', 'stroke-width': 2 }),
|
||
svg('circle', { cx: 0, cy: 0, r: 3.5, fill: '#2563eb' })
|
||
]));
|
||
});
|
||
}
|
||
// Keep a modest margin when a quad is floated so the design doesn’t shrink too much.
|
||
const margin = (model.manualMode && model.manualFloatingQuad !== null) ? 40 : 20;
|
||
const focusValid = isFinite(focusBox.min.x) && isFinite(focusBox.min.y) && focusBox.w() > 0 && focusBox.h() > 0;
|
||
const focusOn = model.manualMode && model.manualFocusEnabled && model.manualFocusSize && focusValid;
|
||
// Keep full arch/column in view while still tracking focus extents for highlighting
|
||
const box = bbox;
|
||
const baseW = Math.max(1, box.w()) + margin * 2;
|
||
const baseH = Math.max(1, box.h()) + margin * 2;
|
||
let minX = box.min.x - margin;
|
||
let minY = box.min.y - margin;
|
||
let vbW = baseW;
|
||
let vbH = baseH;
|
||
const isColumnPattern = (model.patternName || '').toLowerCase().includes('column');
|
||
const targetClusters = 14; // ≈7ft at 2 clusters/ft
|
||
// When not in manual, pad short columns to a consistent scale; in manual keep true size so expanded spacing stays in view.
|
||
if (!model.manualMode && isColumnPattern && model.rowCount < targetClusters) {
|
||
const scaleFactor = targetClusters / Math.max(1, model.rowCount);
|
||
vbW = baseW * scaleFactor;
|
||
vbH = baseH * scaleFactor;
|
||
const cx = (box.min.x + box.max.x) / 2;
|
||
const cy = (box.min.y + box.max.y) / 2;
|
||
minX = cx - vbW / 2;
|
||
minY = cy - vbH / 2;
|
||
}
|
||
if (model.manualMode) {
|
||
// Keep the full column centered in manual mode; avoid upward bias that was hiding the top.
|
||
const lift = 0;
|
||
minY -= lift;
|
||
}
|
||
const vb = [ minX, minY, vbW, vbH ].join(' ');
|
||
|
||
const patternsDefs = [];
|
||
const maskDefs = [];
|
||
const SVG_PATTERN_ZOOM = 2.5;
|
||
const offset = (1 - SVG_PATTERN_ZOOM) / 2;
|
||
const OUTLINE_DARK = '#0f172a'; // matches chip text for light colors
|
||
const OUTLINE_LIGHT = '#f8fafc'; // matches chip text for dark colors
|
||
|
||
Object.entries(model.palette).forEach(([slot, colorInfo]) => {
|
||
if (colorInfo.image) {
|
||
patternsDefs.push(svg('pattern', {id: `classic-pattern-slot-${slot}`, patternContentUnits: 'objectBoundingBox', width: 1, height: 1},
|
||
[ svg('image', { href: colorInfo.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ]
|
||
));
|
||
}
|
||
});
|
||
manualImagePatterns.forEach((id, href) => {
|
||
patternsDefs.push(svg('pattern', {id, patternContentUnits: 'objectBoundingBox', width: 1, height: 1},
|
||
[ svg('image', { href, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ]
|
||
));
|
||
});
|
||
if (model.topperColor.image) {
|
||
patternsDefs.push(svg('pattern', {id: 'classic-pattern-topper', patternContentUnits: 'objectBoundingBox', width: 1, height: 1},
|
||
[ svg('image', { href: model.topperColor.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ]
|
||
));
|
||
}
|
||
Object.entries(numberTopperShapes).forEach(([key, shape]) => {
|
||
const base = shape.base || {};
|
||
if (!base.image) return;
|
||
const w = base.width || 1, h = base.height || 1;
|
||
const maskId = base.maskId || `classic-num-mask-${key.replace('topper-num-', '')}`;
|
||
maskDefs.push(svg('mask', { id: maskId, maskUnits: 'userSpaceOnUse', x: -w/2, y: -h/2, width: w, height: h }, [
|
||
svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet' })
|
||
]));
|
||
});
|
||
const svgDefs = svg('defs', {}, [
|
||
// Tint: use source alpha to clip topper color, then reapply ink.
|
||
svg('filter', { id: 'classic-num-tint', 'color-interpolation-filters': 'sRGB', x: '-10%', y: '-10%', width: '120%', height: '120%' }, [
|
||
svg('feColorMatrix', { in: 'SourceGraphic', type: 'matrix', values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0', result: 'alpha' }),
|
||
svg('feFlood', { 'flood-color': model.topperColor?.hex || '#ffffff', result: 'tint' }),
|
||
svg('feComposite', { in: 'tint', in2: 'alpha', operator: 'in', result: 'fill' }),
|
||
svg('feComposite', { in: 'SourceGraphic', in2: 'alpha', operator: 'in', result: 'ink' }),
|
||
svg('feMerge', {}, [
|
||
svg('feMergeNode', { in: 'fill' }),
|
||
svg('feMergeNode', { in: 'ink' })
|
||
])
|
||
]),
|
||
svg('filter', { id: 'classic-num-outline-dark', 'color-interpolation-filters': 'sRGB', x: '-30%', y: '-30%', width: '160%', height: '160%' }, [
|
||
// Heavy outer stroke
|
||
svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 9, result: 'spreadOuter' }),
|
||
svg('feComposite', { in: 'spreadOuter', in2: 'SourceAlpha', operator: 'out', result: 'strokeOuter' }),
|
||
svg('feFlood', { 'flood-color': OUTLINE_DARK, 'flood-opacity': 1, result: 'strokeOuterColor' }),
|
||
svg('feComposite', { in: 'strokeOuterColor', in2: 'strokeOuter', operator: 'in', result: 'coloredStrokeOuter' }),
|
||
// Inner reinforcement to avoid gaps
|
||
svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 4.8, result: 'spreadInner' }),
|
||
svg('feComposite', { in: 'spreadInner', in2: 'SourceAlpha', operator: 'out', result: 'strokeInner' }),
|
||
svg('feFlood', { 'flood-color': OUTLINE_DARK, 'flood-opacity': 1, result: 'strokeInnerColor' }),
|
||
svg('feComposite', { in: 'strokeInnerColor', in2: 'strokeInner', operator: 'in', result: 'coloredStrokeInner' }),
|
||
svg('feMerge', {}, [
|
||
svg('feMergeNode', { in: 'coloredStrokeOuter' }),
|
||
svg('feMergeNode', { in: 'coloredStrokeInner' }),
|
||
svg('feMergeNode', { in: 'SourceGraphic' })
|
||
])
|
||
]),
|
||
svg('filter', { id: 'classic-num-outline-light', 'color-interpolation-filters': 'sRGB', x: '-30%', y: '-30%', width: '160%', height: '160%' }, [
|
||
// Heavy outer stroke
|
||
svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 9, result: 'spreadOuter' }),
|
||
svg('feComposite', { in: 'spreadOuter', in2: 'SourceAlpha', operator: 'out', result: 'strokeOuter' }),
|
||
svg('feFlood', { 'flood-color': OUTLINE_LIGHT, 'flood-opacity': 1, result: 'strokeOuterColor' }),
|
||
svg('feComposite', { in: 'strokeOuterColor', in2: 'strokeOuter', operator: 'in', result: 'coloredStrokeOuter' }),
|
||
// Inner reinforcement to avoid gaps
|
||
svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 4.8, result: 'spreadInner' }),
|
||
svg('feComposite', { in: 'spreadInner', in2: 'SourceAlpha', operator: 'out', result: 'strokeInner' }),
|
||
svg('feFlood', { 'flood-color': OUTLINE_LIGHT, 'flood-opacity': 1, result: 'strokeInnerColor' }),
|
||
svg('feComposite', { in: 'strokeInnerColor', in2: 'strokeInner', operator: 'in', result: 'coloredStrokeInner' }),
|
||
svg('feMerge', {}, [
|
||
svg('feMergeNode', { in: 'coloredStrokeOuter' }),
|
||
svg('feMergeNode', { in: 'coloredStrokeInner' }),
|
||
svg('feMergeNode', { in: 'SourceGraphic' })
|
||
])
|
||
]),
|
||
...maskDefs,
|
||
...patternsDefs
|
||
]);
|
||
|
||
const mainGroup = svg('g', null, kids);
|
||
const zoomPercent = classicZoom * 100;
|
||
m.render(container, svg('svg', {
|
||
xmlns: 'http://www.w3.org/2000/svg',
|
||
width:'100%',
|
||
height:'100%',
|
||
viewBox: vb,
|
||
preserveAspectRatio:'xMidYMid meet',
|
||
style: `isolation:isolate; width:${zoomPercent}%; height:${zoomPercent}%; min-width:${zoomPercent}%; min-height:${zoomPercent}%; transform-origin:center center;`
|
||
}, [svgDefs, mainGroup]));
|
||
}
|
||
|
||
function makeController(displayEl){
|
||
const models = [];
|
||
function buildModel(name){
|
||
const pattern = patterns[name];
|
||
if (patterns['Column 4']) patterns['Column 4']._reverse = reverse;
|
||
if (patterns['Arch 4']) patterns['Arch 4']._reverse = reverse;
|
||
if (patterns['Column 5']) patterns['Column 5']._reverse = reverse;
|
||
if (patterns['Arch 5']) patterns['Arch 5']._reverse = reverse;
|
||
|
||
const model = {
|
||
patternName: name,
|
||
pattern,
|
||
cells: [],
|
||
rowCount: clusters,
|
||
palette: buildClassicPalette(),
|
||
topperColor: getTopperColor(),
|
||
topperType,
|
||
shineEnabled,
|
||
manualMode,
|
||
manualFocusEnabled,
|
||
manualFloatingQuad,
|
||
explodedScale,
|
||
explodedGapPx,
|
||
explodedStaggerPx,
|
||
manualFocusStart,
|
||
manualFocusSize
|
||
};
|
||
const rows = pattern.cellsPerRow * model.rowCount, cols = pattern.cellsPerColumn;
|
||
for (let y=0; y<rows; y++){
|
||
let balloonIndexInCluster = 0;
|
||
for (let x=0; x<cols; x++) {
|
||
const cellData = pattern.createCell(x,y);
|
||
if (cellData) model.cells.push({ ...cellData, x, y, balloonIndexInCluster: balloonIndexInCluster++ });
|
||
}
|
||
}
|
||
if (name.toLowerCase().includes('column') && topperEnabled) {
|
||
const shapeName = `topper-${topperType}`;
|
||
const originalShape = pattern.balloonShapes[shapeName];
|
||
if (originalShape) {
|
||
const shape = {...originalShape};
|
||
shape.size *= topperSizeMultiplier;
|
||
model.cells.push({ isTopper: true, shape, inflate: 0, x:0, y:rows });
|
||
}
|
||
}
|
||
return model;
|
||
}
|
||
function selectPattern(name){
|
||
const m = buildModel(name); models.push(m);
|
||
newGrid(m.pattern, m.cells, displayEl, m); return m;
|
||
}
|
||
return { selectPattern };
|
||
}
|
||
|
||
function roundedStarPath({ points = 5, outerR = 0.5, innerR = 0.22, round = 0.28, rotate = -90 }) {
|
||
const toRad = Math.PI / 180; const rot = rotate * toRad; const verts = [];
|
||
for (let i = 0; i < points * 2; i++) { const ang = rot + i * Math.PI / points; const R = (i % 2 === 0) ? outerR : innerR; verts.push([Math.cos(ang) * R, Math.sin(ang) * R]); }
|
||
const t = Math.max(0, Math.min(0.49, round));
|
||
const lerp = (a, b, u) => [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u];
|
||
let v0 = verts[0], v1 = verts[1]; let p0 = lerp(v0, v1, t);
|
||
let d = `M ${p0[0].toFixed(4)} ${p0[1].toFixed(4)}`;
|
||
for (let i = 0; i < verts.length; i++) { const v = verts[(i + 1) % verts.length], vNext = verts[(i + 2) % verts.length]; const p = lerp(v, vNext, t); d += ` Q ${v[0].toFixed(4)} ${v[1].toFixed(4)} ${p[0].toFixed(4)} ${p[1].toFixed(4)}`; }
|
||
return d + ' Z';
|
||
}
|
||
|
||
function roundedRectPath(cx, cy, w, h, r = 0.08) {
|
||
const x0 = cx - w / 2, x1 = cx + w / 2;
|
||
const y0 = cy - h / 2, y1 = cy + h / 2;
|
||
const rad = Math.min(r, w / 2, h / 2);
|
||
return [
|
||
`M ${x0 + rad} ${y0}`,
|
||
`H ${x1 - rad}`,
|
||
`Q ${x1} ${y0} ${x1} ${y0 + rad}`,
|
||
`V ${y1 - rad}`,
|
||
`Q ${x1} ${y1} ${x1 - rad} ${y1}`,
|
||
`H ${x0 + rad}`,
|
||
`Q ${x0} ${y1} ${x0} ${y1 - rad}`,
|
||
`V ${y0 + rad}`,
|
||
`Q ${x0} ${y0} ${x0 + rad} ${y0}`,
|
||
'Z'
|
||
].join(' ');
|
||
}
|
||
|
||
function buildNumberTopperShapes() {
|
||
// Reuse the exported SVG glyphs for numbers and recolor them via an alpha mask.
|
||
// ViewBox sizes vary a lot, so we normalize the aspect ratio with a shared base height.
|
||
const viewBoxes = {
|
||
'0': { w: 28.874041, h: 38.883382 },
|
||
'1': { w: 20.387968, h: 39.577327 },
|
||
'2': { w: 27.866452, h: 39.567209 },
|
||
'3': { w: 27.528916, h: 39.153201 },
|
||
'4': { w: 39.999867, h: 39.999867 },
|
||
'5': { w: 39.999867, h: 39.999867 },
|
||
'6': { w: 27.952782, h: 39.269062 },
|
||
'7': { w: 39.999867, h: 39.999867 },
|
||
'8': { w: 39.999867, h: 39.999867 },
|
||
'9': { w: 39.999867, h: 39.999867 }
|
||
};
|
||
|
||
const shapes = {};
|
||
const topperSize = 8.2; // closer to round topper scale
|
||
const baseHeight = 1.05;
|
||
|
||
Object.entries(NUMBER_IMAGE_MAP).forEach(([num, href]) => {
|
||
const vb = viewBoxes[num] || { w: 40, h: 40 };
|
||
const aspect = vb.w / Math.max(1, vb.h);
|
||
const height = baseHeight;
|
||
const width = height * aspect;
|
||
const radius = Math.max(width, height) / 2;
|
||
const maskId = `classic-num-mask-${num}`;
|
||
|
||
shapes[`topper-num-${num}`] = {
|
||
base: {
|
||
image: href,
|
||
width,
|
||
height,
|
||
preserveAspectRatio: 'xMidYMid meet',
|
||
maskId,
|
||
radius,
|
||
allowShine: false // keep number toppers matte; shine causes halo
|
||
},
|
||
size: topperSize
|
||
};
|
||
});
|
||
|
||
return shapes;
|
||
}
|
||
|
||
const numberTopperShapes = buildNumberTopperShapes();
|
||
|
||
// --- Column 4: This is the existing logic from classic.js, which matches your template file ---
|
||
patterns['Column 4'] = {
|
||
baseBalloonSize: 25, _reverse: false, balloonsPerCluster: 4,
|
||
balloonShapes: {
|
||
'front':{zIndex:4, base:{radius:0.5}, size:3}, 'front-inner':{zIndex:3, base:{radius:0.5}, size:3}, 'back-inner':{zIndex:2, base:{radius:0.5}, size:3}, 'back':{zIndex:1, base:{radius:0.5}, size:3},
|
||
'topper-round':{base:{type:'ellipse', radius:0.5, allowShine:true}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5, allowShine:false}, size:8}, 'topper-heart':{base:{type:'path', d:'M0,0.35 C-0.5,0, -0.14,-0.35, 0,-0.14 C0.14,-0.35, 0.5,0, 0,0.35 Z', radius:0.5, allowShine:false}, size:20},
|
||
...numberTopperShapes
|
||
},
|
||
tile: { size:{x:5,y:1} }, cellsPerRow: 1, cellsPerColumn: 5,
|
||
gridX(row, col){ return col + [0, -0.12, -0.24, -0.36, -0.48][col % 5]; },
|
||
gridY(row, col){ return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2)); },
|
||
createCell(x, y) {
|
||
const odd = !!(y % 2);
|
||
const A = ['front-inner','back','', 'front','back-inner'], B = ['back-inner', 'front','', 'back', 'front-inner'];
|
||
const arr = this._reverse ? (odd ? B : A) : (odd ? A : B);
|
||
const shapeName = arr[x % 5];
|
||
const shape = this.balloonShapes[shapeName];
|
||
return shape ? { shape:{...shape} } : null;
|
||
}
|
||
};
|
||
|
||
// --- Arch 4: This is the existing logic from classic.js, which matches your template file ---
|
||
patterns['Arch 4'] = {
|
||
deriveFrom: 'Column 4',
|
||
transform(point, col, row, model){
|
||
const len = this.gridY(model.rowCount*this.tile.size.y, 0) - this.gridY(0, 0);
|
||
const r = (len / Math.PI) + point.x;
|
||
const y = point.y - this.gridY(0, 0);
|
||
const a = Math.PI * (y / len);
|
||
return { x: -r*Math.cos(a), y: -r*Math.sin(a) };
|
||
}
|
||
};
|
||
// --- Column 5 (template geometry) ---
|
||
patterns['Column 5'] = {
|
||
baseBalloonSize: 25,
|
||
_reverse: false,
|
||
balloonsPerCluster: 5,
|
||
tile: { size: { x: 5, y: 1 } },
|
||
cellsPerRow: 1,
|
||
cellsPerColumn: 5,
|
||
balloonShapes: {
|
||
"front": { zIndex:5, base:{radius:0.5}, size:3.0 },
|
||
"front2": { zIndex:4, base:{radius:0.5}, size:3.0 },
|
||
"middle": { zIndex:3, base:{radius:0.5}, size:3.0 },
|
||
"middle2": { zIndex:2, base:{radius:0.5}, size:3.0 },
|
||
"back": { zIndex:1, base:{radius:0.5}, size:3.0 },
|
||
"back2": { zIndex:0, base:{radius:0.5}, size:3.0 },
|
||
'topper-round':{base:{type:'ellipse', radius:0.5, allowShine:true}, size:8},
|
||
'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5, allowShine:false}, size:8},
|
||
'topper-heart':{base:{type:'path', d:'M0,0.35 C-0.5,0, -0.14,-0.35, 0,-0.14 C0.14,-0.35, 0.5,0, 0,0.35 Z', radius:0.5, allowShine:false}, size:20},
|
||
...numberTopperShapes
|
||
},
|
||
gridX(row, col) {
|
||
var mid = 0.6;
|
||
return (0.9) * (col + (0 === col % 5 && -0.5) + (1 === col % 5 && -mid) + (3 === col % 5 && mid) + (4 === col % 5 && 0.5) - 0.5);
|
||
},
|
||
gridY(row, col){
|
||
return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2));
|
||
},
|
||
createCell(x, y) {
|
||
var yOdd = !!(y % 2);
|
||
const shapePattern = yOdd
|
||
? ['middle', 'back', 'front', 'back', 'middle']
|
||
: ['middle2', 'front2', 'back2', 'front2', 'middle2'];
|
||
var shapeName = shapePattern[x % 5];
|
||
var shape = this.balloonShapes[shapeName];
|
||
return shape ? { shape: {...shape} } : null;
|
||
}
|
||
};
|
||
|
||
// Arch 5 derives from Column 5
|
||
patterns['Arch 5'] = {
|
||
deriveFrom: 'Column 5',
|
||
transform(point, col, row, model){
|
||
const len = this.gridY(model.rowCount * this.tile.size.y, 0) - this.gridY(0, 0);
|
||
const r = (len / Math.PI) + point.x;
|
||
const y = point.y - this.gridY(0, 0);
|
||
const a = Math.PI * (y / len);
|
||
return { x: -r * Math.cos(a), y: -r * Math.sin(a) };
|
||
}
|
||
};
|
||
// --- END: MODIFIED SECTION ---
|
||
|
||
// --- Stacked variants (same geometry, single-color clusters alternating rows) ---
|
||
patterns['Arch 4 Stacked'] = { deriveFrom: 'Arch 4', colorMode: 'stacked' };
|
||
patterns['Arch 5 Stacked'] = { deriveFrom: 'Arch 5', colorMode: 'stacked' };
|
||
patterns['Column 4 Stacked'] = { deriveFrom: 'Column 4', colorMode: 'stacked' };
|
||
patterns['Column 5 Stacked'] = { deriveFrom: 'Column 5', colorMode: 'stacked' };
|
||
|
||
|
||
Object.keys(patterns).forEach(n => extend(patterns[n]));
|
||
return api;
|
||
}
|
||
|
||
const patternSlotCount = (name) => ((name || '').includes('5') ? 5 : 4);
|
||
function getStoredSlotCount() {
|
||
try {
|
||
const saved = parseInt(localStorage.getItem(SLOT_COUNT_KEY), 10);
|
||
if (Number.isFinite(saved) && saved > 0) return Math.min(saved, MAX_SLOTS);
|
||
} catch {}
|
||
return 5;
|
||
}
|
||
function setStoredSlotCount(n) {
|
||
const v = Math.max(1, Math.min(MAX_SLOTS, n|0));
|
||
try { localStorage.setItem(SLOT_COUNT_KEY, String(v)); } catch {}
|
||
return v;
|
||
}
|
||
|
||
function initClassicColorPicker(onColorChange) {
|
||
const slotsContainer = document.getElementById('classic-slots'), topperSwatch = document.getElementById('classic-topper-color-swatch'), swatchGrid = document.getElementById('classic-swatch-grid'), activeLabel = document.getElementById('classic-active-label'), randomizeBtn = document.getElementById('classic-randomize-colors'), addSlotBtn = document.getElementById('classic-add-slot'), activeChip = document.getElementById('classic-active-chip'), floatingChip = document.getElementById('classic-active-chip-floating'), activeDot = document.getElementById('classic-active-dot'), floatingDot = document.getElementById('classic-active-dot-floating');
|
||
const projectBlock = document.getElementById('classic-project-block');
|
||
const replaceBlock = document.getElementById('classic-replace-block');
|
||
const replaceFromSel = document.getElementById('classic-replace-from');
|
||
const replaceToSel = document.getElementById('classic-replace-to');
|
||
const replaceBtn = document.getElementById('classic-replace-btn');
|
||
const replaceMsg = document.getElementById('classic-replace-msg');
|
||
const replaceFromChip = document.getElementById('classic-replace-from-chip');
|
||
const replaceToChip = document.getElementById('classic-replace-to-chip');
|
||
const replaceCountLabel = document.getElementById('classic-replace-count');
|
||
const topperBlock = document.getElementById('classic-topper-color-block');
|
||
if (!slotsContainer || !topperSwatch || !swatchGrid) return;
|
||
topperSwatch.classList.add('tab-btn');
|
||
let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount();
|
||
const publishActiveTarget = () => {
|
||
window.ClassicDesigner = window.ClassicDesigner || {};
|
||
window.ClassicDesigner.activeSlot = activeTarget;
|
||
};
|
||
const isManual = () => !!(window.ClassicDesigner?.manualMode);
|
||
const manualColor = () => manualActiveColorGlobal || (manualActiveColorGlobal = (window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }));
|
||
const openManualPicker = () => {
|
||
if (!isManual()) return false;
|
||
if (typeof window.openColorPicker !== 'function') return false;
|
||
const all = flattenPalette().map(c => ({
|
||
label: c.name || c.hex,
|
||
hex: c.hex,
|
||
meta: c,
|
||
metaText: c.family || ''
|
||
}));
|
||
window.openColorPicker({
|
||
title: 'Pick a color',
|
||
subtitle: 'Apply to active paint',
|
||
items: all,
|
||
onSelect: (item) => {
|
||
const meta = item.meta || {};
|
||
manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: meta.hex || item.hex, image: meta.image || null });
|
||
updateUI();
|
||
onColorChange();
|
||
}
|
||
});
|
||
return true;
|
||
};
|
||
|
||
function visibleSlotCount() {
|
||
const patSelect = document.getElementById('classic-pattern');
|
||
const name = patSelect?.value || 'Arch 4';
|
||
const baseCount = patternSlotCount(name);
|
||
const isStacked = (name || '').toLowerCase().includes('stacked');
|
||
if (!isStacked) return baseCount;
|
||
const lengthInp = document.getElementById('classic-length-ft');
|
||
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
|
||
const maxSlots = Math.min(MAX_SLOTS, clusters);
|
||
return Math.min(Math.max(baseCount, slotCount), maxSlots);
|
||
}
|
||
|
||
function renderSlots() {
|
||
slotsContainer.innerHTML = '';
|
||
const count = visibleSlotCount();
|
||
for (let i = 1; i <= count; i++) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'slot-btn tab-btn';
|
||
btn.dataset.slot = String(i);
|
||
btn.textContent = `#${i}`;
|
||
btn.addEventListener('click', () => { activeTarget = String(i); updateUI(); openPalettePicker(); });
|
||
slotsContainer.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
function enforceSlotVisibility() {
|
||
const count = visibleSlotCount();
|
||
if (parseInt(activeTarget, 10) > count) activeTarget = '1';
|
||
renderSlots();
|
||
}
|
||
const allPaletteColors = flattenPalette();
|
||
const labelForColor = (color) => {
|
||
const hex = normHex(color?.hex || color?.colour || '');
|
||
const image = color?.image || '';
|
||
const byImage = image ? allPaletteColors.find(c => c.image === image) : null;
|
||
const byHex = hex ? allPaletteColors.find(c => c.hex === hex) : null;
|
||
return color?.name || byImage?.name || byHex?.name || hex || (image ? 'Texture' : 'Current');
|
||
};
|
||
|
||
const colorKeyFromVal = (val) => {
|
||
const palette = buildClassicPalette();
|
||
let hex = null, image = null;
|
||
if (val && typeof val === 'object') {
|
||
hex = normHex(val.hex || val.colour || '');
|
||
image = val.image || null;
|
||
} else if (typeof val === 'number') {
|
||
const info = palette[val] || null;
|
||
hex = normHex(info?.colour || info?.hex || '');
|
||
image = info?.image || null;
|
||
}
|
||
const cleanedHex = (hex === 'transparent' || hex === 'none') ? '' : (hex || '');
|
||
const key = (image || cleanedHex) ? `${image || ''}|${cleanedHex}` : '';
|
||
return { hex: cleanedHex, image: image || null, key };
|
||
};
|
||
|
||
const manualUsage = () => {
|
||
if (!manualModeState) return [];
|
||
const palette = buildClassicPalette();
|
||
const map = new Map();
|
||
const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]'));
|
||
cells.forEach(g => {
|
||
const match = g.id.match(/balloon_(\d+)_(\d+)/);
|
||
if (!match) return;
|
||
const x = parseInt(match[1], 10);
|
||
const y = parseInt(match[2], 10);
|
||
const override = getManualOverride(currentPatternName, currentRowCount, x, y);
|
||
const code = parseInt(g.getAttribute('data-color-code') || '0', 10);
|
||
const base = palette[code] || { hex: '#ffffff', image: null };
|
||
const fill = override || base;
|
||
const { hex, image, key: k } = colorKeyFromVal(fill);
|
||
if (!k) return;
|
||
const existing = map.get(k) || { hex, image, count: 0 };
|
||
existing.count += 1;
|
||
map.set(k, existing);
|
||
});
|
||
return Array.from(map.values());
|
||
};
|
||
|
||
const setReplaceChip = (chip, color) => {
|
||
if (!chip) return;
|
||
if (color?.image) {
|
||
chip.style.backgroundImage = `url("${color.image}")`;
|
||
chip.style.backgroundSize = 'cover';
|
||
chip.style.backgroundColor = color.hex || '#fff';
|
||
} else {
|
||
chip.style.backgroundImage = 'none';
|
||
chip.style.backgroundColor = color?.hex || '#f1f5f9';
|
||
}
|
||
};
|
||
|
||
const populateReplaceTo = () => {
|
||
if (!replaceToSel) return;
|
||
replaceToSel.innerHTML = '';
|
||
allPaletteColors.forEach((c, idx) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(idx);
|
||
opt.textContent = c.name || c.hex || (c.image ? 'Texture' : 'Color');
|
||
replaceToSel.appendChild(opt);
|
||
});
|
||
};
|
||
|
||
const updateReplaceChips = () => {
|
||
if (!replaceFromSel || !replaceToSel) return 0;
|
||
if (!manualModeState) {
|
||
replaceFromSel.innerHTML = '';
|
||
setReplaceChip(replaceFromChip, { hex: '#f8fafc' });
|
||
setReplaceChip(replaceToChip, { hex: '#f8fafc' });
|
||
if (replaceCountLabel) replaceCountLabel.textContent = '';
|
||
if (replaceMsg) replaceMsg.textContent = 'Manual paint only.';
|
||
return 0;
|
||
}
|
||
const usage = manualUsage();
|
||
replaceFromSel.innerHTML = '';
|
||
usage.forEach(u => {
|
||
const opt = document.createElement('option');
|
||
opt.value = `${u.image || ''}|${u.hex || ''}`;
|
||
const labelHex = u.hex || (u.image ? 'Texture' : 'Color');
|
||
opt.textContent = `${labelHex} (${u.count})`;
|
||
replaceFromSel.appendChild(opt);
|
||
});
|
||
if (!replaceFromSel.value && usage.length) replaceFromSel.value = `${usage[0].image || ''}|${usage[0].hex || ''}`;
|
||
if (!replaceToSel.value && replaceToSel.options.length) replaceToSel.value = replaceToSel.options[0].value;
|
||
|
||
const toIdx = parseInt(replaceToSel.value || '-1', 10);
|
||
const toMeta = Number.isInteger(toIdx) && toIdx >= 0 ? allPaletteColors[toIdx] : null;
|
||
const fromVal = replaceFromSel.value || '';
|
||
const fromParts = fromVal.split('|');
|
||
const fromColor = { image: fromParts[0] || null, hex: fromParts[1] || '' };
|
||
setReplaceChip(replaceFromChip, fromColor);
|
||
setReplaceChip(replaceToChip, toMeta ? { hex: toMeta.hex || '#f1f5f9', image: toMeta.image || null } : { hex: '#f1f5f9' });
|
||
|
||
// count matches
|
||
let count = 0;
|
||
if (fromVal) {
|
||
const usage = manualUsage();
|
||
usage.forEach(u => { if (`${u.image || ''}|${u.hex || ''}` === fromVal) count += u.count; });
|
||
}
|
||
if (replaceCountLabel) replaceCountLabel.textContent = count ? `${count} match${count === 1 ? '' : 'es'}` : '0 matches';
|
||
if (replaceMsg) replaceMsg.textContent = usage.length ? '' : 'Paint something first to replace.';
|
||
return count;
|
||
};
|
||
|
||
const openReplacePicker = (mode = 'from') => {
|
||
if (!window.openColorPicker) return;
|
||
if (mode === 'from') {
|
||
const usage = manualUsage();
|
||
const items = usage.map(u => ({
|
||
label: u.hex || (u.image ? 'Texture' : 'Color'),
|
||
metaText: `${u.count} in design`,
|
||
value: `${u.image || ''}|${u.hex || ''}`,
|
||
hex: u.hex || '#ffffff',
|
||
meta: { image: u.image, hex: u.hex || '#ffffff' },
|
||
image: u.image
|
||
}));
|
||
window.openColorPicker({
|
||
title: 'Replace: From color',
|
||
subtitle: 'Pick a color already on canvas',
|
||
items,
|
||
onSelect: (item) => {
|
||
if (!replaceFromSel) return;
|
||
replaceFromSel.value = item.value;
|
||
updateReplaceChips();
|
||
}
|
||
});
|
||
} else {
|
||
const items = allPaletteColors.map((c, idx) => ({
|
||
label: c.name || c.hex || (c.image ? 'Texture' : 'Color'),
|
||
metaText: c.family || '',
|
||
idx
|
||
}));
|
||
window.openColorPicker({
|
||
title: 'Replace: To color',
|
||
subtitle: 'Choose a library color',
|
||
items,
|
||
onSelect: (item) => {
|
||
if (!replaceToSel) return;
|
||
replaceToSel.value = String(item.idx);
|
||
updateReplaceChips();
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
renderProjectPalette = function renderProjectPaletteFn() {
|
||
if (!projectPaletteBox) return;
|
||
projectPaletteBox.innerHTML = '';
|
||
if (!manualModeState) {
|
||
projectPaletteBox.innerHTML = '<div class="hint text-xs">Enter Manual paint to see colors used.</div>';
|
||
return;
|
||
}
|
||
const used = manualUsedColorsFor(currentPatternName, currentRowCount);
|
||
if (!used.length) {
|
||
projectPaletteBox.innerHTML = '<div class="hint text-xs">Paint to build a project palette.</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 = '500%';
|
||
sw.style.backgroundPosition = 'center';
|
||
sw.style.backgroundColor = item.hex || '#fff';
|
||
} else {
|
||
sw.style.backgroundColor = item.hex || '#fff';
|
||
}
|
||
sw.title = item.label || item.hex || 'Color';
|
||
sw.addEventListener('click', () => {
|
||
manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: item.hex || '#ffffff', image: item.image || null }) || { hex: item.hex || '#ffffff', image: item.image || null };
|
||
updateClassicDesign();
|
||
});
|
||
row.appendChild(sw);
|
||
});
|
||
projectPaletteBox.appendChild(row);
|
||
}
|
||
|
||
function updateUI() {
|
||
enforceSlotVisibility();
|
||
const buttons = Array.from(slotsContainer.querySelectorAll('.slot-btn'));
|
||
[...buttons, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); });
|
||
buttons.forEach(el => el.classList.toggle('slot-active', activeTarget === el.dataset.slot));
|
||
publishActiveTarget();
|
||
|
||
buttons.forEach((slot, i) => {
|
||
const color = classicColors[i];
|
||
if (!color) return; // Safeguard against errors
|
||
slot.style.backgroundImage = color.image ? `url("${color.image}")` : 'none';
|
||
slot.style.backgroundColor = color.hex;
|
||
slot.style.backgroundSize = '200%';
|
||
slot.style.backgroundPosition = 'center';
|
||
const txt = textStyleForColor(color);
|
||
slot.style.color = txt.color;
|
||
slot.style.textShadow = txt.shadow;
|
||
});
|
||
|
||
const topperColor = getTopperColor();
|
||
const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round';
|
||
topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none';
|
||
topperSwatch.style.backgroundBlendMode = 'normal';
|
||
topperSwatch.style.backgroundColor = topperColor.hex;
|
||
topperSwatch.style.backgroundSize = '200%';
|
||
topperSwatch.style.backgroundPosition = 'center';
|
||
const topperTxt = textStyleForColor({ hex: topperColor.hex, image: topperColor.image });
|
||
topperSwatch.style.color = topperTxt.color;
|
||
topperSwatch.style.textShadow = topperTxt.shadow;
|
||
const patName = (document.getElementById('classic-pattern')?.value || '').toLowerCase();
|
||
const topperEnabled = document.getElementById('classic-topper-enabled')?.checked;
|
||
const showTopperColor = patName.includes('column') && (patName.includes('4') || patName.includes('5')) && topperEnabled;
|
||
if (topperBlock) topperBlock.classList.toggle('hidden', !showTopperColor);
|
||
|
||
const patSelect = document.getElementById('classic-pattern');
|
||
const isStacked = (patSelect?.value || '').toLowerCase().includes('stacked');
|
||
if (addSlotBtn) {
|
||
const lengthInp = document.getElementById('classic-length-ft');
|
||
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
|
||
const maxSlots = Math.min(MAX_SLOTS, clusters);
|
||
addSlotBtn.classList.toggle('hidden', !isStacked);
|
||
addSlotBtn.disabled = !isStacked || slotCount >= maxSlots;
|
||
}
|
||
|
||
const manualModeOn = isManual();
|
||
const sharedActive = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null };
|
||
if (activeChip) {
|
||
const idx = parseInt(activeTarget, 10) - 1;
|
||
const color = manualModeOn ? sharedActive : (classicColors[idx] || { hex: '#ffffff', image: null });
|
||
activeChip.setAttribute('title', manualModeOn ? 'Active color' : `Slot #${activeTarget}`);
|
||
const bgImg = color.image ? `url(\"${color.image}\")` : 'none';
|
||
const bgCol = color.hex || '#ffffff';
|
||
activeChip.style.backgroundImage = bgImg;
|
||
activeChip.style.backgroundColor = bgCol;
|
||
activeChip.style.backgroundSize = color.image ? '200%' : 'cover';
|
||
activeChip.style.backgroundPosition = 'center';
|
||
const txt = textStyleForColor({ hex: color.hex || '#ffffff', image: color.image });
|
||
activeChip.style.color = txt.color;
|
||
activeChip.style.textShadow = txt.shadow;
|
||
if (activeLabel) {
|
||
activeLabel.textContent = labelForColor(color);
|
||
activeLabel.style.color = txt.color;
|
||
activeLabel.style.textShadow = txt.shadow;
|
||
}
|
||
if (activeDot) {
|
||
activeDot.style.backgroundImage = bgImg;
|
||
activeDot.style.backgroundColor = bgCol;
|
||
}
|
||
if (floatingChip) {
|
||
floatingChip.style.backgroundImage = bgImg;
|
||
floatingChip.style.backgroundColor = bgCol;
|
||
if (floatingDot) {
|
||
floatingDot.style.backgroundImage = bgImg;
|
||
floatingDot.style.backgroundColor = bgCol;
|
||
}
|
||
floatingChip.style.display = manualModeOn ? '' : 'none';
|
||
}
|
||
}
|
||
if (slotsContainer) {
|
||
const row = slotsContainer.parentElement;
|
||
if (row) row.style.display = manualModeOn ? 'none' : '';
|
||
if (addSlotBtn) addSlotBtn.style.display = manualModeOn ? 'none' : '';
|
||
}
|
||
if (activeChip) {
|
||
activeChip.style.display = manualModeOn ? '' : 'none';
|
||
}
|
||
if (projectBlock) projectBlock.classList.toggle('hidden', !manualModeOn);
|
||
if (replaceBlock) replaceBlock.classList.toggle('hidden', !manualModeOn);
|
||
}
|
||
|
||
swatchGrid.innerHTML = '';
|
||
swatchGrid.style.display = 'none'; // hide inline list; use modal picker instead
|
||
|
||
const openPalettePicker = () => {
|
||
if (typeof window.openColorPicker !== 'function') return;
|
||
const items = allPaletteColors.map(c => ({
|
||
label: c.name || c.hex,
|
||
hex: c.hex,
|
||
meta: c,
|
||
metaText: c.family || ''
|
||
}));
|
||
const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round';
|
||
const subtitle = isManual()
|
||
? 'Apply to active paint'
|
||
: (activeTarget === 'T' ? 'Set topper color' : `Set slot #${activeTarget}`);
|
||
window.openColorPicker({
|
||
title: 'Choose a color',
|
||
subtitle,
|
||
items,
|
||
onSelect: (item) => {
|
||
const meta = item.meta || {};
|
||
const selectedColor = { hex: meta.hex || item.hex, image: meta.image || null };
|
||
if (activeTarget === 'T') {
|
||
setTopperColor(selectedColor);
|
||
} else if (isManual()) {
|
||
manualActiveColorGlobal = window.shared?.setActiveColor?.(selectedColor) || selectedColor;
|
||
} else {
|
||
const index = parseInt(activeTarget, 10) - 1;
|
||
if (index >= 0 && index < MAX_SLOTS) { classicColors[index] = selectedColor; setClassicColors(classicColors); }
|
||
}
|
||
updateUI(); onColorChange();
|
||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||
}
|
||
});
|
||
};
|
||
|
||
topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); openPalettePicker(); });
|
||
activeChip?.addEventListener('click', () => {
|
||
openPalettePicker();
|
||
});
|
||
floatingChip?.addEventListener('click', () => {
|
||
openPalettePicker();
|
||
});
|
||
randomizeBtn?.addEventListener('click', () => {
|
||
if (isManual() && window.ClassicDesigner?.randomizeManualFromPalette) {
|
||
const applied = window.ClassicDesigner.randomizeManualFromPalette();
|
||
if (applied) return;
|
||
}
|
||
const pool = allPaletteColors.slice(); const picks = [];
|
||
const colorCount = visibleSlotCount();
|
||
for (let i = 0; i < colorCount && pool.length; i++) { picks.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); }
|
||
classicColors = setClassicColors(picks.map(c => ({ hex: c.hex, image: c.image })));
|
||
updateUI(); onColorChange();
|
||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||
});
|
||
addSlotBtn?.addEventListener('click', () => {
|
||
const patSelect = document.getElementById('classic-pattern');
|
||
const name = patSelect?.value || '';
|
||
const isStacked = name.toLowerCase().includes('stacked');
|
||
if (!isStacked) return;
|
||
const lengthInp = document.getElementById('classic-length-ft');
|
||
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
|
||
const maxSlots = Math.min(MAX_SLOTS, clusters);
|
||
if (slotCount >= maxSlots) return;
|
||
slotCount = setStoredSlotCount(slotCount + 1);
|
||
while (classicColors.length < slotCount) {
|
||
const fallback = allPaletteColors[Math.floor(Math.random() * allPaletteColors.length)] || { hex: '#ffffff', image: null };
|
||
classicColors.push({ hex: fallback.hex, image: fallback.image });
|
||
}
|
||
setClassicColors(classicColors);
|
||
updateUI(); onColorChange();
|
||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||
});
|
||
replaceFromChip?.addEventListener('click', () => openReplacePicker('from'));
|
||
replaceToChip?.addEventListener('click', () => openReplacePicker('to'));
|
||
replaceFromSel?.addEventListener('change', updateReplaceChips);
|
||
replaceToSel?.addEventListener('change', updateReplaceChips);
|
||
replaceBtn?.addEventListener('click', () => {
|
||
if (!manualModeState) { if (replaceMsg) replaceMsg.textContent = 'Manual paint only.'; return; }
|
||
const fromKey = replaceFromSel?.value || '';
|
||
const toIdx = parseInt(replaceToSel?.value || '-1', 10);
|
||
if (!fromKey || Number.isNaN(toIdx) || toIdx < 0 || toIdx >= allPaletteColors.length) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; }
|
||
const toMeta = allPaletteColors[toIdx];
|
||
const key = manualKey(currentPatternName, currentRowCount);
|
||
const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null;
|
||
if (!manualOverrides[key]) manualOverrides[key] = {};
|
||
const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]'));
|
||
const palette = buildClassicPalette();
|
||
let count = 0;
|
||
cells.forEach(g => {
|
||
const match = g.id.match(/balloon_(\d+)_(\d+)/);
|
||
if (!match) return;
|
||
const x = parseInt(match[1], 10);
|
||
const y = parseInt(match[2], 10);
|
||
const override = getManualOverride(currentPatternName, currentRowCount, x, y);
|
||
const code = parseInt(g.getAttribute('data-color-code') || '0', 10);
|
||
const base = palette[code] || { hex: '#ffffff', image: null };
|
||
const fill = override || base;
|
||
if (colorKeyFromVal(fill).key === fromKey) {
|
||
manualOverrides[key][`${x},${y}`] = {
|
||
hex: normHex(toMeta.hex || toMeta.colour || '#ffffff'),
|
||
image: toMeta.image || null
|
||
};
|
||
count++;
|
||
}
|
||
});
|
||
if (!count) { if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.'; return; }
|
||
saveManualOverrides(manualOverrides);
|
||
manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prevSnapshot });
|
||
manualRedoStack.length = 0;
|
||
if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`;
|
||
onColorChange();
|
||
updateReplaceChips();
|
||
});
|
||
|
||
populateReplaceTo();
|
||
updateUI();
|
||
updateReplaceChips();
|
||
return () => { updateUI(); updateReplaceChips(); };
|
||
}
|
||
|
||
function initClassic() {
|
||
try {
|
||
if (typeof window.m === 'undefined') return fail('Mithril not loaded');
|
||
projectPaletteBox = null;
|
||
const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), borderEnabledCb = document.getElementById('classic-border-enabled'), manualModeBtn = document.getElementById('classic-manual-btn'), expandedToggleRow = document.getElementById('classic-expanded-row'), expandedToggle = document.getElementById('classic-expanded-toggle'), focusRow = document.getElementById('classic-focus-row'), focusPrev = document.getElementById('classic-focus-prev'), focusNext = document.getElementById('classic-focus-next'), focusLabel = document.getElementById('classic-focus-label'), floatingBar = document.getElementById('classic-mobile-bar'), floatingChip = document.getElementById('classic-active-chip-floating'), floatingUndo = document.getElementById('classic-undo-manual'), floatingRedo = document.getElementById('classic-redo-manual'), floatingPick = document.getElementById('classic-pick-manual'), floatingErase = document.getElementById('classic-erase-manual'), floatingClear = document.getElementById('classic-clear-manual'), floatingExport = document.getElementById('classic-export-manual'), quadReset = document.getElementById('classic-quad-reset'), focusZoomOut = document.getElementById('classic-focus-zoomout'), manualHub = document.getElementById('classic-manual-hub'), manualRange = document.getElementById('classic-manual-range'), manualRangeLabel = document.getElementById('classic-manual-range-label'), manualPrevBtn = document.getElementById('classic-manual-prev'), manualNextBtn = document.getElementById('classic-manual-next'), manualFullBtn = document.getElementById('classic-manual-full'), manualFocusBtn = document.getElementById('classic-manual-focus'), manualDetailDisplay = document.getElementById('classic-manual-detail-display');
|
||
const nudgeOpenBtn = document.getElementById('classic-nudge-open');
|
||
const fullscreenBtn = document.getElementById('app-fullscreen-toggle');
|
||
const toolbar = document.getElementById('classic-canvas-toolbar');
|
||
const toolbarPrev = document.getElementById('classic-toolbar-prev');
|
||
const toolbarNext = document.getElementById('classic-toolbar-next');
|
||
const toolbarZoomOut = document.getElementById('classic-toolbar-zoomout');
|
||
const toolbarReset = document.getElementById('classic-toolbar-reset');
|
||
const focusLabelCanvas = document.getElementById('classic-focus-label-canvas');
|
||
const reverseLabel = reverseCb?.closest('label');
|
||
const reverseHint = reverseLabel?.parentElement?.querySelector('.hint');
|
||
const quadModal = document.getElementById('classic-quad-modal');
|
||
const quadModalClose = document.getElementById('classic-quad-modal-close');
|
||
const quadModalDisplay = document.getElementById('classic-quad-modal-display');
|
||
const patternShapeBtns = Array.from(document.querySelectorAll('[data-pattern-shape]'));
|
||
const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]'));
|
||
const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]'));
|
||
const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper'));
|
||
const topperTypeButtons = Array.from(document.querySelectorAll('.topper-type-btn'));
|
||
const topperSizeInput = document.getElementById('classic-topper-size');
|
||
const slotsContainer = document.getElementById('classic-slots');
|
||
projectPaletteBox = document.getElementById('classic-project-palette');
|
||
const manualPaletteBtn = document.getElementById('classic-manual-palette');
|
||
let topperOffsetX = 0, topperOffsetY = 0;
|
||
let lastPresetKey = null; // 'custom' means user-tweaked; otherwise `${pattern}:${type}`
|
||
window.ClassicDesigner = window.ClassicDesigner || {};
|
||
window.ClassicDesigner.lastTopperType = window.ClassicDesigner.lastTopperType || 'round';
|
||
window.ClassicDesigner.resetFloatingQuad = () => { manualFloatingQuad = null; updateClassicDesign(); };
|
||
let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral', lastNonManualLayout = 'spiral';
|
||
manualModeState = loadManualMode();
|
||
let manualExpandedState = loadManualExpanded();
|
||
let manualFocusEnabled = false; // start with full design visible; focus toggles when user targets a cluster
|
||
manualActiveColorGlobal = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null };
|
||
currentPatternName = '';
|
||
currentRowCount = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
|
||
let manualFocusStart = 0;
|
||
const manualFocusSize = 8;
|
||
manualUndoStack = [];
|
||
manualRedoStack = [];
|
||
let manualTool = 'paint'; // paint | pick | erase
|
||
let manualFloatingQuad = null;
|
||
let quadModalRow = null;
|
||
let quadModalStartRect = null;
|
||
let manualDetailRow = 0;
|
||
let manualDetailFrame = null;
|
||
classicZoom = 1;
|
||
window.ClassicDesigner = window.ClassicDesigner || {};
|
||
window.ClassicDesigner.randomizeManualFromPalette = () => {
|
||
if (!manualModeState) return false;
|
||
const used = manualUsedColorsFor(currentPatternName, currentRowCount);
|
||
const source = (used.length ? used : flattenPalette().map(c => ({ hex: c.hex, image: c.image || null }))).filter(Boolean);
|
||
if (!source.length) return false;
|
||
const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]'));
|
||
if (!cells.length) return false;
|
||
const key = manualKey(currentPatternName, currentRowCount);
|
||
const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null;
|
||
manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prevSnapshot });
|
||
manualRedoStack.length = 0;
|
||
manualOverrides[key] = {};
|
||
cells.forEach(g => {
|
||
const match = g.id.match(/balloon_(\d+)_(\d+)/);
|
||
if (!match) return;
|
||
const pick = source[Math.floor(Math.random() * source.length)] || { hex: '#ffffff', image: null };
|
||
manualOverrides[key][`${parseInt(match[1], 10)},${parseInt(match[2], 10)}`] = {
|
||
hex: normHex(pick.hex || pick.colour || '#ffffff'),
|
||
image: pick.image || null
|
||
};
|
||
});
|
||
saveManualOverrides(manualOverrides);
|
||
updateClassicDesign();
|
||
scheduleManualDetail();
|
||
return true;
|
||
};
|
||
// Force UI to reflect initial manual state
|
||
if (manualModeState) patternLayout = 'manual';
|
||
const topperPresets = {
|
||
'Column 4:heart': { enabled: true, offsetX: 3, offsetY: -10.5, size: 1.05 },
|
||
'Column 4:star': { enabled: true, offsetX: 3, offsetY: -7.5, size: 1.65 },
|
||
'Column 4:round': { enabled: true, offsetX: 3, offsetY: -2, size: 1.25 },
|
||
'Column 4:number': { enabled: true, offsetX: 3, offsetY: -7, size: 1.05 },
|
||
'Column 5:heart': { enabled: true, offsetX: 2, offsetY: -10, size: 1.15 },
|
||
'Column 5:star': { enabled: true, offsetX: 2.5, offsetY: -7.5, size: 1.75 },
|
||
'Column 5:round': { enabled: true, offsetX: 2.5, offsetY: -2, size: 1.3 },
|
||
'Column 5:number': { enabled: true, offsetX: 2.5, offsetY: -6.5, size: 1.05 }
|
||
};
|
||
if (!display) return fail('#classic-display not found');
|
||
const GC = GridCalculator(), ctrl = GC.controller(display);
|
||
if (topperSizeInput) {
|
||
const val = Math.max(0.5, Math.min(2, parseFloat(topperSizeInput.value) || 1));
|
||
GC.setTopperSize(val);
|
||
topperSizeInput.addEventListener('input', () => {
|
||
const next = Math.max(0.5, Math.min(2, parseFloat(topperSizeInput.value) || 1));
|
||
GC.setTopperSize(next);
|
||
updateClassicDesign();
|
||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||
});
|
||
}
|
||
let refreshClassicPaletteUi = null;
|
||
|
||
const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round';
|
||
const setTopperType = (type) => {
|
||
topperTypeButtons.forEach(btn => {
|
||
const active = btn.dataset.type === type;
|
||
btn.setAttribute('aria-pressed', String(active));
|
||
btn.classList.toggle('tab-active', active);
|
||
btn.classList.toggle('tab-idle', !active);
|
||
});
|
||
window.ClassicDesigner.lastTopperType = type;
|
||
};
|
||
function ensureNumberTopperImage(type) {
|
||
if (!type || !type.startsWith('num-')) return;
|
||
const cur = getTopperColor();
|
||
if (cur?.image && numberSpriteSet.has(cur.image)) {
|
||
setTopperColor({ hex: cur?.hex || '#ffffff', image: null });
|
||
refreshClassicPaletteUi?.();
|
||
}
|
||
}
|
||
function resetNonNumberTopperColor(type) {
|
||
if (type && type.startsWith('num-')) return;
|
||
const fallback = getTopperColor();
|
||
if (fallback?.image && numberSpriteSet.has(fallback.image)) {
|
||
setTopperColor({ hex: fallback.hex || '#ffffff', image: null });
|
||
refreshClassicPaletteUi?.();
|
||
}
|
||
}
|
||
|
||
function applyTopperPreset(patternName, type) {
|
||
const presetType = (type || '').startsWith('num-') ? 'number' : type;
|
||
const key = `${patternName}:${presetType}`;
|
||
const preset = topperPresets[key];
|
||
if (!preset) return;
|
||
if (lastPresetKey === key || lastPresetKey === 'custom') return;
|
||
topperOffsetX = preset.offsetX;
|
||
topperOffsetY = preset.offsetY;
|
||
if (topperSizeInput) topperSizeInput.value = preset.size;
|
||
if (topperEnabledCb) topperEnabledCb.checked = preset.enabled;
|
||
setTopperType(type);
|
||
ensureNumberTopperImage(type);
|
||
resetNonNumberTopperColor(type);
|
||
lastPresetKey = key;
|
||
}
|
||
|
||
const computePatternName = () => {
|
||
const base = patternShape === 'column' ? 'Column' : 'Arch';
|
||
const count = patternCount === 5 ? '5' : '4';
|
||
const layout = patternLayout === 'stacked' ? ' Stacked' : '';
|
||
return `${base} ${count}${layout}`;
|
||
};
|
||
const syncPatternStateFromSelect = () => {
|
||
const val = (patSel?.value || '').toLowerCase();
|
||
patternShape = val.includes('column') ? 'column' : 'arch';
|
||
patternCount = val.includes('5') ? 5 : 4;
|
||
patternLayout = val.includes('stacked') ? 'stacked' : 'spiral';
|
||
};
|
||
const applyPatternButtons = () => {
|
||
const displayLayout = manualModeState ? 'manual' : patternLayout;
|
||
const setActive = (btns, attr, val) => btns.forEach(b => {
|
||
const active = b.dataset[attr] === val;
|
||
b.classList.toggle('tab-active', active);
|
||
b.classList.toggle('tab-idle', !active);
|
||
b.setAttribute('aria-pressed', String(active));
|
||
});
|
||
setActive(patternShapeBtns, 'patternShape', patternShape);
|
||
setActive(patternCountBtns, 'patternCount', String(patternCount));
|
||
setActive(patternLayoutBtns, 'patternLayout', displayLayout);
|
||
patternLayoutBtns.forEach(b => b.disabled = false);
|
||
if (manualModeBtn) {
|
||
const active = manualModeState;
|
||
manualModeBtn.disabled = false;
|
||
manualModeBtn.classList.toggle('tab-active', active);
|
||
manualModeBtn.classList.toggle('tab-idle', !active);
|
||
manualModeBtn.setAttribute('aria-pressed', String(active));
|
||
}
|
||
if (toolbar) toolbar.classList.toggle('hidden', !manualModeState);
|
||
if (quadModal) quadModal.classList.toggle('hidden', !(manualModeState && quadModalRow !== null));
|
||
if (expandedToggleRow) expandedToggleRow.classList.toggle('hidden', !manualModeState);
|
||
if (expandedToggle) expandedToggle.checked = manualModeState ? manualExpandedState : false;
|
||
if (focusRow) {
|
||
const showFocus = manualModeState && (currentPatternName.toLowerCase().includes('arch') || currentPatternName.toLowerCase().includes('column'));
|
||
focusRow.classList.toggle('hidden', !showFocus);
|
||
}
|
||
if (manualHub) manualHub.classList.toggle('hidden', !manualModeState);
|
||
if (floatingBar) floatingBar.classList.toggle('hidden', !manualModeState);
|
||
// No expanded toggle in bar (handled by main control)
|
||
[floatingPick, floatingErase].forEach(btn => {
|
||
if (!btn) return;
|
||
const active = manualModeState && manualTool === btn.dataset.tool;
|
||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
btn.classList.toggle('active', active);
|
||
});
|
||
};
|
||
syncPatternStateFromSelect();
|
||
|
||
function persistState() {
|
||
const state = {
|
||
patternName: patSel?.value || '',
|
||
length: lengthInp?.value || '',
|
||
reverse: !!reverseCb?.checked,
|
||
topperEnabled: !!topperEnabledCb?.checked,
|
||
topperType: getTopperType(),
|
||
topperOffsetX,
|
||
topperOffsetY,
|
||
topperSize: topperSizeInput?.value || ''
|
||
};
|
||
saveClassicState(state);
|
||
}
|
||
|
||
function applySavedState() {
|
||
const saved = loadClassicState();
|
||
if (!saved) return;
|
||
if (patSel && saved.patternName) patSel.value = saved.patternName;
|
||
if (lengthInp && saved.length) lengthInp.value = saved.length;
|
||
if (reverseCb) reverseCb.checked = !!saved.reverse;
|
||
if (topperEnabledCb) topperEnabledCb.checked = !!saved.topperEnabled;
|
||
if (typeof saved.topperOffsetX === 'number') topperOffsetX = saved.topperOffsetX;
|
||
if (typeof saved.topperOffsetY === 'number') topperOffsetY = saved.topperOffsetY;
|
||
if (topperSizeInput && saved.topperSize) topperSizeInput.value = saved.topperSize;
|
||
if (saved.topperType) setTopperType(saved.topperType);
|
||
syncPatternStateFromSelect();
|
||
lastPresetKey = 'custom';
|
||
}
|
||
|
||
applySavedState();
|
||
applyPatternButtons();
|
||
|
||
const clampRow = (row) => {
|
||
const maxRow = Math.max(1, currentRowCount) - 1;
|
||
return Math.max(0, Math.min(maxRow, row|0));
|
||
};
|
||
const clampFocusStart = (start) => {
|
||
const maxStart = Math.max(0, currentRowCount - manualFocusSize);
|
||
return Math.max(0, Math.min(maxStart, start|0));
|
||
};
|
||
function setManualTargetRow(row, { redraw = true } = {}) {
|
||
if (!manualModeState) return;
|
||
const targetRow = clampRow(row);
|
||
manualDetailRow = targetRow;
|
||
manualFloatingQuad = targetRow;
|
||
manualFocusEnabled = false;
|
||
syncManualUi();
|
||
if (redraw) updateClassicDesign();
|
||
debug('setManualTargetRow', { targetRow, focusStart: manualFocusStart, clusters: currentRowCount });
|
||
}
|
||
function syncManualUi() {
|
||
if (!manualModeState) return;
|
||
if (manualRange) {
|
||
manualRange.max = Math.max(1, currentRowCount);
|
||
manualRange.value = String(Math.min(currentRowCount, manualDetailRow + 1));
|
||
}
|
||
if (manualRangeLabel) {
|
||
manualRangeLabel.textContent = `Cluster ${Math.min(currentRowCount, manualDetailRow + 1)} / ${Math.max(currentRowCount, 1)}`;
|
||
}
|
||
if (manualFullBtn) manualFullBtn.disabled = !manualModeState;
|
||
if (manualFocusBtn) manualFocusBtn.disabled = !manualModeState;
|
||
}
|
||
|
||
const focusSectionForRow = (row) => {
|
||
manualFocusEnabled = false;
|
||
manualFloatingQuad = clampRow(row);
|
||
manualDetailRow = clampRow(row);
|
||
syncManualUi();
|
||
debug('focusSectionForRow', { row, manualFloatingQuad, clusters: currentRowCount });
|
||
};
|
||
const getQuadNodes = (row) => {
|
||
const svg = document.querySelector('#classic-display svg');
|
||
if (!svg) return [];
|
||
let nodes = Array.from(svg.querySelectorAll(`g[data-quad-number="${row + 1}"]`));
|
||
if (!nodes.length) {
|
||
const fallback = Array.from(svg.querySelectorAll(`[data-quad-number="${row + 1}"]`))
|
||
.map(n => n.closest('g')).filter(Boolean);
|
||
nodes = Array.from(new Set(fallback));
|
||
}
|
||
if (!nodes.length) {
|
||
// Fallback: match by id suffix balloon_*_row
|
||
const byId = Array.from(svg.querySelectorAll('g[id^="balloon_"]')).filter(n => {
|
||
const m = n.id.match(/_(\d+)$/);
|
||
return m && parseInt(m[1], 10) === row;
|
||
});
|
||
nodes = Array.from(new Set(byId));
|
||
}
|
||
if (!nodes.length) {
|
||
// Last resort: any element with the quad data on parent chain
|
||
const byAny = Array.from(svg.querySelectorAll('[data-quad-number]'))
|
||
.filter(el => parseInt(el.getAttribute('data-quad-number') || '0', 10) === row + 1)
|
||
.map(n => n.closest('g')).filter(Boolean);
|
||
nodes = Array.from(new Set(byAny));
|
||
}
|
||
debug('quad nodes', { row, count: nodes.length });
|
||
return nodes;
|
||
};
|
||
const rectUnion = (rects) => {
|
||
if (!rects.length) return null;
|
||
const first = rects[0];
|
||
const box = { left: first.left, right: first.right, top: first.top, bottom: first.bottom };
|
||
rects.slice(1).forEach(r => {
|
||
box.left = Math.min(box.left, r.left);
|
||
box.right = Math.max(box.right, r.right);
|
||
box.top = Math.min(box.top, r.top);
|
||
box.bottom = Math.max(box.bottom, r.bottom);
|
||
});
|
||
box.width = box.right - box.left;
|
||
box.height = box.bottom - box.top;
|
||
return box;
|
||
};
|
||
const bboxUnion = (bboxes) => {
|
||
if (!bboxes.length) return null;
|
||
const first = bboxes[0];
|
||
const box = { minX: first.x, minY: first.y, maxX: first.x + first.width, maxY: first.y + first.height };
|
||
bboxes.slice(1).forEach(b => {
|
||
box.minX = Math.min(box.minX, b.x);
|
||
box.minY = Math.min(box.minY, b.y);
|
||
box.maxX = Math.max(box.maxX, b.x + b.width);
|
||
box.maxY = Math.max(box.maxY, b.y + b.height);
|
||
});
|
||
return { x: box.minX, y: box.minY, width: box.maxX - box.minX, height: box.maxY - box.minY };
|
||
};
|
||
function buildQuadModalSvg(row) {
|
||
if (!quadModalDisplay) return null;
|
||
const nodes = getQuadNodes(row);
|
||
if (!nodes.length) return null;
|
||
const sourceSvg = document.querySelector('#classic-display svg');
|
||
const svgNS = 'http://www.w3.org/2000/svg';
|
||
|
||
// Build a temp svg to measure the cloned quad accurately (transforms included)
|
||
const tempSvg = document.createElementNS(svgNS, 'svg');
|
||
tempSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||
tempSvg.style.position = 'absolute';
|
||
tempSvg.style.left = '-99999px';
|
||
tempSvg.style.top = '-99999px';
|
||
|
||
const tempDefs = document.createElementNS(svgNS, 'defs');
|
||
if (sourceSvg) {
|
||
const defs = sourceSvg.querySelector('defs');
|
||
if (defs) tempDefs.appendChild(defs.cloneNode(true));
|
||
}
|
||
const palette = buildClassicPalette();
|
||
Object.entries(palette).forEach(([slot, info]) => {
|
||
if (!info?.image) return;
|
||
const pat = document.createElementNS(svgNS, 'pattern');
|
||
pat.setAttribute('id', `classic-pattern-slot-${slot}`);
|
||
pat.setAttribute('patternContentUnits', 'objectBoundingBox');
|
||
pat.setAttribute('width', '1');
|
||
pat.setAttribute('height', '1');
|
||
const img = document.createElementNS(svgNS, 'image');
|
||
img.setAttributeNS(null, 'href', info.image);
|
||
img.setAttribute('x', '-0.75');
|
||
img.setAttribute('y', '-0.75');
|
||
img.setAttribute('width', '2.5');
|
||
img.setAttribute('height', '2.5');
|
||
img.setAttribute('preserveAspectRatio', 'xMidYMid slice');
|
||
pat.appendChild(img);
|
||
tempDefs.appendChild(pat);
|
||
});
|
||
tempSvg.appendChild(tempDefs);
|
||
|
||
const tempG = document.createElementNS(svgNS, 'g');
|
||
nodes.forEach(node => tempG.appendChild(node.cloneNode(true)));
|
||
tempG.querySelectorAll('.balloon').forEach(el => {
|
||
const code = parseInt(el.getAttribute('data-color-code') || '0', 10);
|
||
const info = palette[code] || null;
|
||
const fill = info
|
||
? (info.image ? `url(#classic-pattern-slot-${code})` : (info.colour || info.hex || '#cccccc'))
|
||
: '#cccccc';
|
||
el.setAttribute('fill', fill);
|
||
el.setAttribute('stroke', '#111827');
|
||
el.setAttribute('stroke-width', '0.6');
|
||
el.setAttribute('paint-order', 'stroke fill');
|
||
el.setAttribute('pointer-events', 'all'); // keep taps/clicks active in detail view
|
||
});
|
||
tempSvg.appendChild(tempG);
|
||
document.body.appendChild(tempSvg);
|
||
|
||
let box = null;
|
||
try {
|
||
box = tempG.getBBox();
|
||
debug('detail bbox (temp measure)', { row, box });
|
||
} catch (err) {
|
||
debug('detail bbox measure failed', { row, err });
|
||
}
|
||
if (!box || box.width < 1 || box.height < 1) {
|
||
box = { x: 0, y: 0, width: 50, height: 50 };
|
||
debug('detail bbox tiny fallback', { row, box });
|
||
}
|
||
|
||
const padBase = Math.max(box.width, box.height) * 0.25;
|
||
const PAD = Math.max(12, padBase);
|
||
const vbW = Math.max(1, box.width + PAD * 2);
|
||
const vbH = Math.max(1, box.height + PAD * 2);
|
||
|
||
const svgEl = document.createElementNS(svgNS, 'svg');
|
||
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||
svgEl.setAttribute('viewBox', `0 0 ${vbW} ${vbH}`);
|
||
svgEl.setAttribute('width', '100%');
|
||
svgEl.setAttribute('height', '100%');
|
||
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
svgEl.style.display = 'block';
|
||
svgEl.style.pointerEvents = 'auto';
|
||
svgEl.style.pointerEvents = 'auto';
|
||
|
||
// Reuse the temp defs in the final svg
|
||
svgEl.appendChild(tempDefs.cloneNode(true));
|
||
const finalG = tempG.cloneNode(true);
|
||
finalG.setAttribute('transform', `translate(${PAD - box.x}, ${PAD - box.y})`);
|
||
finalG.querySelectorAll('.balloon').forEach(el => {
|
||
let fillOverride = null;
|
||
let strokeOverride = null;
|
||
let strokeWidth = null;
|
||
if (manualModeState) {
|
||
const idMatch = (el.id || '').match(/balloon_(\d+)_(\d+)/);
|
||
if (idMatch) {
|
||
const bx = parseInt(idMatch[1], 10);
|
||
const by = parseInt(idMatch[2], 10);
|
||
const key = manualKey(currentPatternName, currentRowCount);
|
||
const overrides = manualOverrides[key] || {};
|
||
const ov = overrides[`${bx},${by}`];
|
||
if (ov && typeof ov === 'object') {
|
||
// For detail view, ignore textures; use hex/colour only.
|
||
fillOverride = ov.hex || ov.colour || null;
|
||
} else if (typeof ov === 'number') {
|
||
const palette = buildClassicPalette();
|
||
const info = palette[ov] || null;
|
||
if (info) fillOverride = info.colour || info.hex || null;
|
||
}
|
||
}
|
||
}
|
||
const isEmpty = !fillOverride;
|
||
if (isEmpty) {
|
||
el.setAttribute('fill', 'none');
|
||
strokeOverride = '#94a3b8';
|
||
strokeWidth = '1.2';
|
||
} else {
|
||
el.setAttribute('fill', fillOverride);
|
||
}
|
||
if (strokeOverride) el.setAttribute('stroke', strokeOverride);
|
||
if (strokeWidth) el.setAttribute('stroke-width', strokeWidth);
|
||
el.setAttribute('paint-order', 'stroke fill');
|
||
el.setAttribute('pointer-events', 'all');
|
||
});
|
||
svgEl.appendChild(finalG);
|
||
|
||
tempSvg.remove();
|
||
return svgEl;
|
||
}
|
||
function scheduleManualDetail() { /* detail view disabled */ }
|
||
function openQuadModal(row) {
|
||
return;
|
||
}
|
||
function closeQuadModal() {
|
||
if (!quadModal || !quadModalDisplay) return;
|
||
quadModalRow = null;
|
||
quadModalStartRect = null;
|
||
quadModalDisplay.style.transition = 'transform 180ms ease, opacity 160ms ease';
|
||
quadModalDisplay.style.transform = 'translate(0,-10px) scale(0.92)';
|
||
quadModalDisplay.style.opacity = '0';
|
||
setTimeout(() => {
|
||
quadModal.classList.add('hidden');
|
||
quadModal.setAttribute('aria-hidden', 'true');
|
||
quadModalDisplay.innerHTML = '';
|
||
quadModalDisplay.style.transform = '';
|
||
quadModalDisplay.style.opacity = '';
|
||
quadModalDisplay.style.transition = '';
|
||
}, 200);
|
||
}
|
||
|
||
function updateClassicDesign() {
|
||
if (!lengthInp || !patSel) return;
|
||
patSel.value = computePatternName();
|
||
const patternName = patSel.value || 'Arch 4';
|
||
currentPatternName = patternName;
|
||
const clusterCount = Math.max(1, Math.round((parseFloat(lengthInp.value) || 0) * 2));
|
||
currentRowCount = clusterCount;
|
||
const manualOn = manualModeState;
|
||
if (!manualOn) {
|
||
manualFocusEnabled = false;
|
||
manualFloatingQuad = null;
|
||
closeQuadModal();
|
||
} else {
|
||
manualDetailRow = clampRow(manualDetailRow);
|
||
// Preserve a cleared floating state; only set if not explicitly reset.
|
||
if (manualFloatingQuad !== null) manualFloatingQuad = clampRow(manualDetailRow);
|
||
manualFocusEnabled = false;
|
||
}
|
||
manualFocusStart = 0;
|
||
window.ClassicDesigner.manualMode = manualOn;
|
||
const isColumn = patternName.toLowerCase().includes('column');
|
||
const hasTopper = patternName.includes('4') || patternName.includes('5');
|
||
const showToggle = isColumn && hasTopper;
|
||
if (patternName.toLowerCase().includes('column')) {
|
||
const baseName = patternName.includes('5') ? 'Column 5' : 'Column 4';
|
||
applyTopperPreset(baseName, getTopperType());
|
||
}
|
||
if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle);
|
||
const showTopper = showToggle && topperEnabledCb?.checked;
|
||
const isNumberTopper = getTopperType().startsWith('num-');
|
||
|
||
topperControls.classList.toggle('hidden', !showTopper);
|
||
// Number tint controls removed; always use base SVG appearance for numbers.
|
||
if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper);
|
||
const showReverse = patternLayout === 'spiral' && !manualOn;
|
||
if (reverseLabel) reverseLabel.classList.toggle('hidden', !showReverse);
|
||
if (reverseHint) reverseHint.classList.toggle('hidden', !showReverse);
|
||
if (reverseCb) {
|
||
reverseCb.disabled = manualOn || !showReverse;
|
||
if (!showReverse) reverseCb.checked = false;
|
||
}
|
||
|
||
GC.setTopperEnabled(showTopper);
|
||
GC.setClusters(clusterCount);
|
||
GC.setManualMode(manualOn);
|
||
GC.setReverse(!!reverseCb?.checked);
|
||
GC.setTopperType(getTopperType());
|
||
GC.setTopperOffsetX(topperOffsetX);
|
||
GC.setTopperOffsetY(topperOffsetY);
|
||
GC.setTopperSize(topperSizeInput?.value);
|
||
GC.setShineEnabled(!!shineEnabledCb?.checked);
|
||
GC.setBorderEnabled(!!borderEnabledCb?.checked);
|
||
const expandedOn = manualOn && manualExpandedState;
|
||
GC.setExplodedSettings({
|
||
scale: expandedOn ? 1.18 : 1,
|
||
gapPx: expandedOn ? 90 : 0,
|
||
staggerPx: expandedOn ? 6 : 0
|
||
});
|
||
if (display) {
|
||
display.classList.toggle('classic-expanded-canvas', expandedOn);
|
||
}
|
||
const totalClusters = clusterCount;
|
||
manualFocusStart = 0;
|
||
GC.setManualFocus({ start: manualFocusStart, size: manualFocusSize, enabled: false });
|
||
GC.setManualFloatingQuad(manualFloatingQuad);
|
||
if (quadReset) {
|
||
quadReset.disabled = manualFloatingQuad === null;
|
||
}
|
||
if (toolbarReset) {
|
||
toolbarReset.disabled = manualFloatingQuad === null;
|
||
}
|
||
if (focusLabel) {
|
||
if (!manualFocusEnabled) {
|
||
focusLabel.textContent = 'Full design view';
|
||
} else {
|
||
const end = Math.min(totalClusters, manualFocusStart + manualFocusSize);
|
||
focusLabel.textContent = `Clusters ${manualFocusStart + 1}–${end} of ${totalClusters}`;
|
||
}
|
||
}
|
||
if (focusLabelCanvas) {
|
||
if (!manualFocusEnabled) {
|
||
focusLabelCanvas.textContent = 'Full design view';
|
||
} else {
|
||
const end = Math.min(totalClusters, manualFocusStart + manualFocusSize);
|
||
focusLabelCanvas.textContent = `Clusters ${manualFocusStart + 1}–${end} of ${totalClusters}`;
|
||
}
|
||
}
|
||
if (document.body) {
|
||
if (showTopper) document.body.dataset.topperOverlay = '1';
|
||
else delete document.body.dataset.topperOverlay;
|
||
}
|
||
window.__updateFloatingNudge?.();
|
||
if(clusterHint) clusterHint.textContent = `≈ ${clusterCount} clusters (rule: 2 clusters/ft)`;
|
||
refreshClassicPaletteUi?.();
|
||
ctrl.selectPattern(patternName);
|
||
syncManualUi();
|
||
renderProjectPalette();
|
||
scheduleManualDetail();
|
||
persistState();
|
||
}
|
||
function handleManualPaint(evt) {
|
||
if (!manualModeState) return;
|
||
const target = evt.target;
|
||
if (!target) return;
|
||
if (target.closest && target.closest('[data-is-topper]')) return;
|
||
const g = target.closest('g[id^="balloon_"]') || (target.id && target);
|
||
const id = g?.id || '';
|
||
const match = id.match(/balloon_(\d+)_(\d+)/);
|
||
if (!match) { debug('manual paint click ignored (no match)', { id }); return; }
|
||
const x = parseInt(match[1], 10);
|
||
const y = parseInt(match[2], 10);
|
||
|
||
if (manualFloatingQuad !== y) {
|
||
setManualTargetRow(y);
|
||
return;
|
||
}
|
||
|
||
debug('manual paint click', { x, y, manualTool, mode: manualModeState, currentPatternName, currentRowCount });
|
||
const prev = getManualOverride(currentPatternName, currentRowCount, x, y);
|
||
const palette = buildClassicPalette();
|
||
const colorCode = parseInt(g?.getAttribute('data-color-code') || '0', 10);
|
||
const currentFill = prev || palette[colorCode] || { hex: '#ffffff', image: null };
|
||
if (manualTool === 'pick') {
|
||
const picked = { hex: normHex(currentFill.hex || currentFill.colour || '#ffffff'), image: currentFill.image || null };
|
||
manualActiveColorGlobal = window.shared?.setActiveColor?.(picked) || picked;
|
||
manualRedoStack.length = 0;
|
||
manualTool = 'paint';
|
||
updateClassicDesign();
|
||
return;
|
||
}
|
||
manualRedoStack.length = 0;
|
||
if (manualTool === 'erase') {
|
||
const ERASE_COLOR = { hex: 'transparent', colour: 'transparent', image: null };
|
||
manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: ERASE_COLOR });
|
||
setManualOverride(currentPatternName, currentRowCount, x, y, ERASE_COLOR);
|
||
manualRedoStack.length = 0;
|
||
updateClassicDesign();
|
||
scheduleManualDetail();
|
||
return;
|
||
}
|
||
const colorToUse = manualActiveColorGlobal || window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null };
|
||
const hexSource = colorToUse.hex || colorToUse.colour || '#ffffff';
|
||
const next = { hex: normHex(hexSource), image: colorToUse.image || null };
|
||
// If clicking a filled balloon with the same active fill, toggle it off (supports images and solids).
|
||
const isSameHex = normHex(currentFill.hex || currentFill.colour) === next.hex;
|
||
const sameImage = currentFill.image && next.image && currentFill.image === next.image;
|
||
const isSameFill = (sameImage || (!currentFill.image && !next.image && isSameHex));
|
||
const finalNext = isSameFill ? { hex: 'transparent', colour: 'transparent', image: null } : next;
|
||
manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: finalNext });
|
||
setManualOverride(currentPatternName, currentRowCount, x, y, finalNext);
|
||
updateClassicDesign();
|
||
scheduleManualDetail();
|
||
}
|
||
function undoLastManual() {
|
||
const last = manualUndoStack.pop();
|
||
if (!last) return;
|
||
if (last.clear) {
|
||
const key = manualKey(last.pattern, last.rows);
|
||
delete manualOverrides[key];
|
||
if (last.snapshot) manualOverrides[key] = { ...last.snapshot };
|
||
} else if (last.pattern !== currentPatternName || last.rows !== currentRowCount) {
|
||
setManualOverride(last.pattern, last.rows, last.x, last.y, last.prev || undefined);
|
||
} else {
|
||
if (last.prev) setManualOverride(last.pattern, last.rows, last.x, last.y, last.prev);
|
||
else clearManualOverride(last.pattern, last.rows, last.x, last.y);
|
||
}
|
||
manualRedoStack.push(last);
|
||
updateClassicDesign();
|
||
}
|
||
function redoLastManual() {
|
||
const last = manualRedoStack.pop();
|
||
if (!last) return;
|
||
if (last.clear) {
|
||
const key = manualKey(last.pattern, last.rows);
|
||
const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null;
|
||
manualUndoStack.push({ clear: true, pattern: last.pattern, rows: last.rows, snapshot: prevSnapshot });
|
||
delete manualOverrides[key];
|
||
} else {
|
||
const prev = getManualOverride(last.pattern, last.rows, last.x, last.y);
|
||
manualUndoStack.push({ ...last, prev });
|
||
if (last.next) setManualOverride(last.pattern, last.rows, last.x, last.y, last.next);
|
||
else clearManualOverride(last.pattern, last.rows, last.x, last.y);
|
||
}
|
||
updateClassicDesign();
|
||
}
|
||
|
||
const setLengthForPattern = () => {
|
||
if (!lengthInp || !patSel) return;
|
||
const isArch = (computePatternName()).toLowerCase().includes('arch');
|
||
lengthInp.value = isArch ? 20 : 5;
|
||
};
|
||
|
||
window.ClassicDesigner = window.ClassicDesigner || {};
|
||
window.ClassicDesigner.api = GC;
|
||
window.ClassicDesigner.redraw = updateClassicDesign;
|
||
window.ClassicDesigner.getColors = getClassicColors;
|
||
window.ClassicDesigner.getTopperColor = getTopperColor;
|
||
|
||
document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50));
|
||
display?.addEventListener('click', handleManualPaint);
|
||
patSel?.addEventListener('change', () => {
|
||
lastPresetKey = null;
|
||
syncPatternStateFromSelect();
|
||
manualFocusEnabled = false;
|
||
manualFloatingQuad = null;
|
||
closeQuadModal();
|
||
applyPatternButtons();
|
||
setLengthForPattern();
|
||
updateClassicDesign();
|
||
});
|
||
patternShapeBtns.forEach(btn => btn.addEventListener('click', () => { patternShape = btn.dataset.patternShape; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }));
|
||
patternCountBtns.forEach(btn => btn.addEventListener('click', () => { patternCount = Number(btn.dataset.patternCount) === 5 ? 5 : 4; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }));
|
||
patternLayoutBtns.forEach(btn => btn.addEventListener('click', () => {
|
||
patternLayout = btn.dataset.patternLayout === 'stacked' ? 'stacked' : 'spiral';
|
||
lastNonManualLayout = patternLayout;
|
||
manualModeState = false;
|
||
saveManualMode(false);
|
||
manualExpandedState = false;
|
||
saveManualExpanded(false);
|
||
manualFocusEnabled = false;
|
||
manualFocusStart = 0;
|
||
manualDetailRow = 0;
|
||
manualFloatingQuad = null;
|
||
closeQuadModal();
|
||
applyPatternButtons();
|
||
updateClassicDesign();
|
||
}));
|
||
manualModeBtn?.addEventListener('click', () => {
|
||
const togglingOn = !manualModeState;
|
||
if (togglingOn) lastNonManualLayout = patternLayout === 'manual' ? 'spiral' : patternLayout;
|
||
manualModeState = togglingOn;
|
||
patternLayout = togglingOn ? 'manual' : lastNonManualLayout;
|
||
manualFocusStart = 0;
|
||
manualFocusEnabled = false; // keep full-view; quad pull-out handles focus
|
||
manualDetailRow = 0;
|
||
manualFloatingQuad = null;
|
||
closeQuadModal();
|
||
saveManualMode(togglingOn);
|
||
applyPatternButtons();
|
||
updateClassicDesign();
|
||
if (togglingOn) scheduleManualDetail();
|
||
debug('manual mode toggle', { on: togglingOn, clusters: currentRowCount });
|
||
});
|
||
const shiftFocus = (dir) => {
|
||
const next = clampRow(manualDetailRow + dir);
|
||
setManualTargetRow(next);
|
||
};
|
||
focusPrev?.addEventListener('click', () => shiftFocus(-1));
|
||
focusNext?.addEventListener('click', () => shiftFocus(1));
|
||
focusZoomOut?.addEventListener('click', () => { manualFocusStart = 0; manualFloatingQuad = null; manualFocusEnabled = false; updateClassicDesign(); });
|
||
toolbarPrev?.addEventListener('click', () => shiftFocus(-1));
|
||
toolbarNext?.addEventListener('click', () => shiftFocus(1));
|
||
toolbarZoomOut?.addEventListener('click', () => { manualFocusStart = 0; manualFloatingQuad = null; manualFocusEnabled = false; updateClassicDesign(); });
|
||
toolbarReset?.addEventListener('click', () => { manualFloatingQuad = null; updateClassicDesign(); });
|
||
quadReset?.addEventListener('click', () => { manualFloatingQuad = null; updateClassicDesign(); });
|
||
quadModalClose?.addEventListener('click', closeQuadModal);
|
||
quadModal?.addEventListener('click', (e) => {
|
||
if (e.target === quadModal || (e.target && e.target.classList && e.target.classList.contains('quad-modal-backdrop'))) {
|
||
closeQuadModal();
|
||
}
|
||
});
|
||
quadModalDisplay?.addEventListener('click', handleManualPaint);
|
||
manualDetailDisplay?.addEventListener('click', handleManualPaint);
|
||
manualRange?.addEventListener('input', () => setManualTargetRow(Number(manualRange.value) - 1));
|
||
manualPrevBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow - 1));
|
||
manualNextBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow + 1));
|
||
manualFullBtn?.addEventListener('click', () => {
|
||
manualFocusEnabled = true; // keep focus so detail/rail stay active
|
||
manualFloatingQuad = null;
|
||
updateClassicDesign();
|
||
scheduleManualDetail();
|
||
debug('manual full view');
|
||
});
|
||
manualFocusBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow));
|
||
manualPaletteBtn?.addEventListener('click', () => {
|
||
if (!window.openColorPicker) return;
|
||
const items = flattenPalette().map(c => ({
|
||
label: c.name || c.hex,
|
||
hex: c.hex,
|
||
meta: c,
|
||
metaText: c.family || ''
|
||
}));
|
||
window.openColorPicker({
|
||
title: 'Manual paint color',
|
||
subtitle: 'Applies to manual paint tool',
|
||
items,
|
||
onSelect: (item) => {
|
||
const meta = item.meta || {};
|
||
manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: meta.hex || item.hex, image: meta.image || null }) || { hex: meta.hex || item.hex, image: meta.image || null };
|
||
updateClassicDesign();
|
||
}
|
||
});
|
||
});
|
||
// Keep detail view in sync after initial render when manual mode is pre-enabled
|
||
if (manualModeState) {
|
||
scheduleManualDetail();
|
||
}
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') closeQuadModal();
|
||
});
|
||
expandedToggle?.addEventListener('change', () => {
|
||
manualExpandedState = !!expandedToggle.checked;
|
||
saveManualExpanded(manualExpandedState);
|
||
updateClassicDesign();
|
||
});
|
||
floatingUndo?.addEventListener('click', undoLastManual);
|
||
floatingRedo?.addEventListener('click', redoLastManual);
|
||
floatingPick?.addEventListener('click', () => { manualTool = 'pick'; applyPatternButtons(); });
|
||
floatingErase?.addEventListener('click', () => { manualTool = 'erase'; applyPatternButtons(); });
|
||
floatingClear?.addEventListener('click', () => {
|
||
const key = manualKey(currentPatternName, currentRowCount);
|
||
const prev = manualOverrides[key] ? { ...manualOverrides[key] } : null;
|
||
manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prev });
|
||
delete manualOverrides[key];
|
||
manualRedoStack.length = 0;
|
||
updateClassicDesign();
|
||
});
|
||
floatingExport?.addEventListener('click', () => {
|
||
document.querySelector('[data-export="png"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
});
|
||
floatingUndo?.addEventListener('click', undoLastManual);
|
||
// Reset floated quad by clicking empty canvas area.
|
||
display?.addEventListener('click', (e) => {
|
||
if (!manualModeState) return;
|
||
const hit = e.target?.closest?.('g[id^="balloon_"], [data-quad-number]');
|
||
if (hit) return;
|
||
if (manualFloatingQuad !== null) {
|
||
manualFloatingQuad = null;
|
||
updateClassicDesign();
|
||
}
|
||
});
|
||
// Zoom: wheel and pinch on the display
|
||
const handleZoom = (factor) => {
|
||
classicZoom = clampZoom(classicZoom * factor);
|
||
updateClassicDesign();
|
||
};
|
||
display?.addEventListener('wheel', (e) => {
|
||
const dy = e.deltaY || 0;
|
||
if (dy === 0) return;
|
||
const factor = dy > 0 ? 0.9 : 1.1;
|
||
handleZoom(factor);
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
let pinchBase = null;
|
||
const pinchDistance = (touches) => {
|
||
if (!touches || touches.length < 2) return 0;
|
||
const dx = touches[0].clientX - touches[1].clientX;
|
||
const dy = touches[0].clientY - touches[1].clientY;
|
||
return Math.hypot(dx, dy);
|
||
};
|
||
display?.addEventListener('touchstart', (e) => {
|
||
if (e.touches.length === 2) {
|
||
pinchBase = pinchDistance(e.touches);
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
display?.addEventListener('touchmove', (e) => {
|
||
if (pinchBase && e.touches.length === 2) {
|
||
const d = pinchDistance(e.touches);
|
||
if (d > 0) {
|
||
const factor = d / pinchBase;
|
||
handleZoom(factor);
|
||
pinchBase = d;
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
const resetPinch = () => { pinchBase = null; };
|
||
display?.addEventListener('touchend', resetPinch);
|
||
display?.addEventListener('touchcancel', resetPinch);
|
||
topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => {
|
||
const dx = Number(btn.dataset.dx || 0);
|
||
const dy = Number(btn.dataset.dy || 0);
|
||
topperOffsetX += dx;
|
||
topperOffsetY += dy;
|
||
lastPresetKey = 'custom';
|
||
GC.setTopperOffsetX(topperOffsetX);
|
||
GC.setTopperOffsetY(topperOffsetY);
|
||
updateClassicDesign();
|
||
}));
|
||
topperTypeButtons.forEach(btn => btn.addEventListener('click', () => {
|
||
setTopperType(btn.dataset.type);
|
||
ensureNumberTopperImage(btn.dataset.type);
|
||
resetNonNumberTopperColor(btn.dataset.type);
|
||
if (topperEnabledCb) {
|
||
topperEnabledCb.checked = true;
|
||
GC.setTopperEnabled(true);
|
||
}
|
||
lastPresetKey = null;
|
||
updateClassicDesign();
|
||
}));
|
||
nudgeOpenBtn?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
window.__showFloatingNudge?.();
|
||
});
|
||
const updateFullscreenLabel = () => {
|
||
if (!fullscreenBtn) return;
|
||
const active = !!document.fullscreenElement;
|
||
fullscreenBtn.textContent = active ? 'Exit Fullscreen' : 'Fullscreen';
|
||
};
|
||
fullscreenBtn?.addEventListener('click', async () => {
|
||
try {
|
||
if (!document.fullscreenElement) {
|
||
await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
|
||
} else {
|
||
await document.exitFullscreen();
|
||
}
|
||
} catch (err) {
|
||
console.error('Fullscreen toggle failed', err);
|
||
} finally {
|
||
updateFullscreenLabel();
|
||
}
|
||
});
|
||
document.addEventListener('fullscreenchange', updateFullscreenLabel);
|
||
[lengthInp, reverseCb, topperEnabledCb, topperSizeInput]
|
||
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInput || el === topperEnabledCb) lastPresetKey = 'custom'; updateClassicDesign(); }); });
|
||
topperEnabledCb?.addEventListener('change', updateClassicDesign);
|
||
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
|
||
borderEnabledCb?.addEventListener('change', (e) => {
|
||
const on = !!e.target.checked;
|
||
GC.setBorderEnabled(on);
|
||
try { localStorage.setItem('classic:borderEnabled:v1', JSON.stringify(on)); } catch {}
|
||
updateClassicDesign();
|
||
});
|
||
refreshClassicPaletteUi = initClassicColorPicker(updateClassicDesign);
|
||
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
|
||
try {
|
||
const saved = localStorage.getItem('classic:borderEnabled:v1');
|
||
if (saved !== null && borderEnabledCb) borderEnabledCb.checked = JSON.parse(saved);
|
||
else if (borderEnabledCb) borderEnabledCb.checked = true; // default to outline on
|
||
} catch { if (borderEnabledCb) borderEnabledCb.checked = true; }
|
||
setLengthForPattern();
|
||
updateClassicDesign();
|
||
refreshClassicPaletteUi?.();
|
||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||
log('Classic ready');
|
||
} catch (e) { fail(e.message || e); }
|
||
}
|
||
|
||
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 };
|
||
})();
|
||
})();
|