balloonDesign/classic.js

2526 lines
120 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
'use strict';
// -------- 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 => (seen.has(c.hex) ? false : (seen.add(c.hex), 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 doesnt 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: '-22%', y: '-22%', width: '144%', height: '144%' }, [
svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 3.1, result: 'spread' }),
svg('feComposite', { in: 'spread', in2: 'SourceAlpha', operator: 'out', result: 'stroke' }),
svg('feFlood', { 'flood-color': OUTLINE_DARK, 'flood-opacity': 1, result: 'strokeColor' }),
svg('feComposite', { in: 'strokeColor', in2: 'stroke', operator: 'in', result: 'coloredStroke' }),
svg('feMerge', {}, [
svg('feMergeNode', { in: 'coloredStroke' }),
svg('feMergeNode', { in: 'SourceGraphic' })
])
]),
svg('filter', { id: 'classic-num-outline-light', 'color-interpolation-filters': 'sRGB', x: '-22%', y: '-22%', width: '144%', height: '144%' }, [
svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 3.1, result: 'spread' }),
svg('feComposite', { in: 'spread', in2: 'SourceAlpha', operator: 'out', result: 'stroke' }),
svg('feFlood', { 'flood-color': OUTLINE_LIGHT, 'flood-opacity': 1, result: 'strokeColor' }),
svg('feComposite', { in: 'strokeColor', in2: 'stroke', operator: 'in', result: 'coloredStroke' }),
svg('feMerge', {}, [
svg('feMergeNode', { in: 'coloredStroke' }),
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 };
})();
})();