1107 lines
56 KiB
JavaScript
1107 lines
56 KiB
JavaScript
(() => {
|
|
'use strict';
|
|
|
|
// -------- helpers ----------
|
|
const log = (...a) => console.log('[Classic]', ...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];
|
|
}
|
|
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 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: '#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 0.5;
|
|
}
|
|
function setNumberTintOpacity(v) {
|
|
const clamped = clamp01(parseFloat(v));
|
|
try { localStorage.setItem(NUMBER_TINT_OPACITY_KEY, String(clamped)); } catch {}
|
|
return clamped;
|
|
}
|
|
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;
|
|
|
|
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; }
|
|
};
|
|
|
|
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){
|
|
const shape = cell.shape;
|
|
const base = shape.base || {};
|
|
const scale = cellScale(cell);
|
|
const transform = [(base.transform||''), `scale(${scale})`].join(' ');
|
|
const commonAttrs = {
|
|
'vector-effect': 'non-scaling-stroke',
|
|
stroke: borderEnabled ? '#111827' : 'none',
|
|
'stroke-width': borderEnabled ? 0.6 : 0,
|
|
'paint-order': 'stroke fill', class: 'balloon',
|
|
fill: explicitFill || '#cccccc'
|
|
};
|
|
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;
|
|
if (base.image) {
|
|
const w = base.width || 1, h = base.height || 1;
|
|
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 && cell.isTopper && (model.topperType || '').startsWith('num-')) {
|
|
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'
|
|
}));
|
|
}
|
|
} 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 = 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);
|
|
return { x: p.x * rel * pxUnit, y: p.y * rel * pxUnit };
|
|
}
|
|
|
|
// === 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();
|
|
const balloonsPerCluster = pattern.balloonsPerCluster || 4;
|
|
const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse));
|
|
const rowColorPatterns = {};
|
|
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],
|
|
];
|
|
|
|
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);
|
|
|
|
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 colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster];
|
|
cell.colorCode = colorCode;
|
|
colorInfo = model.palette[colorCode];
|
|
fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent';
|
|
}
|
|
|
|
const scale = cellScale(cell), shapeRadius = cell.shape.base.radius || 0.5, size = shapeRadius * scale;
|
|
bbox.add(c.x - size, c.y - size);
|
|
bbox.add(c.x + size, c.y + size);
|
|
const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model, colorInfo);
|
|
v.attrs.transform = `translate(${c.x},${c.y}) ${v.attrs.transform || ''}`;
|
|
const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0));
|
|
(layers[zi] ||= []).push(v);
|
|
};
|
|
|
|
layers.forEach(layer => layer && layer.forEach(v => kids.push(v)));
|
|
const margin = 20;
|
|
const vb = [ bbox.min.x - margin, bbox.min.y - margin, Math.max(1,bbox.w()) + margin*2, Math.max(1,bbox.h()) + margin*2 ].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' }) ]
|
|
));
|
|
}
|
|
});
|
|
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' }, [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
|
|
};
|
|
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');
|
|
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();
|
|
|
|
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));
|
|
|
|
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;
|
|
}
|
|
|
|
activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`;
|
|
}
|
|
|
|
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 };
|
|
if (activeTarget === 'T') {
|
|
const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round';
|
|
if (currentType.startsWith('num-')) {
|
|
setNumberTintColor(selectedColor.hex);
|
|
if (numberTintSlider) numberTintSlider.value = getNumberTintOpacity();
|
|
} else {
|
|
setTopperColor(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(); });
|
|
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');
|
|
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 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';
|
|
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;
|
|
}
|
|
|
|
let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral';
|
|
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 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', patternLayout);
|
|
};
|
|
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();
|
|
|
|
function updateClassicDesign() {
|
|
if (!lengthInp || !patSel) return;
|
|
patSel.value = computePatternName();
|
|
const patternName = patSel.value || 'Arch 4';
|
|
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);
|
|
|
|
GC.setTopperEnabled(showTopper);
|
|
GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2));
|
|
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);
|
|
if (document.body) {
|
|
if (showTopper) document.body.dataset.topperOverlay = '1';
|
|
else delete document.body.dataset.topperOverlay;
|
|
}
|
|
window.__updateFloatingNudge?.();
|
|
if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
|
|
refreshClassicPaletteUi?.();
|
|
ctrl.selectPattern(patternName);
|
|
persistState();
|
|
}
|
|
|
|
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));
|
|
patSel?.addEventListener('change', () => {
|
|
lastPresetKey = null;
|
|
syncPatternStateFromSelect();
|
|
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'; lastPresetKey = null; applyPatternButtons(); updateClassicDesign(); }));
|
|
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(); } });
|
|
})();
|