balloonDesign/classic.js

751 lines
35 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 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();
// -------- persistent color selection (now supports image textures) ----------
const PALETTE_KEY = 'classic:colors:v2';
const TOPPER_COLOR_KEY = 'classic:topperColor:v2';
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, 5).map(hex => ({ hex: normHex(hex), image: null }));
} else if (typeof saved[0] === 'object' && saved[0] !== null) {
arr = saved.slice(0, 5);
}
while (arr.length < 5) arr.push({ hex: '#ffffff', image: null });
}
} catch (e) { console.error('Failed to parse classic colors:', e); }
return arr;
}
function setClassicColors(arr) {
const clean = (arr || []).slice(0, 5).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 buildClassicPalette() {
const colors = getClassicColors();
const palette = { 0: { colour: '#FFFFFF', name: 'No Colour', image: null } };
colors.forEach((c, i) => {
palette[i + 1] = { colour: c.hex, image: c.image };
});
return palette;
}
function flattenPalette() {
const out = [];
if (Array.isArray(window.PALETTE)) {
window.PALETTE.forEach(group => {
(group.colors || []).forEach(c => {
if (!c?.hex) return;
out.push({
hex: normHex(c.hex), name: c.name || c.hex,
family: group.family || '', image: c.image || null
});
});
});
}
const seen = new Set();
return out.filter(c => (seen.has(c.hex) ? false : (seen.add(c.hex), true)));
}
// -------- tiny grid engine (Mithril) ----------
function GridCalculator() {
if (typeof window.m === 'undefined') throw new Error('Mithril (m) not loaded');
let pxUnit = 10;
let clusters = 10;
let reverse = false;
let topperEnabled = false;
let topperType = 'round';
let topperOffsetX_Px = 0;
let topperOffsetY_Px = 0;
let topperSizeMultiplier = 1;
let shineEnabled = true;
const patterns = {};
const api = {
patterns,
initialPattern: 'Arch 4',
controller: (el) => makeController(el),
setClusters(n) { clusters = Math.max(1, (Number(n)|0) || 10); },
setReverse(on){ reverse = !!on; },
setTopperEnabled(on) { topperEnabled = !!on; },
setTopperType(type) { topperType = type || 'round'; },
setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; },
setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; },
setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; },
setShineEnabled(on) { shineEnabled = !!on; }
};
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){
const shape = cell.shape;
const scale = cellScale(cell);
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
const commonAttrs = {
'vector-effect': 'non-scaling-stroke', stroke: '#111827',
'stroke-width': 2, '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;
}
let shapeEl;
if (shape.base.type === 'path') shapeEl = svg('path', { ...commonAttrs, d: shape.base.d });
else shapeEl = svg('ellipse', { ...commonAttrs, cx:0, cy:0, rx:0.5, ry:0.5 });
const kids = [shapeEl];
const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round'));
if (applyShine) {
kids.push(svg('ellipse', {
class: 'shine', cx: -0.15, cy: -0.15, rx: 0.22, ry: 0.13,
fill: '#ffffff', opacity: 0.45, transform: 'rotate(-25)', 'pointer-events': 'none'
}));
}
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 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;
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;
} else {
c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model);
const rowIndex = cell.y;
if (!rowColorPatterns[rowIndex]) {
const qEff = rowIndex + 1;
let pat;
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]];
}
}
if (reversed && pat.length > 1) {
pat.reverse();
}
// --- NEW: swap left/right after every 5 clusters ---
const SWAP_EVERY = 5; // clusters per block
const blockIndex = Math.floor(rowIndex / SWAP_EVERY);
// swap on blocks #2, #4, #6, ... (i.e., rows 610, 1620, ...)
if (blockIndex % 2 === 1) {
if (balloonsPerCluster === 5) {
// [leftMid, leftBack, front, rightBack, rightMid]
[pat[0], pat[4]] = [pat[4], pat[0]];
// [pat[1], pat[3]] = [pat[3], pat[1]];
}
}
rowColorPatterns[rowIndex] = pat;
}
const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster];
cell.colorCode = colorCode;
const 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);
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 };
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 === 'Column 4' && 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';
}
// --- 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}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5}, 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}, size:20}
},
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) };
}
};
// --- START: MODIFIED SECTION ---
// This is the new 'Column 5' definition, adapted from your template file.
patterns['Column 5'] = {
baseBalloonSize: 25,
_reverse: false,
balloonsPerCluster: 5, // Kept this from classic.js to ensure 5-color spiral
tile: { size: { x: 5, y: 1 } },
cellsPerRow: 1,
cellsPerColumn: 5,
// Balloon shapes from your template, converted to classic.js format
// (type: "qlink" is approx size: 3.0)
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 }
},
// gridX function from your template
// (I've hard-coded `this.exploded` to false, as it's not in classic.js)
gridX(row, col) {
var mid = 0.6; // this.exploded ? 0.2 : 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 function is inherited from Column 4 via `deriveFrom` in your template.
// So, we use the gridY function from this file's 'Column 4'.
gridY(row, col){
return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2));
},
// createCell function from your template, adapted for classic.js
createCell(x, y) {
var yOdd = !!(y % 2);
// Re-created logic from template's createCell
const shapePattern = yOdd ?
['middle', 'back', 'front', 'back', 'middle'] :
['middle2', 'front2', 'back2', 'front2', 'middle2'];
var shapeName = shapePattern[x % 5];
var shape = this.balloonShapes[shapeName];
// Return in classic.js format
return shape ? { shape: {...shape} } : null;
}
};
// This is the new 'Arch 5' definition.
// It derives from the new 'Column 5' and uses the same arching logic as 'Arch 4'.
patterns['Arch 5'] = {
deriveFrom: 'Column 5',
transform(point, col, row, model){
// This transform logic is standard and will work with the new Column 5's gridY
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 ---
Object.keys(patterns).forEach(n => extend(patterns[n]));
return api;
}
const patternSlotCount = (name) => ((name || '').includes('5') ? 5 : 4);
function initClassicColorPicker(onColorChange) {
const slots = Array.from(document.querySelectorAll('#classic-slots .slot-btn')), 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'), dockColorBtn = document.getElementById('dock-classic-color');
if (!slots.length || !topperSwatch || !swatchGrid || !activeLabel) return;
topperSwatch.classList.add('tab-btn');
let classicColors = getClassicColors(), activeTarget = '1';
const syncDockColor = (color) => {
if (!dockColorBtn) return;
const next = color?.hex ? color : { hex: '#2563eb', image: null };
if (next.image) {
dockColorBtn.style.backgroundImage = `url("${next.image}")`;
dockColorBtn.style.backgroundSize = '200%';
dockColorBtn.style.backgroundColor = 'transparent';
} else {
dockColorBtn.style.backgroundImage = 'none';
dockColorBtn.style.backgroundColor = next.hex;
}
};
function visibleSlotCount() {
const patSelect = document.getElementById('classic-pattern');
const name = patSelect?.value || 'Arch 4';
return patternSlotCount(name);
}
function enforceSlotVisibility() {
const count = visibleSlotCount();
slots.forEach((slot, i) => {
const show = i < count;
slot.classList.toggle('hidden', !show);
if (!show && activeTarget === slot.dataset.slot) activeTarget = '1';
});
if (parseInt(activeTarget, 10) > count) activeTarget = '1';
}
function updateUI() {
enforceSlotVisibility();
[...slots, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); });
slots.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 topperColor = getTopperColor();
topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none';
topperSwatch.style.backgroundColor = topperColor.hex;
topperSwatch.style.backgroundSize = '200%';
topperSwatch.style.backgroundPosition = 'center';
activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`;
const displayColor = activeTarget === 'T' ? topperColor : classicColors[(parseInt(activeTarget, 10) || 1) - 1] || classicColors[0];
syncDockColor(displayColor);
}
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.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') setTopperColor(selectedColor);
else {
const index = parseInt(activeTarget, 10) - 1;
if (index >= 0 && index < 5) { classicColors[index] = selectedColor; setClassicColors(classicColors); }
}
updateUI(); onColorChange();
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
});
row.appendChild(sw);
});
swatchGrid.appendChild(row);
});
slots.forEach(slot => { slot.addEventListener('click', () => { activeTarget = slot.dataset.slot; updateUI(); }); });
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();
});
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'), topperTypeSelect = document.getElementById('classic-topper-type'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), lengthPresetWrap = document.getElementById('classic-length-presets'), lengthLabel = document.getElementById('classic-length-label');
const ARCH_LENGTHS = [20, 25, 30, 35, 40];
const COLUMN_LENGTHS = [3,4,5,6,7,8,9,10,11,12,13,14,15];
const ARCH_DEFAULT = 20;
const COLUMN_DEFAULT = 5;
const lengthDialDrawer = document.getElementById('classic-length-drawer');
const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper'));
const slotButtons = Array.from(document.querySelectorAll('#classic-slots .slot-btn'));
const patternBtns = Array.from(document.querySelectorAll('.classic-pattern-btn'));
const variantBtns = Array.from(document.querySelectorAll('.classic-variant-btn'));
const topperBtns = Array.from(document.querySelectorAll('.classic-topper-btn'));
const topperInline = document.getElementById('topper-inline');
const topperTypeInline = document.getElementById('classic-topper-type-inline');
const topperSizeInline = document.getElementById('classic-topper-size-inline');
const topperColorInline = document.getElementById('classic-topper-color-swatch-inline');
let topperOffsetX = 0, topperOffsetY = 0;
let userTopperChoice = false;
if (!display) return fail('#classic-display not found');
const GC = GridCalculator(), ctrl = GC.controller(display);
const syncDockPatternButtons = (patternName) => {
const isArch = (patternName || '').toLowerCase().includes('arch');
const isFive = (patternName || '').includes('5');
patternBtns.forEach(btn => {
const base = (btn.dataset.patternBase || '').toLowerCase();
const active = isArch ? base === 'arch' : base === 'column';
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', String(active));
});
variantBtns.forEach(btn => {
const active = btn.dataset.patternVariant === (isFive ? '5' : '4');
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', String(active));
});
};
const syncDockTopperButton = () => {
const on = !!topperEnabledCb?.checked;
topperBtns.forEach(btn => {
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
});
};
const syncTopperInline = () => {
const on = !!topperEnabledCb?.checked;
if (topperInline) topperInline.classList.toggle('hidden', !on);
if (topperTypeInline && topperTypeSelect) topperTypeInline.value = topperTypeSelect.value;
if (topperSizeInline && topperSizeInp) topperSizeInline.value = topperSizeInp.value;
if (topperColorInline) {
const tc = getTopperColor();
topperColorInline.style.backgroundImage = tc.image ? `url("${tc.image}")` : 'none';
topperColorInline.style.backgroundColor = tc.hex;
}
};
const renderLengthPresets = (patternName) => {
if (!lengthInp) return;
const isArch = (patternName || '').toLowerCase().includes('arch');
const list = isArch ? ARCH_LENGTHS : COLUMN_LENGTHS;
const current = parseFloat(lengthInp.value) || (isArch ? ARCH_DEFAULT : COLUMN_DEFAULT);
if (lengthLabel) lengthLabel.textContent = `${current} ft`;
const targets = [lengthPresetWrap, lengthDialDrawer];
targets.forEach(container => {
if (!container) return;
container.innerHTML = '';
list.forEach(len => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'dock-pill';
if (Math.abs(current - len) < 1e-6) btn.classList.add('active');
btn.textContent = `${len} ft`;
btn.dataset.len = len;
btn.addEventListener('click', () => {
lengthInp.value = len;
if (lengthLabel) lengthLabel.textContent = `${len} ft`;
updateClassicDesign();
btn.classList.add('ping');
btn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
setTimeout(() => btn.classList.remove('ping'), 280);
document.getElementById('classic-drawer-pattern')?.classList.add('hidden');
window.__dockActiveMenu = null;
});
container.appendChild(btn);
});
});
};
const ensureLengthForPattern = (patternName) => {
if (!lengthInp) return;
const isArch = (patternName || '').toLowerCase().includes('arch');
const list = isArch ? ARCH_LENGTHS : COLUMN_LENGTHS;
const cur = parseFloat(lengthInp.value);
if (!list.includes(cur)) {
const def = isArch ? ARCH_DEFAULT : COLUMN_DEFAULT;
lengthInp.value = def;
}
};
function updateClassicDesign() {
if (!lengthInp || !patSel) return;
const patternName = patSel.value || 'Arch 4';
ensureLengthForPattern(patternName);
const isColumn = patternName.toLowerCase().includes('column');
const hasTopper = patternName.includes('4') || patternName.includes('5');
const showToggle = isColumn && hasTopper;
if (showToggle && topperEnabledCb && !userTopperChoice) {
topperEnabledCb.checked = true;
}
if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle);
const showTopper = showToggle && topperEnabledCb?.checked;
slotButtons.forEach((btn, i) => {
const count = patternSlotCount(patternName);
const show = i < count;
btn.classList.toggle('hidden', !show);
});
topperControls.classList.toggle('hidden', !showTopper);
topperTypeSelect.disabled = !showTopper;
GC.setTopperEnabled(showTopper);
GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2));
GC.setReverse(!!reverseCb?.checked);
GC.setTopperType(topperTypeSelect.value);
GC.setTopperOffsetX(topperOffsetX);
GC.setTopperOffsetY(topperOffsetY);
GC.setTopperSize(topperSizeInp?.value);
GC.setShineEnabled(!!shineEnabledCb?.checked);
if(clusterHint) clusterHint.textContent = `${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
ctrl.selectPattern(patternName);
syncDockPatternButtons(patternName);
syncDockTopperButton();
renderLengthPresets(patternName);
syncTopperInline();
}
const setLengthForPattern = () => {
if (!lengthInp || !patSel) return;
const isArch = (patSel.value || '').toLowerCase().includes('arch');
lengthInp.value = isArch ? ARCH_DEFAULT : COLUMN_DEFAULT;
};
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', () => {
ensureLengthForPattern(patSel.value);
updateClassicDesign();
renderLengthPresets(patSel.value);
});
topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => {
const dx = Number(btn.dataset.dx || 0);
const dy = Number(btn.dataset.dy || 0);
topperOffsetX += dx;
topperOffsetY += dy;
GC.setTopperOffsetX(topperOffsetX);
GC.setTopperOffsetY(topperOffsetY);
updateClassicDesign();
}));
[lengthInp, reverseCb, topperEnabledCb, topperTypeSelect, topperSizeInp]
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, updateClassicDesign); });
topperEnabledCb?.addEventListener('change', updateClassicDesign);
topperEnabledCb?.addEventListener('change', (e) => {
if (e.isTrusted) userTopperChoice = true;
syncDockTopperButton();
syncTopperInline();
});
topperTypeInline?.addEventListener('change', () => {
if (topperTypeSelect) topperTypeSelect.value = topperTypeInline.value;
updateClassicDesign();
});
topperSizeInline?.addEventListener('input', () => {
if (topperSizeInp) topperSizeInp.value = topperSizeInline.value;
updateClassicDesign();
});
topperColorInline?.addEventListener('click', () => {
const sw = document.getElementById('classic-topper-color-swatch');
sw?.click();
});
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
initClassicColorPicker(updateClassicDesign);
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
setLengthForPattern();
updateClassicDesign();
renderLengthPresets(patSel?.value || '');
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(); } });
})();