751 lines
35 KiB
JavaScript
751 lines
35 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();
|
||
|
||
// -------- 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 6–10, 16–20, ...)
|
||
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(); } });
|
||
})();
|