balloonDesign/classic.js

2107 lines
105 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 classicZoom = 1;
const clampZoom = (z) => Math.min(2.2, Math.max(0.5, z));
function classicShineStyle(colorInfo) {
const hex = normHex(colorInfo?.hex || colorInfo?.colour || '');
if (hex.startsWith('#')) {
const lum = luminance(hex);
if (lum > 0.7) {
const t = clamp01((lum - 0.7) / 0.3);
const fillAlpha = 0.22 + (0.10 - 0.22) * t;
return {
fill: `rgba(0,0,0,${fillAlpha})`,
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' };
}
// -------- 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 NUMBER_TINT_COLOR_KEY = 'classic:numberTintColor:v1';
const NUMBER_TINT_OPACITY_KEY = 'classic:numberTintOpacity: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.svg',
'1': 'output_webp/1.svg',
'2': 'output_webp/2.svg',
'3': 'output_webp/3.svg',
'4': 'output_webp/4.svg',
'5': 'output_webp/5.svg',
'6': 'output_webp/6.svg',
'7': 'output_webp/7.svg',
'8': 'output_webp/8.svg',
'9': 'output_webp/9.svg'
};
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: '#a18b67', image: 'images/chrome-gold.webp' });
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));
return (saved && saved.hex) ? saved : defaultTopper();
} catch { return defaultTopper(); }
}
function setTopperColor(colorObj) {
const clean = { hex: normHex(colorObj.hex), image: colorObj.image || null };
try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {}
}
function getNumberTintColor() {
try {
const saved = JSON.parse(localStorage.getItem(NUMBER_TINT_COLOR_KEY));
if (saved && saved.hex) return normHex(saved.hex);
} catch {}
return '#ffffff';
}
function setNumberTintColor(hex) {
const clean = normHex(hex || '#ffffff');
try { localStorage.setItem(NUMBER_TINT_COLOR_KEY, JSON.stringify({ hex: clean })); } catch {}
return clean;
}
function getNumberTintOpacity() {
try {
const saved = parseFloat(localStorage.getItem(NUMBER_TINT_OPACITY_KEY));
if (!isNaN(saved)) return clamp01(saved);
} catch {}
return 1; // default to full tint so number color changes are obvious
}
function setNumberTintOpacity(v) {
const clamped = clamp01(parseFloat(v));
try { localStorage.setItem(NUMBER_TINT_OPACITY_KEY, String(clamped)); } catch {}
return clamped;
}
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);
}
}
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 numberTintHex = getNumberTintColor();
let numberTintOpacity = getNumberTintOpacity();
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; },
setNumberTintHex(hex) { numberTintHex = setNumberTintColor(hex); },
setNumberTintOpacity(val) { numberTintOpacity = setNumberTintOpacity(val); },
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;
const manualScale = expandedOn ? 1.35 : 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) {
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' }));
}
const tintColor = model.numberTintHex || '#ffffff';
const tintOpacity = model.numberTintOpacity || 0;
if (tintOpacity > 0 && isNumTopper) {
const maskId = `mask-${id}`;
kids.push(svg('mask', { id: maskId, maskUnits: 'userSpaceOnUse' }, [
svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' })
]));
kids.push(svg('rect', {
x: -w/2, y: -h/2, width: w, height: h,
fill: tintColor, opacity: tintOpacity,
mask: `url(#${maskId})`,
style: 'mix-blend-mode:multiply; pointer-events:none'
}));
// Also draw the image beneath with zero opacity to keep mask refs consistent
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;opacity:0' }));
}
} 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);
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 along the arch tangent to increase spacing without distorting the curve.
const dist = Math.hypot(xPx, yPx) || 1;
const tx = -yPx / dist;
const ty = xPx / dist;
const push = rowIndex * gap;
xPx += tx * push;
yPx += ty * push;
} 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();
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, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]];
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) {
const isArch = (model.patternName || '').toLowerCase().includes('arch');
let slideX = 80;
let slideY = 0;
if (isArch) {
// Radial slide outward; preserve layout.
const dist = Math.hypot(c.x, c.y) || 1;
const offset = 80;
slideX = (c.x / dist) * offset;
slideY = (c.y / dist) * offset;
}
let tx = c.x + slideX;
let ty = c.y + slideY;
// Keep shape intact; only fan columns slightly.
const idx = typeof cell.balloonIndexInCluster === 'number' ? cell.balloonIndexInCluster : 0;
const spread = idx - 1.5;
if (isArch) {
// no fan/scale for arches; preserve layout
} else {
tx += spread * 4;
ty += spread * 2;
}
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)));
// 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 SVG_PATTERN_ZOOM = 2.5;
const offset = (1 - SVG_PATTERN_ZOOM) / 2;
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' }) ]
));
}
const svgDefs = svg('defs', {}, patternsDefs);
const mainGroup = svg('g', null, kids);
m.render(container, svg('svg', {
xmlns: 'http://www.w3.org/2000/svg',
width:'100%',
height:'100%',
viewBox: vb,
preserveAspectRatio:'xMidYMid meet',
style: `isolation:isolate; transform:scale(${classicZoom}); 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,
numberTintHex,
numberTintOpacity,
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() {
const r = 1.0;
const baseTransform = 'scale(0.58)';
const fallbackPaths = {
'0': { d: 'M 0 -0.7 C 0.38 -0.7 0.62 -0.42 0.62 0 C 0.62 0.42 0.38 0.7 0 0.7 C -0.38 0.7 -0.62 0.42 -0.62 0 C -0.62 -0.42 -0.38 -0.7 0 -0.7 Z M 0 -0.4 C -0.2 -0.4 -0.34 -0.22 -0.34 0 C -0.34 0.24 -0.2 0.42 0 0.42 C 0.2 0.42 0.34 0.24 0.34 0 C 0.34 -0.22 0.2 -0.4 0 -0.4 Z', fillRule: 'evenodd' },
'1': { d: 'M -0.12 -0.55 Q 0.1 -0.72 0.28 -0.6 Q 0.36 -0.52 0.34 -0.42 L 0.34 0.65 Q 0.34 0.82 0 0.82 Q -0.34 0.82 -0.34 0.65 L -0.34 -0.18 Q -0.34 -0.32 -0.46 -0.32 Q -0.6 -0.32 -0.62 -0.45 Q -0.64 -0.58 -0.52 -0.65 Z' },
'2': { d: 'M -0.55 -0.25 Q -0.55 -0.7 -0.18 -0.9 Q 0.1 -1.05 0.48 -0.98 Q 0.86 -0.9 1 -0.55 Q 1.12 -0.25 0.92 0.06 Q 0.78 0.28 0.36 0.5 Q 0.02 0.68 -0.2 0.88 Q -0.36 1.04 -0.32 1.12 Q -0.28 1.2 -0.12 1.2 L 0.78 1.2 Q 0.98 1.2 0.98 0.94 Q 0.98 0.7 0.78 0.7 L 0.14 0.7 Q 0.02 0.7 0.02 0.6 Q 0.02 0.52 0.24 0.38 Q 0.76 0.08 0.96 -0.22 Q 1.2 -0.58 1 -0.98 Q 0.82 -1.36 0.38 -1.48 Q -0.2 -1.64 -0.7 -1.34 Q -1.1 -1.1 -1.12 -0.6 Q -1.14 -0.38 -0.96 -0.3 Q -0.8 -0.24 -0.68 -0.32 Q -0.55 -0.42 -0.55 -0.25 Z', fillRule: 'nonzero' },
'3': { d: 'M -0.42 -0.88 Q -0.1 -1.08 0.26 -1.02 Q 0.7 -0.94 0.94 -0.62 Q 1.16 -0.32 1 -0.02 Q 0.86 0.24 0.58 0.36 Q 0.88 0.5 1 0.76 Q 1.16 1.12 0.88 1.38 Q 0.6 1.64 0.08 1.64 Q -0.3 1.64 -0.62 1.44 Q -0.88 1.26 -0.78 0.98 Q -0.7 0.72 -0.44 0.82 Q -0.06 0.96 0.26 0.88 Q 0.42 0.82 0.42 0.62 Q 0.42 0.38 0.1 0.36 L -0.24 0.34 Q -0.44 0.32 -0.44 0.12 Q -0.44 -0.08 -0.24 -0.12 L 0.08 -0.2 Q 0.32 -0.24 0.4 -0.42 Q 0.48 -0.62 0.26 -0.76 Q -0.02 -0.94 -0.4 -0.78 Q -0.62 -0.7 -0.74 -0.9 Q -0.86 -1.1 -0.68 -1.26 Q -0.58 -1.36 -0.42 -0.88 Z' },
'4': { d: 'M 0.42 -0.94 Q 0.64 -0.94 0.7 -0.74 L 0.7 -0.1 L 0.92 -0.1 Q 1.12 -0.08 1.14 0.16 Q 1.16 0.38 0.92 0.46 L 0.7 0.54 L 0.7 0.98 Q 0.7 1.14 0.5 1.18 Q 0.3 1.22 0.18 1.08 L -0.34 0.48 L -0.6 0.48 Q -0.82 0.48 -0.86 0.28 Q -0.88 0.08 -0.7 -0.02 L -0.36 -0.18 L -0.36 -0.76 Q -0.36 -0.96 -0.14 -0.96 Q 0.08 -0.96 0.12 -0.76 L 0.12 -0.36 L 0.08 -0.36 Q 0.28 -0.62 0.42 -0.94 Z' },
'5': { d: 'M 0.92 -0.94 Q 0.92 -1.16 0.72 -1.16 L -0.58 -1.16 Q -0.86 -1.16 -0.86 -0.86 Q -0.86 -0.56 -0.58 -0.56 L -0.2 -0.56 Q -0.02 -0.56 0.14 -0.5 Q 0.44 -0.38 0.44 -0.06 Q 0.44 0.18 0.22 0.36 Q 0.06 0.5 -0.16 0.5 L -0.52 0.5 Q -0.8 0.5 -0.8 0.8 Q -0.8 1.12 -0.52 1.12 L 0.24 1.12 Q 0.7 1.12 0.96 0.84 Q 1.2 0.58 1.12 0.24 Q 1.04 -0.02 0.82 -0.16 Q 0.96 -0.38 0.98 -0.62 Q 0.98 -0.86 0.92 -0.94 Z' },
'6': { d: 'M 0.94 -0.6 Q 0.88 -0.98 0.52 -1.16 Q 0.06 -1.4 -0.44 -1.1 Q -0.94 -0.82 -1.02 -0.22 Q -1.12 0.4 -0.8 0.96 Q -0.48 1.48 0.14 1.48 Q 0.52 1.48 0.82 1.24 Q 1.12 1.02 1.12 0.66 Q 1.12 0.32 0.86 0.1 Q 0.66 -0.08 0.42 -0.08 Q 0.08 -0.08 -0.12 0.18 Q -0.26 0.36 -0.28 0.6 Q -0.5 0.26 -0.48 -0.18 Q -0.46 -0.66 -0.12 -0.86 Q 0.08 -0.98 0.32 -0.9 Q 0.56 -0.82 0.62 -0.58 Q 0.68 -0.34 0.92 -0.32 Q 1.02 -0.32 0.94 -0.6 Z M -0.06 0.6 C 0.12 0.6 0.26 0.44 0.26 0.26 C 0.26 0.08 0.12 -0.08 -0.06 -0.08 C -0.24 -0.08 -0.38 0.08 -0.38 0.26 C -0.38 0.44 -0.24 0.6 -0.06 0.6 Z', fillRule: 'evenodd' },
'7': { d: 'M -0.74 -0.96 Q -0.94 -0.96 -0.94 -0.72 Q -0.94 -0.5 -0.74 -0.5 L 0.2 -0.5 Q 0.46 -0.5 0.52 -0.3 Q 0.58 -0.1 0.42 0.1 L -0.28 1.02 Q -0.42 1.2 -0.22 1.32 Q 0 1.44 0.18 1.28 L 0.98 0.2 Q 1.22 -0.12 1.1 -0.46 Q 0.98 -0.84 0.58 -0.96 Q 0.32 -1.04 0 -1.04 Z' },
'8': { d: 'M 0 -1 C 0.44 -1 0.78 -0.72 0.78 -0.34 C 0.78 0.02 0.46 0.28 0.18 0.36 C 0.46 0.44 0.8 0.66 0.8 1.02 C 0.8 1.44 0.44 1.72 0 1.72 C -0.44 1.72 -0.8 1.44 -0.8 1.02 C -0.8 0.66 -0.48 0.44 -0.2 0.36 C -0.48 0.28 -0.8 0.02 -0.8 -0.34 C -0.8 -0.72 -0.44 -1 -0.02 -1 Z M 0 0.48 C 0.2 0.48 0.34 0.64 0.34 0.84 C 0.34 1.04 0.2 1.18 0 1.18 C -0.2 1.18 -0.34 1.04 -0.34 0.84 C -0.34 0.64 -0.2 0.48 0 0.48 Z M 0 -0.46 C 0.18 -0.46 0.3 -0.64 0.3 -0.8 C 0.3 -0.98 0.18 -1.12 0 -1.12 C -0.18 -1.12 -0.3 -0.98 -0.3 -0.8 C -0.3 -0.64 -0.18 -0.46 0 -0.46 Z', fillRule: 'evenodd' },
'9': { d: 'M 0 -0.72 C 0.42 -0.72 0.7 -0.44 0.7 -0.08 C 0.7 0.2 0.56 0.46 0.32 0.6 C 0.12 0.72 0.1 0.84 0.1 1.06 C 0.1 1.24 -0.16 1.32 -0.28 1.18 L -0.64 0.72 C -0.92 0.38 -1.08 -0.1 -0.96 -0.54 C -0.82 -1.02 -0.46 -1.26 -0.08 -1.26 C 0.14 -1.26 0.32 -1.18 0.48 -1.04 C 0.62 -0.9 0.62 -0.74 0.5 -0.66 C 0.38 -0.58 0.26 -0.66 0.08 -0.74 C -0.14 -0.84 -0.34 -0.68 -0.42 -0.44 C -0.5 -0.24 -0.46 0.04 -0.32 0.26 C -0.16 0.5 0.14 0.46 0.26 0.26 C 0.38 0.06 0.3 -0.1 0.14 -0.18 C 0.02 -0.24 0 -0.42 0.12 -0.52 C 0.2 -0.58 0.46 -0.62 0.64 -0.42 C 0.82 -0.22 0.86 0.02 0.76 0.24 C 0.6 0.58 0.18 0.76 -0.16 0.7 C -0.36 0.66 -0.54 0.56 -0.68 0.42 C -0.64 0.82 -0.44 1.22 -0.14 1.46 C 0.16 1.7 0.54 1.72 0.86 1.52 C 1.26 1.26 1.42 0.84 1.36 0.38 C 1.26 -0.3 0.72 -0.72 0 -0.72 Z', fillRule: 'evenodd' }
};
const shapes = {};
const topperSize = 9.5; // ≈34" foil height when base balloons are ~11"
Object.keys({ ...fallbackPaths, ...NUMBER_IMAGE_MAP }).forEach(num => {
const img = NUMBER_IMAGE_MAP[num];
const hasImage = !!img;
shapes[`topper-num-${num}`] = {
base: hasImage
? { type: 'image', image: img, width: 1, height: 1, radius: 0.9, allowShine: false, transform: 'scale(0.9)' }
: { type: 'path', paths: [{ d: fallbackPaths[num].d, fillRule: fallbackPaths[num].fillRule || 'nonzero' }], radius: r, allowShine: true, transform: baseTransform },
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 numberTintSlider = document.getElementById('classic-number-tint');
const topperBlock = document.getElementById('classic-topper-color-block');
if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) 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(); });
slotsContainer.appendChild(btn);
}
}
function enforceSlotVisibility() {
const count = visibleSlotCount();
if (parseInt(activeTarget, 10) > count) activeTarget = '1';
renderSlots();
}
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';
const tintColor = getNumberTintColor();
if (currentType.startsWith('num-') && topperColor.image) {
topperSwatch.style.backgroundImage = `linear-gradient(${tintColor}99, ${tintColor}99), url("${topperColor.image}")`;
topperSwatch.style.backgroundBlendMode = 'multiply, normal';
topperSwatch.style.backgroundSize = '220%';
topperSwatch.style.backgroundPosition = 'center';
topperSwatch.style.backgroundColor = tintColor;
} else {
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: tintColor || 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 };
activeLabel.textContent = manualModeOn ? 'Manual paint color' : (activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`);
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;
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';
}
}
const allPaletteColors = flattenPalette(); swatchGrid.innerHTML = '';
(window.PALETTE || []).forEach(group => {
const title = document.createElement('div'); title.className = 'family-title'; title.textContent = group.family; swatchGrid.appendChild(title);
const row = document.createElement('div'); row.className = 'swatch-row';
(group.colors || []).forEach(colorItem => {
const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'swatch'; sw.title = colorItem.name;
sw.setAttribute('aria-label', colorItem.name);
sw.dataset.hex = normHex(colorItem.hex);
if (colorItem.image) sw.dataset.image = colorItem.image;
sw.style.backgroundImage = colorItem.image ? `url("${colorItem.image}")` : 'none';
sw.style.backgroundColor = colorItem.hex;
sw.style.backgroundSize = '500%';
sw.style.backgroundPosition = 'center';
sw.addEventListener('click', () => {
const selectedColor = { hex: colorItem.hex, image: colorItem.image };
const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round';
if (activeTarget === 'T') {
if (currentType.startsWith('num-')) {
setNumberTintColor(selectedColor.hex);
setNumberTintOpacity(1);
if (numberTintSlider) numberTintSlider.value = 1;
} else {
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();
});
row.appendChild(sw);
});
swatchGrid.appendChild(row);
});
topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); });
activeChip?.addEventListener('click', () => {
if (openManualPicker()) return;
try { swatchGrid?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {}
});
floatingChip?.addEventListener('click', () => {
if (openManualPicker()) return;
try { swatchGrid?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {}
});
randomizeBtn?.addEventListener('click', () => {
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();
});
updateUI();
return updateUI;
}
function initClassic() {
try {
if (typeof window.m === 'undefined') return fail('Mithril not loaded');
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 numberTintRow = document.getElementById('classic-number-tint-row'), numberTintSlider = document.getElementById('classic-number-tint');
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 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 slotsContainer = document.getElementById('classic-slots');
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';
let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral', lastNonManualLayout = 'spiral';
let 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 };
let currentPatternName = '';
let currentRowCount = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
let manualFocusStart = 0;
const manualFocusSize = 8;
const manualUndoStack = [];
const manualRedoStack = [];
let manualTool = 'paint'; // paint | pick | erase
let manualFloatingQuad = null;
let quadModalRow = null;
let quadModalStartRect = null;
let manualDetailRow = 0;
let manualDetailFrame = null;
classicZoom = 1;
// Force UI to reflect initial manual state
if (manualModeState) patternLayout = 'manual';
if (numberTintSlider) numberTintSlider.value = getNumberTintOpacity();
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);
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 applyNumberTopperTexture(type) {
if (!type || !type.startsWith('num-')) return;
const num = type.split('-')[1];
if (!num) return;
const imgPath = NUMBER_IMAGE_MAP[num];
if (imgPath) setTopperColor({ hex: '#ffffff', image: imgPath });
else setTopperColor({ hex: '#d4d4d8', image: null }); // fallback silver fill if image missing
refreshClassicPaletteUi?.();
}
function resetNonNumberTopperColor(type) {
if (type && type.startsWith('num-')) return;
const fallback = getTopperColor();
// If last topper type was a number, strip image to avoid leaking photo texture.
if (fallback?.image && Object.values(NUMBER_IMAGE_MAP).includes(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 (topperSizeInp) topperSizeInp.value = preset.size;
if (topperEnabledCb) topperEnabledCb.checked = preset.enabled;
setTopperType(type);
applyNumberTopperTexture(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: topperSizeInp?.value || '',
numberTint: numberTintSlider ? numberTintSlider.value : getNumberTintOpacity()
};
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 (topperSizeInp && saved.topperSize) topperSizeInp.value = saved.topperSize;
if (saved.topperType) setTopperType(saved.topperType);
if (numberTintSlider && typeof saved.numberTint !== 'undefined') {
numberTintSlider.value = saved.numberTint;
setNumberTintOpacity(saved.numberTint);
}
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);
if (numberTintRow) numberTintRow.classList.toggle('hidden', !(showTopper && isNumberTopper));
if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper);
if (reverseCb) {
reverseCb.disabled = manualOn;
if (manualOn) reverseCb.checked = false;
}
GC.setTopperEnabled(showTopper);
GC.setClusters(clusterCount);
GC.setManualMode(manualOn);
GC.setReverse(!!reverseCb?.checked);
GC.setTopperType(getTopperType());
GC.setNumberTintHex(getNumberTintColor());
GC.setNumberTintOpacity(numberTintSlider ? numberTintSlider.value : getNumberTintOpacity());
applyNumberTopperTexture(getTopperType());
GC.setTopperOffsetX(topperOffsetX);
GC.setTopperOffsetY(topperOffsetY);
GC.setTopperSize(topperSizeInp?.value);
GC.setShineEnabled(!!shineEnabledCb?.checked);
GC.setBorderEnabled(!!borderEnabledCb?.checked);
const expandedOn = manualOn && manualExpandedState;
GC.setExplodedSettings({
scale: expandedOn ? 1.18 : 1,
gapPx: expandedOn ? 26 : 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();
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));
// 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);
// 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);
applyNumberTopperTexture(btn.dataset.type);
resetNonNumberTopperColor(btn.dataset.type);
lastPresetKey = null;
updateClassicDesign();
}));
numberTintSlider?.addEventListener('input', () => {
GC.setNumberTintOpacity(numberTintSlider.value);
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, topperSizeInp]
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInp || 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 };
})();
})();