Compare commits

...

3 Commits

8 changed files with 2667 additions and 184 deletions

View File

@ -86,6 +86,8 @@
const MANUAL_MODE_KEY = 'classic:manualMode:v1'; const MANUAL_MODE_KEY = 'classic:manualMode:v1';
const MANUAL_OVERRIDES_KEY = 'classic:manualOverrides:v1'; const MANUAL_OVERRIDES_KEY = 'classic:manualOverrides:v1';
const MANUAL_EXPANDED_KEY = 'classic:manualExpanded:v1'; const MANUAL_EXPANDED_KEY = 'classic:manualExpanded:v1';
const MANUAL_ROW_OFFSETS_KEY = 'classic:manualRowOffsets:v1';
const MANUAL_ROW_SCALES_KEY = 'classic:manualRowScales:v1';
const NUMBER_IMAGE_MAP = { const NUMBER_IMAGE_MAP = {
'0': 'output_webp/0.webp', '0': 'output_webp/0.webp',
'1': 'output_webp/1.webp', '1': 'output_webp/1.webp',
@ -183,13 +185,35 @@
} catch {} } catch {}
return {}; return {};
} }
function loadManualRowOffsets() {
try {
const saved = JSON.parse(localStorage.getItem(MANUAL_ROW_OFFSETS_KEY));
if (saved && typeof saved === 'object') return saved;
} catch {}
return {};
}
function loadManualRowScales() {
try {
const saved = JSON.parse(localStorage.getItem(MANUAL_ROW_SCALES_KEY));
if (saved && typeof saved === 'object') return saved;
} catch {}
return {};
}
function saveManualOverrides(map) { function saveManualOverrides(map) {
try { localStorage.setItem(MANUAL_OVERRIDES_KEY, JSON.stringify(map || {})); } catch {} try { localStorage.setItem(MANUAL_OVERRIDES_KEY, JSON.stringify(map || {})); } catch {}
} }
function saveManualRowOffsets(map) {
try { localStorage.setItem(MANUAL_ROW_OFFSETS_KEY, JSON.stringify(map || {})); } catch {}
}
function saveManualRowScales(map) {
try { localStorage.setItem(MANUAL_ROW_SCALES_KEY, JSON.stringify(map || {})); } catch {}
}
function manualKey(patternName, rowCount) { function manualKey(patternName, rowCount) {
return `${patternName || ''}::${rowCount || 0}`; return `${patternName || ''}::${rowCount || 0}`;
} }
const manualOverrides = loadManualOverrides(); const manualOverrides = loadManualOverrides();
const manualRowOffsets = loadManualRowOffsets();
const manualRowScales = loadManualRowScales();
function manualOverrideCount(patternName, rowCount) { function manualOverrideCount(patternName, rowCount) {
const key = manualKey(patternName, rowCount); const key = manualKey(patternName, rowCount);
const entry = manualOverrides[key]; const entry = manualOverrides[key];
@ -239,6 +263,18 @@
}); });
return out; return out;
} }
function getManualRowOffsets(patternName, rowCount) {
const key = manualKey(patternName, rowCount);
const entry = manualRowOffsets[key];
if (!entry || typeof entry !== 'object') return {};
return entry;
}
function getManualRowScales(patternName, rowCount) {
const key = manualKey(patternName, rowCount);
const entry = manualRowScales[key];
if (!entry || typeof entry !== 'object') return {};
return entry;
}
// Manual palette (used in Manual mode project palette) // Manual palette (used in Manual mode project palette)
let projectPaletteBox = null; let projectPaletteBox = null;
let renderProjectPalette = () => {}; let renderProjectPalette = () => {};
@ -294,6 +330,7 @@
let pxUnit = 10; let pxUnit = 10;
let clusters = 10; let clusters = 10;
let lengthFt = null;
let reverse = false; let reverse = false;
let topperEnabled = false; let topperEnabled = false;
let topperType = 'round'; let topperType = 'round';
@ -317,6 +354,10 @@
initialPattern: 'Arch 4', initialPattern: 'Arch 4',
controller: (el) => makeController(el), controller: (el) => makeController(el),
setClusters(n) { clusters = Math.max(1, (Number(n)|0) || 10); }, setClusters(n) { clusters = Math.max(1, (Number(n)|0) || 10); },
setLengthFt(n) {
const next = Number(n);
lengthFt = Number.isFinite(next) ? next : null;
},
setReverse(on){ reverse = !!on; }, setReverse(on){ reverse = !!on; },
setTopperEnabled(on) { topperEnabled = !!on; }, setTopperEnabled(on) { topperEnabled = !!on; },
setTopperType(type) { topperType = type || 'round'; }, setTopperType(type) { topperType = type || 'round'; },
@ -451,6 +492,35 @@
const rel = (pattern.baseBalloonSize && base.baseBalloonSize) ? pattern.baseBalloonSize/base.baseBalloonSize : 1; 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) }; 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); if (pattern.transform) p = pattern.transform(p,x,y,model);
if (pattern.sizeProfile === 'corinthian') {
const totalRows = pattern.cellsPerRow * model.rowCount;
const sizeScale = corinthianScale(y, totalRows, pattern.baseBalloonSize, pattern.sizeProfileOpts);
const axisScale = sizeScale * corinthianAxisScale(y, totalRows, pattern.sizeProfileOpts);
const rowScale = corinthianRowScale(sizeScale, pattern.sizeProfileOpts);
const cols = pattern.cellsPerColumn || 1;
let center = 0;
if (cols > 1) {
let sum = 0;
for (let i = 0; i < cols; i++) {
sum += pattern.gridX(model.pattern.cellsPerRow > 1 ? y : i, i);
}
center = sum / cols;
}
p = { x: center + (p.x - center) * axisScale, y: corinthianRowY(y, pattern, model, pattern.sizeProfileOpts) * rowScale };
}
if (model.manualMode && model.manualRowScales && Number.isFinite(model.manualRowScales[y])) {
const isColumnPattern = (model.patternName || '').toLowerCase().includes('column');
const cols = pattern.cellsPerColumn || 1;
if (isColumnPattern && cols > 1) {
let sum = 0;
for (let i = 0; i < cols; i++) {
sum += pattern.gridX(model.pattern.cellsPerRow > 1 ? y : i, i);
}
const center = sum / cols;
const tighten = model.manualRowScales[y];
p = { x: center + (p.x - center) * tighten, y: p.y };
}
}
let xPx = p.x * rel * pxUnit; let xPx = p.x * rel * pxUnit;
let yPx = p.y * rel * pxUnit; let yPx = p.y * rel * pxUnit;
if (model.manualMode && (model.explodedGapPx || 0) > 0) { if (model.manualMode && (model.explodedGapPx || 0) > 0) {
@ -474,6 +544,24 @@
yPx += rowIndex * gap; // columns: separate along the vertical path yPx += rowIndex * gap; // columns: separate along the vertical path
} }
} }
if (pattern.sizeProfile === 'corinthian') {
const perRow = model.corinthianRowOffsets;
if (perRow && Number.isFinite(perRow[y])) {
yPx += perRow[y];
} else if (pattern.sizeProfileOpts?.rowOffsetBySize) {
const baseInches = Number.isFinite(pattern.sizeProfileOpts.baseInches) ? pattern.sizeProfileOpts.baseInches : 11;
const totalRows = pattern.cellsPerRow * model.rowCount;
const scale = corinthianScale(y, totalRows, pattern.baseBalloonSize, pattern.sizeProfileOpts);
const inches = Math.round(scale * baseInches);
const offset = pattern.sizeProfileOpts.rowOffsetBySize[inches];
if (Number.isFinite(offset)) yPx += offset;
}
}
const isColumnPattern = (model.patternName || '').toLowerCase().includes('column');
const rowOffsets = model.manualRowOffsets || null;
if (isColumnPattern && rowOffsets && Number.isFinite(rowOffsets[y])) {
yPx += rowOffsets[y];
}
return { x: xPx, y: yPx }; return { x: xPx, y: yPx };
} }
@ -490,6 +578,192 @@ function distinctPaletteSlots(palette) {
return out.length ? out : [1,2,3,4,5]; return out.length ? out : [1,2,3,4,5];
} }
function corinthianScale(rowIndex, totalRows, baseSize, opts = {}) {
const base = Number.isFinite(baseSize) && baseSize > 0 ? baseSize : 25;
const baseInches = Number.isFinite(opts.baseInches) ? opts.baseInches : 11;
const sequence = Array.isArray(opts.sizeSequence) ? opts.sizeSequence : null;
if (sequence && sequence.length) {
if (totalRows <= 1) return sequence[0] / baseInches;
const t = rowIndex / (totalRows - 1);
const pos = t * (sequence.length - 1);
const mode = opts.sequenceMode || 'lerp';
let inches;
if (mode === 'profile') {
const top = Array.isArray(opts.profileTop) ? opts.profileTop : [];
const bottom = Array.isArray(opts.profileBottom) ? opts.profileBottom : [];
const mid = Number.isFinite(opts.profileMid) ? opts.profileMid : (sequence[Math.floor(sequence.length / 2)] || baseInches);
if (totalRows >= (top.length + bottom.length + 1)) {
if (rowIndex < top.length) inches = top[rowIndex];
else if (rowIndex >= totalRows - bottom.length) inches = bottom[rowIndex - (totalRows - bottom.length)];
else inches = mid;
} else {
const idx = Math.floor(pos);
inches = sequence[Math.max(0, Math.min(sequence.length - 1, idx))];
}
} else if (mode === 'step') {
const idx = Math.round(pos);
inches = sequence[Math.max(0, Math.min(sequence.length - 1, idx))];
} else {
const idx = Math.floor(pos);
const frac = pos - idx;
const a = sequence[Math.max(0, Math.min(sequence.length - 1, idx))];
const b = sequence[Math.max(0, Math.min(sequence.length - 1, idx + 1))];
inches = a + (b - a) * frac;
}
return inches / baseInches;
}
const edge = Number.isFinite(opts.edge) ? opts.edge
: (Number.isFinite(opts.edgeInches) ? (opts.edgeInches / baseInches) : 1.2);
const mid = Number.isFinite(opts.mid) ? opts.mid
: (Number.isFinite(opts.midInches) ? (opts.midInches / baseInches) : 0.85);
if (totalRows <= 1) return edge;
const t = rowIndex / (totalRows - 1);
const edgeWeight = (Math.cos(2 * Math.PI * t) + 1) / 2;
return mid + (edge - mid) * edgeWeight;
}
function corinthianAxisScale(rowIndex, totalRows, opts = {}) {
const tighten = Number.isFinite(opts.axisTighten) ? opts.axisTighten : 0.3;
if (totalRows <= 1) return 1;
const t = rowIndex / (totalRows - 1);
const edgeWeight = (Math.cos(2 * Math.PI * t) + 1) / 2;
const scale = 1 - tighten * (1 - edgeWeight);
return Math.max(0.2, scale);
}
function corinthianRowScale(sizeScale, opts = {}) {
const tighten = Number.isFinite(opts.verticalTighten) ? opts.verticalTighten : 0.15;
return sizeScale * (1 - tighten * (1 - sizeScale));
}
function corinthianRowY(rowIndex, pattern, model, opts = {}) {
if (!model._corinthianYCache) model._corinthianYCache = { y: [], scale: [] };
const cache = model._corinthianYCache;
if (Number.isFinite(cache.y[rowIndex])) return cache.y[rowIndex];
const totalRows = pattern.cellsPerRow * model.rowCount;
const spacingPower = Number.isFinite(opts.rowSpacingPower) ? opts.rowSpacingPower : 0.7;
const minRowScale = Number.isFinite(opts.minRowScale) ? opts.minRowScale : 0.55;
for (let y = cache.y.length; y <= rowIndex; y++) {
const baseY = pattern.gridY(y, 0);
const scale = corinthianScale(y, totalRows, pattern.baseBalloonSize, opts);
if (y === 0) {
cache.y[y] = baseY;
cache.scale[y] = scale;
continue;
}
const prevBaseY = pattern.gridY(y - 1, 0);
const delta = baseY - prevBaseY;
const prevScale = cache.scale[y - 1] ?? scale;
const avgScale = (prevScale + scale) / 2;
const easedScale = Math.max(minRowScale, Math.pow(avgScale, spacingPower));
cache.y[y] = cache.y[y - 1] + (delta * easedScale);
cache.scale[y] = scale;
}
return cache.y[rowIndex];
}
function resampleRowOffsets(baseMap, baseCount, rowCount) {
if (!baseMap || typeof baseMap !== 'object') return null;
if (rowCount === baseCount) return baseMap;
const maxBase = Math.max(1, baseCount - 1);
const maxRow = Math.max(1, rowCount - 1);
const baseOffsets = new Array(baseCount).fill(0);
let last = Number(baseMap[0]) || 0;
baseOffsets[0] = last;
for (let i = 1; i < baseCount; i++) {
const val = Number(baseMap[i]);
if (Number.isFinite(val)) last = val;
baseOffsets[i] = last;
}
const baseDeltas = new Array(maxBase).fill(0);
for (let i = 0; i < maxBase; i++) {
baseDeltas[i] = baseOffsets[i + 1] - baseOffsets[i];
}
const deltas = new Array(maxRow).fill(0);
for (let i = 0; i < maxRow; i++) {
const pos = (i / maxRow) * maxBase;
const lo = Math.floor(pos);
const hi = Math.min(maxBase - 1, Math.ceil(pos));
const a = baseDeltas[lo] || 0;
const b = baseDeltas[hi] || 0;
const t = hi === lo ? 0 : (pos - lo) / (hi - lo);
deltas[i] = a + (b - a) * t;
}
const out = {};
out[0] = Math.round(baseOffsets[0]);
for (let i = 1; i < rowCount; i++) {
out[i] = Math.round(out[i - 1] + deltas[i - 1]);
}
return out;
}
function blendRowOffsets(baseA, countA, baseB, countB, rowCount, t) {
const resampledA = resampleRowOffsets(baseA, countA, rowCount);
const resampledB = resampleRowOffsets(baseB, countB, rowCount);
if (!resampledA && !resampledB) return null;
if (!resampledB) return resampledA;
if (!resampledA) return resampledB;
const out = {};
for (let row = 0; row < rowCount; row++) {
const a = Number(resampledA[row]) || 0;
const b = Number(resampledB[row]) || 0;
out[row] = Math.round(a + (b - a) * t);
}
return out;
}
function collectRowOffsetMaps(patternName) {
const out = {};
Object.entries(manualRowOffsets || {}).forEach(([key, map]) => {
if (!map || typeof map !== 'object') return;
const [name, rowsStr] = String(key).split('::');
const rows = parseInt(rowsStr, 10);
if (!Number.isFinite(rows)) return;
if (name === patternName) out[rows] = map;
});
return out;
}
function chooseRowOffsets(mapSet, rowCount, lengthFt) {
if (!mapSet || typeof mapSet !== 'object') return null;
const keys = Object.keys(mapSet).map(n => parseInt(n, 10)).filter(n => Number.isFinite(n)).sort((a, b) => a - b);
if (!keys.length || !Number.isFinite(rowCount) || rowCount <= 0) return null;
if (Number.isFinite(lengthFt)) {
const lowFt = Math.floor(lengthFt);
const highFt = Math.ceil(lengthFt);
const lowCount = lowFt * 2;
const highCount = highFt * 2;
if (lowCount === highCount) {
if (mapSet[lowCount]) return resampleRowOffsets(mapSet[lowCount], lowCount, rowCount);
} else if (mapSet[lowCount] && mapSet[highCount]) {
const tRaw = (lengthFt - lowFt) / (highFt - lowFt);
const t = Math.abs(tRaw - 0.5) < 0.001 ? 0.5 : tRaw;
return blendRowOffsets(mapSet[lowCount], lowCount, mapSet[highCount], highCount, rowCount, t);
}
}
if (mapSet[rowCount]) return mapSet[rowCount];
if (rowCount <= keys[0]) return resampleRowOffsets(mapSet[keys[0]], keys[0], rowCount);
if (rowCount >= keys[keys.length - 1]) return resampleRowOffsets(mapSet[keys[keys.length - 1]], keys[keys.length - 1], rowCount);
for (let i = 1; i < keys.length; i++) {
const low = keys[i - 1];
const high = keys[i];
if (rowCount > low && rowCount < high) {
const t = (rowCount - low) / (high - low);
return blendRowOffsets(mapSet[low], low, mapSet[high], high, rowCount, t);
}
}
return null;
}
function scaledRowOffsetsFor(pattern, rowCount, patternName, lengthFt) {
if (!Number.isFinite(rowCount) || rowCount <= 0) return null;
const manualMaps = collectRowOffsetMaps(patternName);
const fromManual = chooseRowOffsets(manualMaps, rowCount, lengthFt);
if (fromManual) return fromManual;
const byCount = pattern.sizeProfileOpts?.rowOffsetByRowCount;
return chooseRowOffsets(byCount, rowCount, lengthFt);
}
function newGrid(pattern, cells, container, model){ function newGrid(pattern, cells, container, model){
@ -501,9 +775,11 @@ function distinctPaletteSlots(palette) {
const rowColorPatterns = {}; const rowColorPatterns = {};
const wireframeMode = false; // per-balloon wireframe handled in cellView for unpainted balloons const wireframeMode = false; // per-balloon wireframe handled in cellView for unpainted balloons
const stackedSlots = (() => { const stackedSlots = (() => {
const slots = distinctPaletteSlots(model.palette); const slots = Object.keys(model.palette || {})
const limit = Math.max(1, Math.min(slots.length, balloonsPerCluster)); .map(Number)
return slots.slice(0, limit); .filter(n => Number.isFinite(n) && n > 0)
.sort((a, b) => a - b);
return slots.length ? slots : [1];
})(); })();
const colorBlock4 = [ const colorBlock4 = [
@ -555,9 +831,9 @@ function distinctPaletteSlots(palette) {
const rowIndex = cell.y; const rowIndex = cell.y;
if (!rowColorPatterns[rowIndex]) { if (!rowColorPatterns[rowIndex]) {
const totalRows = model.rowCount * (pattern.cellsPerRow || 1); const totalRows = model.rowCount * (pattern.cellsPerRow || 1);
const isRightHalf = false; // mirror mode removed const isCorinthianPattern = (model.patternName || '').toLowerCase().includes('corinthian');
const baseRow = rowIndex; const rowForPattern = rowIndex;
const qEff = baseRow + 1; const qEff = rowForPattern + 1;
let pat; let pat;
if (pattern.colorMode === 'stacked') { if (pattern.colorMode === 'stacked') {
@ -577,16 +853,17 @@ function distinctPaletteSlots(palette) {
// Swap left/right emphasis every 5 clusters to break repetition (per template override) // Swap left/right emphasis every 5 clusters to break repetition (per template override)
if (balloonsPerCluster === 5) { if (balloonsPerCluster === 5) {
const SWAP_EVERY = 5; const SWAP_EVERY = 5;
const blockIndex = Math.floor(rowIndex / SWAP_EVERY); const blockIndex = Math.floor(rowForPattern / SWAP_EVERY);
if (blockIndex % 2 === 1) { if (blockIndex % 2 === 1) {
[pat[0], pat[4]] = [pat[4], pat[0]]; [pat[0], pat[4]] = [pat[4], pat[0]];
} }
} }
if (pat.length > 1) { if (pat.length > 1 && reversed) {
let shouldReverse; if (isCorinthianPattern) {
shouldReverse = reversed; pat.push(pat.shift());
if (shouldReverse) pat.reverse(); }
pat.reverse();
} }
rowColorPatterns[rowIndex] = pat; rowColorPatterns[rowIndex] = pat;
@ -869,11 +1146,16 @@ function distinctPaletteSlots(palette) {
pattern, pattern,
cells: [], cells: [],
rowCount: clusters, rowCount: clusters,
corinthianRowOffsets: pattern.sizeProfile === 'corinthian'
? scaledRowOffsetsFor(pattern, clusters, name, lengthFt)
: null,
manualRowScales: getManualRowScales(name, clusters),
palette: buildClassicPalette(), palette: buildClassicPalette(),
topperColor: getTopperColor(), topperColor: getTopperColor(),
topperType, topperType,
shineEnabled, shineEnabled,
manualMode, manualMode,
manualRowOffsets: getManualRowOffsets(name, clusters),
manualFocusEnabled, manualFocusEnabled,
manualFloatingQuad, manualFloatingQuad,
explodedScale, explodedScale,
@ -887,6 +1169,13 @@ function distinctPaletteSlots(palette) {
let balloonIndexInCluster = 0; let balloonIndexInCluster = 0;
for (let x=0; x<cols; x++) { for (let x=0; x<cols; x++) {
const cellData = pattern.createCell(x,y); const cellData = pattern.createCell(x,y);
if (cellData && pattern.sizeProfile === 'corinthian' && cellData.shape && typeof cellData.shape.size === 'number') {
const scale = corinthianScale(y, rows, pattern.baseBalloonSize, pattern.sizeProfileOpts);
cellData.shape.size *= scale;
}
if (model.manualMode && cellData && model.manualRowScales && Number.isFinite(model.manualRowScales[y]) && cellData.shape && typeof cellData.shape.size === 'number') {
cellData.shape.size *= model.manualRowScales[y];
}
if (cellData) model.cells.push({ ...cellData, x, y, balloonIndexInCluster: balloonIndexInCluster++ }); if (cellData) model.cells.push({ ...cellData, x, y, balloonIndexInCluster: balloonIndexInCluster++ });
} }
} }
@ -1016,6 +1305,30 @@ function distinctPaletteSlots(palette) {
return { x: -r*Math.cos(a), y: -r*Math.sin(a) }; return { x: -r*Math.cos(a), y: -r*Math.sin(a) };
} }
}; };
patterns['Column 4 Corinthian'] = {
deriveFrom: 'Column 4',
sizeProfile: 'corinthian',
sizeProfileOpts: {
baseInches: 11,
sizeSequence: [11, 9, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 9, 11],
sequenceMode: 'profile',
profileTop: [11, 9, 7],
profileMid: 5,
profileBottom: [7, 9, 11],
axisTighten: 0,
verticalTighten: 0.15,
rowSpacingPower: 1.2,
minRowScale: 0.35,
rowOffsetBySize: { 11: -1, 9: 19, 7: 38, 5: 56 },
rowOffsetByRowCount: {
10: { 0: 0, 1: -13, 2: -23, 3: -29, 4: -23, 5: -19, 6: -13, 7: -2, 8: 5, 9: 8 },
12: { 0: 0, 1: 14, 2: 29, 3: 38, 4: 47, 5: 55, 6: 64, 7: 72, 8: 80, 9: 92, 10: 108, 11: 125 },
14: { 0: 0, 1: 18, 2: 27, 3: 38, 4: 47, 5: 55, 6: 60, 7: 68, 8: 76, 9: 85, 10: 93, 11: 101, 12: 117, 13: 134 },
16: { 0: 0, 1: 18, 2: 31, 3: 42, 4: 51, 5: 59, 6: 68, 7: 76, 8: 84, 9: 93, 10: 101, 11: 110, 12: 118, 13: 130, 14: 146, 15: 163 },
18: { 0: 0, 1: 18, 2: 31, 3: 38, 4: 47, 5: 55, 6: 64, 7: 68, 8: 76, 9: 85, 10: 93, 11: 102, 12: 110, 13: 118, 14: 127, 15: 139, 16: 150, 17: 168 }
}
}
};
// --- Column 5 (template geometry) --- // --- Column 5 (template geometry) ---
patterns['Column 5'] = { patterns['Column 5'] = {
baseBalloonSize: 25, baseBalloonSize: 25,
@ -1065,6 +1378,30 @@ function distinctPaletteSlots(palette) {
return { x: -r * Math.cos(a), y: -r * Math.sin(a) }; return { x: -r * Math.cos(a), y: -r * Math.sin(a) };
} }
}; };
patterns['Column 5 Corinthian'] = {
deriveFrom: 'Column 5',
sizeProfile: 'corinthian',
sizeProfileOpts: {
baseInches: 11,
sizeSequence: [11, 9, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 7, 9, 11],
sequenceMode: 'profile',
profileTop: [11, 9, 7],
profileMid: 5,
profileBottom: [7, 9, 11],
axisTighten: 0,
verticalTighten: 0.15,
rowSpacingPower: 1.2,
minRowScale: 0.35,
rowOffsetBySize: { 11: -1, 9: 19, 7: 38, 5: 56 },
rowOffsetByRowCount: {
10: { 0: 0, 1: -13, 2: -23, 3: -29, 4: -23, 5: -19, 6: -13, 7: -2, 8: 5, 9: 8 },
12: { 0: 0, 1: 14, 2: 29, 3: 38, 4: 47, 5: 55, 6: 64, 7: 72, 8: 80, 9: 92, 10: 108, 11: 125 },
14: { 0: 0, 1: 18, 2: 27, 3: 38, 4: 47, 5: 55, 6: 60, 7: 68, 8: 76, 9: 85, 10: 93, 11: 101, 12: 117, 13: 134 },
16: { 0: 0, 1: 18, 2: 31, 3: 42, 4: 51, 5: 59, 6: 68, 7: 76, 8: 84, 9: 93, 10: 101, 11: 110, 12: 118, 13: 130, 14: 146, 15: 163 },
18: { 0: 0, 1: 18, 2: 31, 3: 38, 4: 47, 5: 55, 6: 64, 7: 68, 8: 76, 9: 85, 10: 93, 11: 102, 12: 110, 13: 118, 14: 127, 15: 139, 16: 150, 17: 168 }
}
}
};
// --- END: MODIFIED SECTION --- // --- END: MODIFIED SECTION ---
// --- Stacked variants (same geometry, single-color clusters alternating rows) --- // --- Stacked variants (same geometry, single-color clusters alternating rows) ---
@ -1093,7 +1430,7 @@ function distinctPaletteSlots(palette) {
} }
function initClassicColorPicker(onColorChange) { function initClassicColorPicker(onColorChange) {
const slotsContainer = document.getElementById('classic-slots'), topperSwatch = document.getElementById('classic-topper-color-swatch'), swatchGrid = document.getElementById('classic-swatch-grid'), activeLabel = document.getElementById('classic-active-label'), randomizeBtn = document.getElementById('classic-randomize-colors'), addSlotBtn = document.getElementById('classic-add-slot'), activeChip = document.getElementById('classic-active-chip'), floatingChip = document.getElementById('classic-active-chip-floating'), activeDot = document.getElementById('classic-active-dot'), floatingDot = document.getElementById('classic-active-dot-floating'); const 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'), removeSlotBtn = document.getElementById('classic-remove-slot'), activeChip = document.getElementById('classic-active-chip'), floatingChip = document.getElementById('classic-active-chip-floating'), activeDot = document.getElementById('classic-active-dot'), floatingDot = document.getElementById('classic-active-dot-floating');
const projectBlock = document.getElementById('classic-project-block'); const projectBlock = document.getElementById('classic-project-block');
const replaceBlock = document.getElementById('classic-replace-block'); const replaceBlock = document.getElementById('classic-replace-block');
const replaceFromSel = document.getElementById('classic-replace-from'); const replaceFromSel = document.getElementById('classic-replace-from');
@ -1349,7 +1686,7 @@ function distinctPaletteSlots(palette) {
sw.title = item.label || item.hex || 'Color'; sw.title = item.label || item.hex || 'Color';
sw.addEventListener('click', () => { sw.addEventListener('click', () => {
manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: item.hex || '#ffffff', image: item.image || null }) || { hex: item.hex || '#ffffff', image: item.image || null }; manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: item.hex || '#ffffff', image: item.image || null }) || { hex: item.hex || '#ffffff', image: item.image || null };
updateClassicDesign(); onColorChange?.();
}); });
row.appendChild(sw); row.appendChild(sw);
}); });
@ -1396,9 +1733,15 @@ function distinctPaletteSlots(palette) {
const lengthInp = document.getElementById('classic-length-ft'); const lengthInp = document.getElementById('classic-length-ft');
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
const maxSlots = Math.min(MAX_SLOTS, clusters); const maxSlots = Math.min(MAX_SLOTS, clusters);
const baseSlots = patternSlotCount(patSelect?.value || '');
addSlotBtn.classList.toggle('hidden', !isStacked); addSlotBtn.classList.toggle('hidden', !isStacked);
addSlotBtn.disabled = !isStacked || slotCount >= maxSlots; addSlotBtn.disabled = !isStacked || slotCount >= maxSlots;
} }
if (removeSlotBtn) {
const baseSlots = patternSlotCount(patSelect?.value || '');
removeSlotBtn.classList.toggle('hidden', !isStacked);
removeSlotBtn.disabled = !isStacked || slotCount <= baseSlots;
}
const manualModeOn = isManual(); const manualModeOn = isManual();
const sharedActive = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; const sharedActive = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null };
@ -1438,6 +1781,7 @@ function distinctPaletteSlots(palette) {
const row = slotsContainer.parentElement; const row = slotsContainer.parentElement;
if (row) row.style.display = manualModeOn ? 'none' : ''; if (row) row.style.display = manualModeOn ? 'none' : '';
if (addSlotBtn) addSlotBtn.style.display = manualModeOn ? 'none' : ''; if (addSlotBtn) addSlotBtn.style.display = manualModeOn ? 'none' : '';
if (removeSlotBtn) removeSlotBtn.style.display = manualModeOn ? 'none' : '';
} }
if (activeChip) { if (activeChip) {
activeChip.style.display = manualModeOn ? '' : 'none'; activeChip.style.display = manualModeOn ? '' : 'none';
@ -1519,6 +1863,20 @@ function distinctPaletteSlots(palette) {
updateUI(); onColorChange(); updateUI(); onColorChange();
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
}); });
removeSlotBtn?.addEventListener('click', () => {
const patSelect = document.getElementById('classic-pattern');
const name = patSelect?.value || '';
const isStacked = name.toLowerCase().includes('stacked');
if (!isStacked) return;
const baseSlots = patternSlotCount(name);
if (slotCount <= baseSlots) return;
slotCount = setStoredSlotCount(slotCount - 1);
if (parseInt(activeTarget, 10) > slotCount) activeTarget = String(slotCount);
classicColors = classicColors.slice(0, slotCount);
setClassicColors(classicColors);
updateUI(); onColorChange();
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
});
replaceFromChip?.addEventListener('click', () => openReplacePicker('from')); replaceFromChip?.addEventListener('click', () => openReplacePicker('from'));
replaceToChip?.addEventListener('click', () => openReplacePicker('to')); replaceToChip?.addEventListener('click', () => openReplacePicker('to'));
replaceFromSel?.addEventListener('change', updateReplaceChips); replaceFromSel?.addEventListener('change', updateReplaceChips);
@ -1571,7 +1929,7 @@ function distinctPaletteSlots(palette) {
try { try {
if (typeof window.m === 'undefined') return fail('Mithril not loaded'); if (typeof window.m === 'undefined') return fail('Mithril not loaded');
projectPaletteBox = null; projectPaletteBox = null;
const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), borderEnabledCb = document.getElementById('classic-border-enabled'), manualModeBtn = document.getElementById('classic-manual-btn'), expandedToggleRow = document.getElementById('classic-expanded-row'), expandedToggle = document.getElementById('classic-expanded-toggle'), focusRow = document.getElementById('classic-focus-row'), focusPrev = document.getElementById('classic-focus-prev'), focusNext = document.getElementById('classic-focus-next'), focusLabel = document.getElementById('classic-focus-label'), floatingBar = document.getElementById('classic-mobile-bar'), floatingChip = document.getElementById('classic-active-chip-floating'), floatingUndo = document.getElementById('classic-undo-manual'), floatingRedo = document.getElementById('classic-redo-manual'), floatingPick = document.getElementById('classic-pick-manual'), floatingErase = document.getElementById('classic-erase-manual'), floatingClear = document.getElementById('classic-clear-manual'), floatingExport = document.getElementById('classic-export-manual'), quadReset = document.getElementById('classic-quad-reset'), focusZoomOut = document.getElementById('classic-focus-zoomout'), manualHub = document.getElementById('classic-manual-hub'), manualRange = document.getElementById('classic-manual-range'), manualRangeLabel = document.getElementById('classic-manual-range-label'), manualPrevBtn = document.getElementById('classic-manual-prev'), manualNextBtn = document.getElementById('classic-manual-next'), manualFullBtn = document.getElementById('classic-manual-full'), manualFocusBtn = document.getElementById('classic-manual-focus'), manualDetailDisplay = document.getElementById('classic-manual-detail-display'); const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), borderEnabledCb = document.getElementById('classic-border-enabled'), manualModeBtn = document.getElementById('classic-manual-btn'), expandedToggleRow = document.getElementById('classic-expanded-row'), expandedToggle = document.getElementById('classic-expanded-toggle'), focusRow = document.getElementById('classic-focus-row'), focusPrev = document.getElementById('classic-focus-prev'), focusNext = document.getElementById('classic-focus-next'), focusLabel = document.getElementById('classic-focus-label'), floatingBar = document.getElementById('classic-mobile-bar'), floatingChip = document.getElementById('classic-active-chip-floating'), floatingUndo = document.getElementById('classic-undo-manual'), floatingRedo = document.getElementById('classic-redo-manual'), floatingPick = document.getElementById('classic-pick-manual'), floatingErase = document.getElementById('classic-erase-manual'), floatingClear = document.getElementById('classic-clear-manual'), floatingExport = document.getElementById('classic-export-manual'), quadReset = document.getElementById('classic-quad-reset'), focusZoomOut = document.getElementById('classic-focus-zoomout'), manualHub = document.getElementById('classic-manual-hub'), manualRange = document.getElementById('classic-manual-range'), manualRangeLabel = document.getElementById('classic-manual-range-label'), manualPrevBtn = document.getElementById('classic-manual-prev'), manualNextBtn = document.getElementById('classic-manual-next'), manualFullBtn = document.getElementById('classic-manual-full'), manualFocusBtn = document.getElementById('classic-manual-focus'), manualDetailDisplay = document.getElementById('classic-manual-detail-display'), manualSizeRow = document.getElementById('classic-manual-size-row'), manualSizeLabel = document.getElementById('classic-manual-size-label'), manualSizeModePaint = document.getElementById('classic-size-mode-paint'), manualSizeModeInflate = document.getElementById('classic-size-mode-inflate'), manualSizeModeDeflate = document.getElementById('classic-size-mode-deflate'), manualSizeModeSlide = document.getElementById('classic-size-mode-slide'), manualSizeReset = document.getElementById('classic-size-reset');
const nudgeOpenBtn = document.getElementById('classic-nudge-open'); const nudgeOpenBtn = document.getElementById('classic-nudge-open');
const fullscreenBtn = document.getElementById('app-fullscreen-toggle'); const fullscreenBtn = document.getElementById('app-fullscreen-toggle');
const toolbar = document.getElementById('classic-canvas-toolbar'); const toolbar = document.getElementById('classic-canvas-toolbar');
@ -1610,12 +1968,22 @@ function distinctPaletteSlots(palette) {
const manualFocusSize = 8; const manualFocusSize = 8;
manualUndoStack = []; manualUndoStack = [];
manualRedoStack = []; manualRedoStack = [];
let manualTool = 'paint'; // paint | pick | erase let manualTool = 'paint'; // paint | inflate | deflate | slide
let manualPaintMode = 'paint'; // paint | pick | erase
let rowDrag = null;
let rowDragRaf = null;
const ROW_DRAG_STEP = 2;
const ROW_DRAG_THRESHOLD = 6;
const MANUAL_SIZE_STEPS = (window.shared?.SIZE_PRESETS || [24, 18, 11, 9, 5])
.filter(n => n <= 18 && n >= 5)
.sort((a, b) => a - b);
const MANUAL_SIZE_BASE = 11;
const MIN_MANUAL_SCALE = Math.min(...MANUAL_SIZE_STEPS) / MANUAL_SIZE_BASE;
const MAX_MANUAL_SCALE = Math.max(...MANUAL_SIZE_STEPS) / MANUAL_SIZE_BASE;
let manualFloatingQuad = null; let manualFloatingQuad = null;
let quadModalRow = null; let quadModalRow = null;
let quadModalStartRect = null; let quadModalStartRect = null;
let manualDetailRow = 0; let manualDetailRow = 0;
let manualDetailFrame = null;
classicZoom = 1; classicZoom = 1;
window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner = window.ClassicDesigner || {};
window.ClassicDesigner.randomizeManualFromPalette = () => { window.ClassicDesigner.randomizeManualFromPalette = () => {
@ -1644,6 +2012,117 @@ function distinctPaletteSlots(palette) {
scheduleManualDetail(); scheduleManualDetail();
return true; return true;
}; };
const getRowFromTarget = (target) => {
const g = target?.closest?.('g[id^="balloon_"]');
const id = g?.id || '';
const match = id.match(/balloon_(\d+)_(\d+)/);
if (!match) return null;
return parseInt(match[2], 10);
};
const getActiveManualRow = () => (manualFloatingQuad !== null ? manualFloatingQuad : manualDetailRow);
const clampManualScale = (val) => Math.max(MIN_MANUAL_SCALE, Math.min(MAX_MANUAL_SCALE, val));
const getManualScale = (row) => {
const key = manualKey(currentPatternName, currentRowCount);
const entry = manualRowScales[key] || {};
return Number.isFinite(entry[row]) ? entry[row] : 1;
};
const setManualScale = (row, scale) => {
const key = manualKey(currentPatternName, currentRowCount);
if (!manualRowScales[key]) manualRowScales[key] = {};
if (Math.abs(scale - 1) < 0.001) {
delete manualRowScales[key][row];
} else {
manualRowScales[key][row] = scale;
}
saveManualRowScales(manualRowScales);
};
const nearestSizeStep = (inches) => {
const steps = MANUAL_SIZE_STEPS.slice().sort((a, b) => a - b);
let best = steps[0];
let bestDist = Math.abs(inches - best);
steps.forEach(step => {
const dist = Math.abs(inches - step);
if (dist < bestDist) { bestDist = dist; best = step; }
});
return best;
};
const stepManualSize = (row, dir = 1) => {
const steps = MANUAL_SIZE_STEPS.slice().sort((a, b) => a - b);
const currentScale = getManualScale(row);
const currentInches = currentScale * MANUAL_SIZE_BASE;
const snapped = nearestSizeStep(currentInches);
const idx = steps.indexOf(snapped);
const nextIdx = Math.max(0, Math.min(steps.length - 1, idx + (dir > 0 ? 1 : -1)));
return steps[nextIdx];
};
const applyManualInflate = (row, dir = 1) => {
const nextInches = stepManualSize(row, dir);
const next = clampManualScale(nextInches / MANUAL_SIZE_BASE);
setManualScale(row, next);
updateClassicDesign();
};
const syncManualSizeUi = () => {
if (!manualSizeRow) return;
manualSizeRow.classList.toggle('hidden', !manualModeState);
if (!manualModeState) return;
const row = getActiveManualRow();
const value = clampManualScale(getManualScale(row));
if (manualSizeLabel) {
const sizeLabel = Math.round(value * MANUAL_SIZE_BASE);
manualSizeLabel.textContent = `Quad ${row + 1}${sizeLabel}\"`;
}
};
const scheduleRowDragRedraw = () => {
if (rowDragRaf) return;
rowDragRaf = requestAnimationFrame(() => {
rowDragRaf = null;
updateClassicDesign();
});
};
const handleRowDragMove = (evt) => {
if (!rowDrag || evt.pointerId !== rowDrag.pointerId) return;
if (rowDrag.pending) {
const dist = Math.hypot(evt.clientX - rowDrag.startX, evt.clientY - rowDrag.startY);
if (dist < ROW_DRAG_THRESHOLD) return;
rowDrag.pending = false;
display?.setPointerCapture?.(rowDrag.pointerId);
}
const delta = evt.clientY - rowDrag.startY;
const next = rowDrag.baseOffset + delta;
manualRowOffsets[rowDrag.key][rowDrag.row] = Math.round(next / ROW_DRAG_STEP) * ROW_DRAG_STEP;
saveManualRowOffsets(manualRowOffsets);
scheduleRowDragRedraw();
};
const handleRowDragEnd = (evt) => {
if (!rowDrag || evt.pointerId !== rowDrag.pointerId) return;
if (!rowDrag.pending) {
display?.releasePointerCapture?.(rowDrag.pointerId);
}
rowDrag = null;
window.removeEventListener('pointermove', handleRowDragMove);
scheduleRowDragRedraw();
};
const handleRowDragStart = (evt) => {
if (!manualModeState && patternLayout !== 'corinthian') return;
if (!(currentPatternName || '').toLowerCase().includes('column')) return;
if (manualModeState && manualTool !== 'slide') return;
const row = getRowFromTarget(evt.target);
if (row === null) return;
const key = manualKey(currentPatternName, currentRowCount);
if (!manualRowOffsets[key]) manualRowOffsets[key] = {};
const baseOffset = Number(manualRowOffsets[key][row]) || 0;
rowDrag = {
row,
key,
startX: evt.clientX,
startY: evt.clientY,
baseOffset,
pointerId: evt.pointerId,
pending: true
};
window.addEventListener('pointermove', handleRowDragMove);
window.addEventListener('pointerup', handleRowDragEnd, { once: true });
};
// Force UI to reflect initial manual state // Force UI to reflect initial manual state
if (manualModeState) patternLayout = 'manual'; if (manualModeState) patternLayout = 'manual';
const topperPresets = { const topperPresets = {
@ -1716,16 +2195,19 @@ function distinctPaletteSlots(palette) {
const computePatternName = () => { const computePatternName = () => {
const base = patternShape === 'column' ? 'Column' : 'Arch'; const base = patternShape === 'column' ? 'Column' : 'Arch';
const count = patternCount === 5 ? '5' : '4'; const count = patternCount === 5 ? '5' : '4';
const layout = patternLayout === 'stacked' ? ' Stacked' : ''; const isCorinthian = patternLayout === 'corinthian' && base === 'Column';
const layout = isCorinthian ? ' Corinthian' : (patternLayout === 'stacked' ? ' Stacked' : '');
return `${base} ${count}${layout}`; return `${base} ${count}${layout}`;
}; };
const syncPatternStateFromSelect = () => { const syncPatternStateFromSelect = () => {
const val = (patSel?.value || '').toLowerCase(); const val = (patSel?.value || '').toLowerCase();
patternShape = val.includes('column') ? 'column' : 'arch'; patternShape = val.includes('column') ? 'column' : 'arch';
patternCount = val.includes('5') ? 5 : 4; patternCount = val.includes('5') ? 5 : 4;
patternLayout = val.includes('stacked') ? 'stacked' : 'spiral'; if (val.includes('corinthian')) patternLayout = 'corinthian';
else patternLayout = val.includes('stacked') ? 'stacked' : 'spiral';
}; };
const applyPatternButtons = () => { const applyPatternButtons = () => {
if (patternLayout === 'corinthian' && patternShape !== 'column') patternLayout = 'spiral';
const displayLayout = manualModeState ? 'manual' : patternLayout; const displayLayout = manualModeState ? 'manual' : patternLayout;
const setActive = (btns, attr, val) => btns.forEach(b => { const setActive = (btns, attr, val) => btns.forEach(b => {
const active = b.dataset[attr] === val; const active = b.dataset[attr] === val;
@ -1736,7 +2218,16 @@ function distinctPaletteSlots(palette) {
setActive(patternShapeBtns, 'patternShape', patternShape); setActive(patternShapeBtns, 'patternShape', patternShape);
setActive(patternCountBtns, 'patternCount', String(patternCount)); setActive(patternCountBtns, 'patternCount', String(patternCount));
setActive(patternLayoutBtns, 'patternLayout', displayLayout); setActive(patternLayoutBtns, 'patternLayout', displayLayout);
patternLayoutBtns.forEach(b => b.disabled = false); patternLayoutBtns.forEach(b => {
if (b.dataset.patternLayout === 'corinthian') {
const show = patternShape === 'column';
b.classList.toggle('hidden', !show);
b.disabled = manualModeState || !show;
return;
}
b.classList.toggle('hidden', false);
b.disabled = false;
});
if (manualModeBtn) { if (manualModeBtn) {
const active = manualModeState; const active = manualModeState;
manualModeBtn.disabled = false; manualModeBtn.disabled = false;
@ -1754,13 +2245,21 @@ function distinctPaletteSlots(palette) {
} }
if (manualHub) manualHub.classList.toggle('hidden', !manualModeState); if (manualHub) manualHub.classList.toggle('hidden', !manualModeState);
if (floatingBar) floatingBar.classList.toggle('hidden', !manualModeState); if (floatingBar) floatingBar.classList.toggle('hidden', !manualModeState);
if (manualSizeRow) manualSizeRow.classList.toggle('hidden', !manualModeState);
// No expanded toggle in bar (handled by main control) // No expanded toggle in bar (handled by main control)
[floatingPick, floatingErase].forEach(btn => { [floatingPick, floatingErase].forEach(btn => {
if (!btn) return; if (!btn) return;
const active = manualModeState && manualTool === btn.dataset.tool; const active = manualModeState && manualTool === 'paint' && manualPaintMode === btn.dataset.tool;
btn.setAttribute('aria-pressed', active ? 'true' : 'false'); btn.setAttribute('aria-pressed', active ? 'true' : 'false');
btn.classList.toggle('active', active); btn.classList.toggle('active', active);
}); });
[manualSizeModePaint, manualSizeModeInflate, manualSizeModeDeflate, manualSizeModeSlide].forEach(btn => {
if (!btn) return;
const active = manualModeState && manualTool === btn.dataset.tool;
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
btn.classList.toggle('tab-active', active);
btn.classList.toggle('tab-idle', !active);
});
}; };
syncPatternStateFromSelect(); syncPatternStateFromSelect();
@ -1825,6 +2324,7 @@ function distinctPaletteSlots(palette) {
} }
if (manualFullBtn) manualFullBtn.disabled = !manualModeState; if (manualFullBtn) manualFullBtn.disabled = !manualModeState;
if (manualFocusBtn) manualFocusBtn.disabled = !manualModeState; if (manualFocusBtn) manualFocusBtn.disabled = !manualModeState;
syncManualSizeUi();
} }
const focusSectionForRow = (row) => { const focusSectionForRow = (row) => {
@ -2040,8 +2540,22 @@ function distinctPaletteSlots(palette) {
patSel.value = computePatternName(); patSel.value = computePatternName();
const patternName = patSel.value || 'Arch 4'; const patternName = patSel.value || 'Arch 4';
currentPatternName = patternName; currentPatternName = patternName;
const isCorinthian = patternName.toLowerCase().includes('corinthian');
if (isCorinthian) {
lengthInp.min = '5';
lengthInp.max = '9';
lengthInp.step = '1';
const next = Math.round(parseFloat(lengthInp.value) || 5);
const clamped = Math.max(5, Math.min(9, next));
if (Number.isFinite(clamped)) lengthInp.value = String(clamped);
} else {
lengthInp.min = '1';
lengthInp.max = '100';
lengthInp.step = '0.5';
}
const clusterCount = Math.max(1, Math.round((parseFloat(lengthInp.value) || 0) * 2)); const clusterCount = Math.max(1, Math.round((parseFloat(lengthInp.value) || 0) * 2));
currentRowCount = clusterCount; currentRowCount = clusterCount;
GC.setLengthFt(parseFloat(lengthInp.value) || 0);
const manualOn = manualModeState; const manualOn = manualModeState;
if (!manualOn) { if (!manualOn) {
manualFocusEnabled = false; manualFocusEnabled = false;
@ -2069,7 +2583,7 @@ function distinctPaletteSlots(palette) {
topperControls.classList.toggle('hidden', !showTopper); topperControls.classList.toggle('hidden', !showTopper);
// Number tint controls removed; always use base SVG appearance for numbers. // Number tint controls removed; always use base SVG appearance for numbers.
if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper); if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper);
const showReverse = patternLayout === 'spiral' && !manualOn; const showReverse = (patternLayout === 'spiral' || patternLayout === 'corinthian') && !manualOn;
if (reverseLabel) reverseLabel.classList.toggle('hidden', !showReverse); if (reverseLabel) reverseLabel.classList.toggle('hidden', !showReverse);
if (reverseHint) reverseHint.classList.toggle('hidden', !showReverse); if (reverseHint) reverseHint.classList.toggle('hidden', !showReverse);
if (reverseCb) { if (reverseCb) {
@ -2146,27 +2660,34 @@ function distinctPaletteSlots(palette) {
if (!match) { debug('manual paint click ignored (no match)', { id }); return; } if (!match) { debug('manual paint click ignored (no match)', { id }); return; }
const x = parseInt(match[1], 10); const x = parseInt(match[1], 10);
const y = parseInt(match[2], 10); const y = parseInt(match[2], 10);
if (manualFloatingQuad !== y) { if (manualTool === 'inflate' || manualTool === 'deflate') {
setManualTargetRow(y); applyManualInflate(y, manualTool === 'inflate' ? 1 : -1);
return; return;
} }
debug('manual paint click', { x, y, manualTool, mode: manualModeState, currentPatternName, currentRowCount }); if (manualFloatingQuad !== y) {
setManualTargetRow(y);
if (manualTool === 'slide') return;
return;
}
if (manualTool === 'slide') return;
debug('manual paint click', { x, y, manualTool, manualPaintMode, mode: manualModeState, currentPatternName, currentRowCount });
const prev = getManualOverride(currentPatternName, currentRowCount, x, y); const prev = getManualOverride(currentPatternName, currentRowCount, x, y);
const palette = buildClassicPalette(); const palette = buildClassicPalette();
const colorCode = parseInt(g?.getAttribute('data-color-code') || '0', 10); const colorCode = parseInt(g?.getAttribute('data-color-code') || '0', 10);
const currentFill = prev || palette[colorCode] || { hex: '#ffffff', image: null }; const currentFill = prev || palette[colorCode] || { hex: '#ffffff', image: null };
if (manualTool === 'pick') { if (manualPaintMode === 'pick') {
const picked = { hex: normHex(currentFill.hex || currentFill.colour || '#ffffff'), image: currentFill.image || null }; const picked = { hex: normHex(currentFill.hex || currentFill.colour || '#ffffff'), image: currentFill.image || null };
manualActiveColorGlobal = window.shared?.setActiveColor?.(picked) || picked; manualActiveColorGlobal = window.shared?.setActiveColor?.(picked) || picked;
manualRedoStack.length = 0; manualRedoStack.length = 0;
manualTool = 'paint'; manualPaintMode = 'paint';
updateClassicDesign(); updateClassicDesign();
return; return;
} }
manualRedoStack.length = 0; manualRedoStack.length = 0;
if (manualTool === 'erase') { if (manualPaintMode === 'erase') {
const ERASE_COLOR = { hex: 'transparent', colour: 'transparent', image: null }; const ERASE_COLOR = { hex: 'transparent', colour: 'transparent', image: null };
manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: ERASE_COLOR }); manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: ERASE_COLOR });
setManualOverride(currentPatternName, currentRowCount, x, y, ERASE_COLOR); setManualOverride(currentPatternName, currentRowCount, x, y, ERASE_COLOR);
@ -2223,7 +2744,8 @@ function distinctPaletteSlots(palette) {
const setLengthForPattern = () => { const setLengthForPattern = () => {
if (!lengthInp || !patSel) return; if (!lengthInp || !patSel) return;
const isArch = (computePatternName()).toLowerCase().includes('arch'); const patternName = computePatternName().toLowerCase();
const isArch = patternName.includes('arch');
lengthInp.value = isArch ? 20 : 5; lengthInp.value = isArch ? 20 : 5;
}; };
@ -2235,6 +2757,7 @@ function distinctPaletteSlots(palette) {
document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50)); document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50));
display?.addEventListener('click', handleManualPaint); display?.addEventListener('click', handleManualPaint);
display?.addEventListener('pointerdown', handleRowDragStart);
patSel?.addEventListener('change', () => { patSel?.addEventListener('change', () => {
lastPresetKey = null; lastPresetKey = null;
syncPatternStateFromSelect(); syncPatternStateFromSelect();
@ -2245,10 +2768,16 @@ function distinctPaletteSlots(palette) {
setLengthForPattern(); setLengthForPattern();
updateClassicDesign(); updateClassicDesign();
}); });
patternShapeBtns.forEach(btn => btn.addEventListener('click', () => { patternShape = btn.dataset.patternShape; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); })); patternShapeBtns.forEach(btn => btn.addEventListener('click', () => {
patternShape = btn.dataset.patternShape;
if (patternShape !== 'column' && patternLayout === 'corinthian') patternLayout = 'spiral';
lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign();
}));
patternCountBtns.forEach(btn => btn.addEventListener('click', () => { patternCount = Number(btn.dataset.patternCount) === 5 ? 5 : 4; 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', () => { patternLayoutBtns.forEach(btn => btn.addEventListener('click', () => {
patternLayout = btn.dataset.patternLayout === 'stacked' ? 'stacked' : 'spiral'; const nextLayout = btn.dataset.patternLayout || 'spiral';
patternLayout = (nextLayout === 'stacked' || nextLayout === 'corinthian') ? nextLayout : 'spiral';
if (patternLayout === 'corinthian' && patternShape !== 'column') patternShape = 'column';
lastNonManualLayout = patternLayout; lastNonManualLayout = patternLayout;
manualModeState = false; manualModeState = false;
saveManualMode(false); saveManualMode(false);
@ -2266,6 +2795,8 @@ function distinctPaletteSlots(palette) {
const togglingOn = !manualModeState; const togglingOn = !manualModeState;
if (togglingOn) lastNonManualLayout = patternLayout === 'manual' ? 'spiral' : patternLayout; if (togglingOn) lastNonManualLayout = patternLayout === 'manual' ? 'spiral' : patternLayout;
manualModeState = togglingOn; manualModeState = togglingOn;
manualTool = 'paint';
manualPaintMode = 'paint';
patternLayout = togglingOn ? 'manual' : lastNonManualLayout; patternLayout = togglingOn ? 'manual' : lastNonManualLayout;
manualFocusStart = 0; manualFocusStart = 0;
manualFocusEnabled = false; // keep full-view; quad pull-out handles focus manualFocusEnabled = false; // keep full-view; quad pull-out handles focus
@ -2301,6 +2832,13 @@ function distinctPaletteSlots(palette) {
manualRange?.addEventListener('input', () => setManualTargetRow(Number(manualRange.value) - 1)); manualRange?.addEventListener('input', () => setManualTargetRow(Number(manualRange.value) - 1));
manualPrevBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow - 1)); manualPrevBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow - 1));
manualNextBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow + 1)); manualNextBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow + 1));
manualSizeReset?.addEventListener('click', () => {
if (!manualModeState) return;
const row = getActiveManualRow();
setManualScale(row, 1);
syncManualSizeUi();
updateClassicDesign();
});
manualFullBtn?.addEventListener('click', () => { manualFullBtn?.addEventListener('click', () => {
manualFocusEnabled = true; // keep focus so detail/rail stay active manualFocusEnabled = true; // keep focus so detail/rail stay active
manualFloatingQuad = null; manualFloatingQuad = null;
@ -2340,10 +2878,49 @@ function distinctPaletteSlots(palette) {
saveManualExpanded(manualExpandedState); saveManualExpanded(manualExpandedState);
updateClassicDesign(); updateClassicDesign();
}); });
floatingUndo?.addEventListener('click', undoLastManual);
floatingRedo?.addEventListener('click', redoLastManual); floatingRedo?.addEventListener('click', redoLastManual);
floatingPick?.addEventListener('click', () => { manualTool = 'pick'; applyPatternButtons(); }); floatingPick?.addEventListener('click', () => {
floatingErase?.addEventListener('click', () => { manualTool = 'erase'; applyPatternButtons(); }); if (!manualModeState) return;
manualTool = 'paint';
manualPaintMode = (manualPaintMode === 'pick') ? 'paint' : 'pick';
applyPatternButtons();
});
floatingErase?.addEventListener('click', () => {
if (!manualModeState) return;
manualTool = 'paint';
manualPaintMode = (manualPaintMode === 'erase') ? 'paint' : 'erase';
applyPatternButtons();
});
manualSizeModeInflate?.addEventListener('click', () => {
if (!manualModeState) return;
manualTool = (manualTool === 'inflate') ? 'paint' : 'inflate';
manualPaintMode = 'paint';
applyPatternButtons();
});
manualSizeModeDeflate?.addEventListener('click', () => {
if (!manualModeState) return;
manualTool = (manualTool === 'deflate') ? 'paint' : 'deflate';
manualPaintMode = 'paint';
applyPatternButtons();
});
manualSizeModePaint?.addEventListener('click', () => {
if (!manualModeState) return;
manualTool = 'paint';
manualPaintMode = 'paint';
applyPatternButtons();
});
manualSizeModeSlide?.addEventListener('click', () => {
if (!manualModeState) return;
manualTool = 'slide';
manualPaintMode = 'paint';
applyPatternButtons();
});
manualSizeReset?.addEventListener('click', () => {
if (!manualModeState) return;
const row = getActiveManualRow();
setManualScale(row, 1);
updateClassicDesign();
});
floatingClear?.addEventListener('click', () => { floatingClear?.addEventListener('click', () => {
const key = manualKey(currentPatternName, currentRowCount); const key = manualKey(currentPatternName, currentRowCount);
const prev = manualOverrides[key] ? { ...manualOverrides[key] } : null; const prev = manualOverrides[key] ? { ...manualOverrides[key] } : null;
@ -2433,7 +3010,10 @@ function distinctPaletteSlots(palette) {
const updateFullscreenLabel = () => { const updateFullscreenLabel = () => {
if (!fullscreenBtn) return; if (!fullscreenBtn) return;
const active = !!document.fullscreenElement; const active = !!document.fullscreenElement;
fullscreenBtn.textContent = active ? 'Exit Fullscreen' : 'Fullscreen'; fullscreenBtn.innerHTML = active
? '<i class="fa-solid fa-compress" aria-hidden="true"></i><span class="sr-only">Exit fullscreen</span>'
: '<i class="fa-solid fa-expand" aria-hidden="true"></i><span class="sr-only">Enter fullscreen</span>';
fullscreenBtn.setAttribute('aria-label', active ? 'Exit fullscreen' : 'Enter fullscreen');
}; };
fullscreenBtn?.addEventListener('click', async () => { fullscreenBtn?.addEventListener('click', async () => {
try { try {

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="166.7193"
height="180.81715"
viewBox="0 0 166.7193 180.81716"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="g1"
transform="translate(-44.072952,-33.660285)">
<path
style="fill:#000000;stroke-width:1.16411"
d="m 119.0579,214.47745 c -5.41466,0 -6.17803,-2.31476 -3.2796,-9.94455 l 2.44066,-6.42475 -8.69721,-2.25367 C 77.667488,187.60024 52.246065,158.86694 45.266294,123.22788 40.292594,97.831943 51.10133,70.663512 73.299766,52.764004 121.63388,13.790232 195.14377,37.472698 208.80616,96.419699 c 2.35926,10.179171 2.52903,14.397851 0.97252,24.167171 -5.10541,32.04372 -25.94795,59.64891 -53.9394,71.44079 -5.86202,2.46947 -11.72546,4.49947 -13.02988,4.51111 -5.8898,0.0525 -6.89009,2.0379 -4.13905,8.21513 2.92396,6.56552 1.44634,9.72355 -4.54959,9.72355 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

18
images/weight-mask.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

BIN
images/weight.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -34,14 +34,18 @@
<div class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div> <div class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div>
</div> </div>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:gap-4 w-full lg:w-auto">
<nav id="mode-tabs" class="flex gap-2"> <nav id="mode-tabs" class="flex flex-wrap items-center gap-2">
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button> <button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic</button> <button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic</button>
<button type="button" class="tab-btn tab-idle" data-target="#tab-wall" aria-pressed="false">Wall</button> <button type="button" class="tab-btn tab-idle" data-target="#tab-wall" aria-pressed="false">Wall</button>
<button type="button" class="tab-btn tab-idle" data-target="#tab-helium" aria-pressed="false">Helium</button>
</nav> </nav>
<div class="flex items-center gap-3"> <div class="flex items-center gap-2 lg:gap-3 ml-auto">
<button id="app-fullscreen-toggle" class="btn-dark text-xs px-3 py-2" aria-label="Toggle fullscreen">Fullscreen</button> <button id="app-fullscreen-toggle" class="btn-dark text-xs px-3 py-2" aria-label="Enter fullscreen">
<i class="fa-solid fa-expand" aria-hidden="true"></i>
<span class="sr-only">Enter fullscreen</span>
</button>
<button id="clear-canvas-btn-top" class="btn-danger text-xs px-3 py-2">Start Fresh</button> <button id="clear-canvas-btn-top" class="btn-danger text-xs px-3 py-2">Start Fresh</button>
</div> </div>
</div> </div>
@ -86,6 +90,11 @@
<svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg> <svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
<span class="hidden sm:inline">Picker</span> <span class="hidden sm:inline">Picker</span>
</button> </button>
</div>
<div class="mb-3 flex items-center gap-2">
<span class="text-xs font-semibold text-gray-700">Color</span>
<div id="quick-color-chip" class="current-color-chip cursor-pointer !w-8 !h-8 shrink-0" title="Choose active color"></div>
<button type="button" id="quick-color-btn" class="btn-blue text-xs px-3 py-2">Change Color</button>
</div> </div>
<p class="hint mt-1">Use Path to click-drag a line; balloons will be auto-placed along it.</p> <p class="hint mt-1">Use Path to click-drag a line; balloons will be auto-placed along it.</p>
<div id="garland-controls" class="mt-2 flex flex-col gap-3 text-sm text-gray-700"> <div id="garland-controls" class="mt-2 flex flex-col gap-3 text-sm text-gray-700">
@ -126,6 +135,16 @@
<input type="range" id="selected-size" min="5" max="32" step="0.5" value="11" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" disabled> <input type="range" id="selected-size" min="5" max="32" step="0.5" value="11" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" disabled>
<span id="selected-size-label" class="text-xs w-12 text-right">0\"</span> <span id="selected-size-label" class="text-xs w-12 text-right">0\"</span>
</div> </div>
<div class="mt-2 grid grid-cols-3 gap-2">
<button type="button" class="btn-dark text-sm py-2" id="rotate-selected-left" disabled>Rotate -15°</button>
<button type="button" class="btn-dark text-sm py-2" id="rotate-selected-reset" disabled>Reset</button>
<button type="button" class="btn-dark text-sm py-2" id="rotate-selected-right" disabled>Rotate +15°</button>
</div>
<div class="mt-2 grid grid-cols-3 gap-2">
<button type="button" class="btn-dark text-sm py-2" id="ribbon-length-down" disabled>Ribbon Shorter</button>
<button type="button" class="btn-dark text-sm py-2" id="ribbon-attach-weight" disabled>Attach to Weight</button>
<button type="button" class="btn-dark text-sm py-2" id="ribbon-length-up" disabled>Ribbon Longer</button>
</div>
<div class="mt-2 grid grid-cols-2 gap-2"> <div class="mt-2 grid grid-cols-2 gap-2">
<button type="button" class="btn-dark text-sm py-2" id="bring-forward" disabled>Bring Forward</button> <button type="button" class="btn-dark text-sm py-2" id="bring-forward" disabled>Bring Forward</button>
<button type="button" class="btn-dark text-sm py-2" id="send-backward" disabled>Send Backward</button> <button type="button" class="btn-dark text-sm py-2" id="send-backward" disabled>Send Backward</button>
@ -141,6 +160,15 @@
<div class="panel-heading mt-4">Size & Shine</div> <div class="panel-heading mt-4">Size & Shine</div>
<div class="panel-card"> <div class="panel-card">
<div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div> <div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div>
<div id="helium-placement-row" class="hidden mt-2 mb-2">
<div class="text-xs font-semibold text-gray-700 mb-2">Place</div>
<div class="flex gap-2">
<button type="button" id="helium-place-balloon" class="tab-btn tab-active text-xs px-3 py-2" aria-pressed="true">Balloon</button>
<button type="button" id="helium-place-curl" class="tab-btn tab-idle text-xs px-3 py-2" aria-pressed="false">Curl 260</button>
<button type="button" id="helium-place-ribbon" class="tab-btn tab-idle text-xs px-3 py-2" aria-pressed="false">Ribbon</button>
<button type="button" id="helium-place-weight" class="tab-btn tab-idle text-xs px-3 py-2" aria-pressed="false">Weight</button>
</div>
</div>
<p class="hint mb-3">Size presets adjust the diameter for new balloons.</p> <p class="hint mb-3">Size presets adjust the diameter for new balloons.</p>
<label class="text-sm inline-flex items-center gap-2 font-medium"> <label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked> <input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
@ -152,7 +180,6 @@
</label> </label>
<button type="button" id="fit-view-btn" class="btn-dark text-sm mt-3 w-full">Fit to Design</button> <button type="button" id="fit-view-btn" class="btn-dark text-sm mt-3 w-full">Fit to Design</button>
</div> </div>
</div>
<div class="control-stack" data-mobile-tab="colors"> <div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Organic Colors</div> <div class="panel-heading">Organic Colors</div>
@ -257,6 +284,7 @@
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<button type="button" class="tab-btn tab-active pattern-btn" data-pattern-layout="spiral" aria-pressed="true">Spiral</button> <button type="button" class="tab-btn tab-active pattern-btn" data-pattern-layout="spiral" aria-pressed="true">Spiral</button>
<button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-layout="stacked" aria-pressed="false">Stacked</button> <button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-layout="stacked" aria-pressed="false">Stacked</button>
<button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-layout="corinthian" aria-pressed="false">Corinthian</button>
<button type="button" class="tab-btn tab-idle" id="classic-manual-btn" aria-pressed="false">Manual paint</button> <button type="button" class="tab-btn tab-idle" id="classic-manual-btn" aria-pressed="false">Manual paint</button>
</div> </div>
<div id="classic-expanded-row" class="flex items-center gap-2 hidden"> <div id="classic-expanded-row" class="flex items-center gap-2 hidden">
@ -266,6 +294,20 @@
</label> </label>
<p class="hint m-0">Separate clusters for easier taps.</p> <p class="hint m-0">Separate clusters for easier taps.</p>
</div> </div>
<div id="classic-manual-size-row" class="hidden flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-700">Manual size mode</div>
<span id="classic-manual-size-label" class="text-xs text-gray-500">Quad 1</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button type="button" id="classic-size-mode-paint" class="tab-btn tab-idle" data-tool="paint">Paint</button>
<button type="button" id="classic-size-mode-inflate" class="tab-btn tab-idle" data-tool="inflate">Inflate</button>
<button type="button" id="classic-size-mode-deflate" class="tab-btn tab-idle" data-tool="deflate">Deflate</button>
<button type="button" id="classic-size-mode-slide" class="tab-btn tab-idle" data-tool="slide">Slide</button>
<button type="button" id="classic-size-reset" class="btn-yellow text-xs px-2 py-1">Reset</button>
</div>
<p class="hint m-0">Pick a mode; click a quad to target. Drag to slide in Slide.</p>
</div>
<div id="classic-focus-row" class="flex items-center gap-2 hidden"> <div id="classic-focus-row" class="flex items-center gap-2 hidden">
<button type="button" class="btn-dark text-xs px-3 py-2 hidden" id="classic-focus-prev" aria-hidden="true" tabindex="-1">◀ Prev</button> <button type="button" class="btn-dark text-xs px-3 py-2 hidden" id="classic-focus-prev" aria-hidden="true" tabindex="-1">◀ Prev</button>
<span id="classic-focus-label" class="text-sm text-gray-700">Clusters 18</span> <span id="classic-focus-label" class="text-sm text-gray-700">Clusters 18</span>
@ -277,8 +319,10 @@
<select id="classic-pattern" class="select align-middle hidden" aria-hidden="true" tabindex="-1"> <select id="classic-pattern" class="select align-middle hidden" aria-hidden="true" tabindex="-1">
<option value="Arch 4">Arch 4 (4-color spiral)</option> <option value="Arch 4">Arch 4 (4-color spiral)</option>
<option value="Column 4">Column 4 (quad wrap)</option> <option value="Column 4">Column 4 (quad wrap)</option>
<option value="Column 4 Corinthian">Column 4 (corinthian)</option>
<option value="Arch 5">Arch 5 (5-color spiral)</option> <option value="Arch 5">Arch 5 (5-color spiral)</option>
<option value="Column 5">Column 5 (5-balloon wrap)</option> <option value="Column 5">Column 5 (5-balloon wrap)</option>
<option value="Column 5 Corinthian">Column 5 (corinthian)</option>
<option value="Arch 4 Stacked">Arch 4 (stacked)</option> <option value="Arch 4 Stacked">Arch 4 (stacked)</option>
<option value="Column 4 Stacked">Column 4 (stacked)</option> <option value="Column 4 Stacked">Column 4 (stacked)</option>
<option value="Arch 5 Stacked">Arch 5 (stacked)</option> <option value="Arch 5 Stacked">Arch 5 (stacked)</option>
@ -352,6 +396,7 @@
<div class="panel-card"> <div class="panel-card">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div id="classic-slots" class="flex items-center gap-2"></div> <div id="classic-slots" class="flex items-center gap-2"></div>
<button id="classic-remove-slot" class="btn-dark text-sm px-3 py-2 hidden" type="button" title="Remove color slot">-</button>
<button id="classic-add-slot" class="btn-dark text-sm px-3 py-2 hidden" type="button" title="Add color slot">+</button> <button id="classic-add-slot" class="btn-dark text-sm px-3 py-2 hidden" type="button" title="Add color slot">+</button>
</div> </div>
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
@ -426,11 +471,11 @@
<i class="fa-solid fa-rotate-right" aria-hidden="true"></i> <i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
<span>Redo</span> <span>Redo</span>
</button> </button>
<button type="button" class="mobile-action-btn" id="classic-pick-manual" aria-label="Eyedropper" aria-pressed="false"> <button type="button" class="mobile-action-btn" id="classic-pick-manual" aria-label="Eyedropper" aria-pressed="false" data-tool="pick">
<i class="fa-solid fa-eye-dropper" aria-hidden="true"></i> <i class="fa-solid fa-eye-dropper" aria-hidden="true"></i>
<span>Pick</span> <span>Pick</span>
</button> </button>
<button type="button" class="mobile-action-btn" id="classic-erase-manual" aria-label="Toggle Erase" aria-pressed="false"> <button type="button" class="mobile-action-btn" id="classic-erase-manual" aria-label="Toggle Erase" aria-pressed="false" data-tool="erase">
<i class="fa-solid fa-eraser" aria-hidden="true"></i> <i class="fa-solid fa-eraser" aria-hidden="true"></i>
<span>Erase</span> <span>Erase</span>
</button> </button>
@ -595,6 +640,25 @@
</section> </section>
</section> </section>
<section id="tab-helium" class="hidden flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
<aside id="helium-controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
<div class="panel-header-row">
<h2 class="panel-title">Helium Controls</h2>
</div>
<div class="control-stack">
<div class="panel-heading">Coming Soon</div>
<div class="panel-card space-y-2">
<p class="text-sm text-gray-700">Helium layouts are in progress.</p>
<p class="hint">We can add bouquet builders, ceiling clusters, and tied columns here.</p>
</div>
</div>
</aside>
<section class="order-1 w-full lg:flex-1 flex items-center justify-center rounded-2xl bg-white/70 ring-1 ring-black/5">
<div class="text-sm text-gray-500">Helium canvas coming soon.</div>
</section>
</section>
</div> </div>
<div id="mobile-tabbar" class="mobile-tabbar"> <div id="mobile-tabbar" class="mobile-tabbar">

1987
organic.js

File diff suppressed because it is too large Load Diff

View File

@ -28,9 +28,11 @@
const orgSheet = document.getElementById('controls-panel'); const orgSheet = document.getElementById('controls-panel');
const claSheet = document.getElementById('classic-controls-panel'); const claSheet = document.getElementById('classic-controls-panel');
const wallSheet = document.getElementById('wall-controls-panel'); const wallSheet = document.getElementById('wall-controls-panel');
const heliumSheet = document.getElementById('helium-controls-panel');
const orgSection = document.getElementById('tab-organic'); const orgSection = document.getElementById('tab-organic');
const claSection = document.getElementById('tab-classic'); const claSection = document.getElementById('tab-classic');
const wallSection = document.getElementById('tab-wall'); const wallSection = document.getElementById('tab-wall');
const heliumSection = document.getElementById('tab-helium');
const tabBtns = Array.from(document.querySelectorAll('#mode-tabs .tab-btn')); const tabBtns = Array.from(document.querySelectorAll('#mode-tabs .tab-btn'));
const mobileActionBar = document.getElementById('mobile-action-bar'); const mobileActionBar = document.getElementById('mobile-action-bar');
@ -383,12 +385,14 @@
const classicVisible = !document.getElementById('tab-classic')?.classList.contains('hidden'); const classicVisible = !document.getElementById('tab-classic')?.classList.contains('hidden');
const organicVisible = !document.getElementById('tab-organic')?.classList.contains('hidden'); const organicVisible = !document.getElementById('tab-organic')?.classList.contains('hidden');
const wallVisible = !document.getElementById('tab-wall')?.classList.contains('hidden'); const wallVisible = !document.getElementById('tab-wall')?.classList.contains('hidden');
const heliumVisible = !document.getElementById('tab-helium')?.classList.contains('hidden');
let id = bodyActive || activeBtn?.dataset?.target; let id = bodyActive || activeBtn?.dataset?.target;
if (!id) { if (!id) {
if (classicVisible && !organicVisible && !wallVisible) id = '#tab-classic'; if (classicVisible && !organicVisible && !wallVisible && !heliumVisible) id = '#tab-classic';
else if (organicVisible && !classicVisible && !wallVisible) id = '#tab-organic'; else if (organicVisible && !classicVisible && !wallVisible && !heliumVisible) id = '#tab-organic';
else if (wallVisible && !classicVisible && !organicVisible) id = '#tab-wall'; else if (wallVisible && !classicVisible && !organicVisible && !heliumVisible) id = '#tab-wall';
else if (heliumVisible && !classicVisible && !organicVisible && !wallVisible) id = '#tab-helium';
} }
if (!id) id = '#tab-organic'; if (!id) id = '#tab-organic';
if (document.body) document.body.dataset.activeTab = id; if (document.body) document.body.dataset.activeTab = id;
@ -399,17 +403,30 @@
function updateSheets() { function updateSheets() {
const tab = detectCurrentTab(); const tab = detectCurrentTab();
const hide = !window.matchMedia('(min-width: 1024px)').matches && document.body?.dataset?.controlsHidden === '1'; const hide = !window.matchMedia('(min-width: 1024px)').matches && document.body?.dataset?.controlsHidden === '1';
if (orgSheet) orgSheet.classList.toggle('hidden', hide || tab !== '#tab-organic'); const usesOrganicWorkspace = tab === '#tab-organic' || tab === '#tab-helium';
if (orgSheet) orgSheet.classList.toggle('hidden', hide || !usesOrganicWorkspace);
if (claSheet) claSheet.classList.toggle('hidden', hide || tab !== '#tab-classic'); if (claSheet) claSheet.classList.toggle('hidden', hide || tab !== '#tab-classic');
if (wallSheet) wallSheet.classList.toggle('hidden', hide || tab !== '#tab-wall'); if (wallSheet) wallSheet.classList.toggle('hidden', hide || tab !== '#tab-wall');
if (heliumSheet) heliumSheet.classList.add('hidden');
} }
function updateMobileStacks(tabName) { function getCurrentMobilePanel(currentTab) {
const orgPanel = document.getElementById('controls-panel'); const orgPanel = document.getElementById('controls-panel');
const claPanel = document.getElementById('classic-controls-panel'); const claPanel = document.getElementById('classic-controls-panel');
const wallPanel = document.getElementById('wall-controls-panel'); const wallPanel = document.getElementById('wall-controls-panel');
const heliumPanel = document.getElementById('helium-controls-panel');
if (currentTab === '#tab-classic') return claPanel;
if (currentTab === '#tab-wall') return wallPanel;
if (currentTab === '#tab-helium') {
const heliumHasStacks = !!heliumPanel?.querySelector?.('.control-stack[data-mobile-tab]');
return heliumHasStacks ? heliumPanel : orgPanel;
}
return orgPanel;
}
function updateMobileStacks(tabName) {
const currentTab = detectCurrentTab(); const currentTab = detectCurrentTab();
const panel = currentTab === '#tab-classic' ? claPanel : (currentTab === '#tab-wall' ? wallPanel : orgPanel); const panel = getCurrentMobilePanel(currentTab);
const target = tabName || document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT; const target = tabName || document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT;
const isHidden = document.body?.dataset?.controlsHidden === '1'; const isHidden = document.body?.dataset?.controlsHidden === '1';
const isDesktop = window.matchMedia('(min-width: 1024px)').matches; const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
@ -440,11 +457,7 @@
delete document.body.dataset.controlsHidden; delete document.body.dataset.controlsHidden;
} }
// Ensure the current panel is not minimized/hidden when we select a tab. // Ensure the current panel is not minimized/hidden when we select a tab.
const panel = activeMainTab === '#tab-classic' const panel = getCurrentMobilePanel(activeMainTab);
? document.getElementById('classic-controls-panel')
: (activeMainTab === '#tab-wall'
? document.getElementById('wall-controls-panel')
: document.getElementById('controls-panel'));
panel?.classList.remove('minimized'); panel?.classList.remove('minimized');
if (panel) panel.style.display = ''; if (panel) panel.style.display = '';
updateSheets(); updateSheets();
@ -524,15 +537,20 @@
// Tab switching // Tab switching
if (orgSection && claSection && tabBtns.length > 0) { if (orgSection && claSection && tabBtns.length > 0) {
let current = '#tab-organic'; let current = '#tab-organic';
const usesOrganicWorkspace = () => current === '#tab-organic' || current === '#tab-helium';
const syncOrganicWorkspaceLabels = () => {
const title = document.querySelector('#controls-panel .panel-title');
if (title) title.textContent = current === '#tab-helium' ? 'Helium Controls' : 'Organic Controls';
};
const isMobileView = () => window.matchMedia('(max-width: 1023px)').matches; const isMobileView = () => window.matchMedia('(max-width: 1023px)').matches;
const updateMobileActionBarVisibility = () => { const updateMobileActionBarVisibility = () => {
const modalOpen = !!document.querySelector('.color-modal:not(.hidden)'); const modalOpen = !!document.querySelector('.color-modal:not(.hidden)');
const isMobile = isMobileView(); const isMobile = isMobileView();
const showOrganic = isMobile && !modalOpen && current === '#tab-organic'; const showOrganic = isMobile && !modalOpen && usesOrganicWorkspace();
if (mobileActionBar) mobileActionBar.classList.toggle('hidden', !showOrganic); if (mobileActionBar) mobileActionBar.classList.toggle('hidden', !showOrganic);
}; };
const wireMobileActionButtons = () => { const wireMobileActionButtons = () => {
const guardOrganic = () => current === '#tab-organic'; const guardOrganic = () => usesOrganicWorkspace();
const clickBtn = (sel) => { if (!guardOrganic()) return; document.querySelector(sel)?.click(); }; const clickBtn = (sel) => { if (!guardOrganic()) return; document.querySelector(sel)?.click(); };
const on = (id, fn) => document.getElementById(id)?.addEventListener('click', fn); const on = (id, fn) => document.getElementById(id)?.addEventListener('click', fn);
on('mobile-act-undo', () => clickBtn('#tool-undo')); on('mobile-act-undo', () => clickBtn('#tool-undo'));
@ -558,9 +576,11 @@
orgSheet?.classList.remove('minimized'); orgSheet?.classList.remove('minimized');
claSheet?.classList.remove('minimized'); claSheet?.classList.remove('minimized');
wallSheet?.classList.remove('minimized'); wallSheet?.classList.remove('minimized');
orgSection.classList.toggle('hidden', id !== '#tab-organic'); const useOrganicWorkspace = id === '#tab-organic' || id === '#tab-helium';
orgSection.classList.toggle('hidden', !useOrganicWorkspace);
claSection.classList.toggle('hidden', id !== '#tab-classic'); claSection.classList.toggle('hidden', id !== '#tab-classic');
wallSection?.classList.toggle('hidden', id !== '#tab-wall'); wallSection?.classList.toggle('hidden', id !== '#tab-wall');
heliumSection?.classList.add('hidden');
updateSheets(); updateSheets();
updateFloatingNudge(); updateFloatingNudge();
tabBtns.forEach(btn => { tabBtns.forEach(btn => {
@ -573,7 +593,7 @@
try { localStorage.setItem(ACTIVE_TAB_KEY, id); } catch {} try { localStorage.setItem(ACTIVE_TAB_KEY, id); } catch {}
} }
if (document.body) delete document.body.dataset.controlsHidden; if (document.body) delete document.body.dataset.controlsHidden;
const isOrganic = id === '#tab-organic'; const isOrganic = useOrganicWorkspace;
const showHeaderColor = id !== '#tab-classic'; const showHeaderColor = id !== '#tab-classic';
const clearTop = document.getElementById('clear-canvas-btn-top'); const clearTop = document.getElementById('clear-canvas-btn-top');
if (clearTop) { if (clearTop) {
@ -588,9 +608,11 @@
})(); })();
if (document.body) document.body.dataset.mobileTab = savedMobile; if (document.body) document.body.dataset.mobileTab = savedMobile;
setMobileTab(savedMobile, id, true); setMobileTab(savedMobile, id, true);
orgSheet?.classList.toggle('hidden', id !== '#tab-organic'); orgSheet?.classList.toggle('hidden', !useOrganicWorkspace);
claSheet?.classList.toggle('hidden', id !== '#tab-classic'); claSheet?.classList.toggle('hidden', id !== '#tab-classic');
wallSheet?.classList.toggle('hidden', id !== '#tab-wall'); wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
heliumSheet?.classList.add('hidden');
syncOrganicWorkspaceLabels();
window.updateExportButtonVisibility(); window.updateExportButtonVisibility();
updateMobileActionBarVisibility(); updateMobileActionBarVisibility();
} }
@ -625,12 +647,9 @@
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const tab = btn.dataset.mobileTab || 'controls'; const tab = btn.dataset.mobileTab || 'controls';
const activeTabId = detectCurrentTab(); const activeTabId = detectCurrentTab();
const panel = activeTabId === '#tab-classic' const panel = getCurrentMobilePanel(activeTabId);
? document.getElementById('classic-controls-panel')
: (activeTabId === '#tab-wall'
? document.getElementById('wall-controls-panel')
: document.getElementById('controls-panel'));
const currentTab = document.body.dataset.mobileTab; const currentTab = document.body.dataset.mobileTab;
if (!panel) return;
if (tab === currentTab) { if (tab === currentTab) {
panel.classList.toggle('minimized'); panel.classList.toggle('minimized');
} else { } else {

View File

@ -1,7 +1,8 @@
/* Minimal extras (Tailwind handles most styling) */ /* Minimal extras (Tailwind handles most styling) */
body { color: #1f2937; } body { color: #1f2937; }
body[data-active-tab="#tab-classic"] #clear-canvas-btn-top, body[data-active-tab="#tab-classic"] #clear-canvas-btn-top,
body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { body[data-active-tab="#tab-wall"] #clear-canvas-btn-top,
body[data-active-tab="#tab-helium"] #clear-canvas-btn-top {
display: none !important; display: none !important;
} }
@ -223,6 +224,9 @@ height: 95%}
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
#classic-slots {
flex-wrap: wrap;
}
.slot-swatch { .slot-swatch {
width: 2.5rem; width: 2.5rem;
@ -396,7 +400,7 @@ height: 95%}
z-index: 30; z-index: 30;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1); transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
height: 92%; height: auto;
} }
.control-sheet.hidden { display: none; } .control-sheet.hidden { display: none; }
.control-sheet.minimized { transform: translateY(100%); } .control-sheet.minimized { transform: translateY(100%); }
@ -430,7 +434,7 @@ height: 92%;
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
body { padding-bottom: 0; overflow: auto; } body { padding-bottom: calc(6rem + env(safe-area-inset-bottom, 0px)); overflow: auto; }
html, body { height: auto; overflow: auto; } html, body { height: auto; overflow: auto; }
#current-color-chip-global { display: none; } #current-color-chip-global { display: none; }
#clear-canvas-btn-top { display: none !important; } #clear-canvas-btn-top { display: none !important; }
@ -451,10 +455,16 @@ height: 92%;
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"], body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"],
body[data-mobile-tab="controls"] #wall-controls-panel [data-mobile-tab="controls"], body[data-mobile-tab="controls"] #wall-controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #wall-controls-panel [data-mobile-tab="colors"], body[data-mobile-tab="colors"] #wall-controls-panel [data-mobile-tab="colors"],
body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"] { body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"],
body[data-mobile-tab="controls"] #helium-controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #helium-controls-panel [data-mobile-tab="colors"],
body[data-mobile-tab="save"] #helium-controls-panel [data-mobile-tab="save"] {
display: block; display: block;
} }
.control-sheet { bottom: 4.5rem; max-height: 55vh; } .control-sheet {
bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
max-height: min(72vh, calc(100dvh - 8.5rem - env(safe-area-inset-bottom, 0px)));
}
.control-sheet.minimized { transform: translateY(115%); } .control-sheet.minimized { transform: translateY(115%); }
/* Larger tap targets and spacing */ /* Larger tap targets and spacing */
@ -481,8 +491,8 @@ height: 92%;
position: fixed; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
bottom: 4.75rem; bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
padding: 0.35rem 0.75rem 0.7rem; padding: 0.35rem 0.75rem calc(0.7rem + env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.96) 100%); background: linear-gradient(180deg, rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.96) 100%);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
@ -676,7 +686,7 @@ height: 92%;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
padding: .6rem .9rem .9rem; padding: .6rem .9rem calc(.9rem + env(safe-area-inset-bottom, 0px));
background: linear-gradient(135deg, rgba(255,255,255,0.95), rgba(224,242,254,0.92)); background: linear-gradient(135deg, rgba(255,255,255,0.95), rgba(224,242,254,0.92));
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
@ -697,11 +707,8 @@ height: 92%;
#wall-display, #wall-display,
#balloon-canvas { #balloon-canvas {
margin-bottom: 0; margin-bottom: 0;
height: calc(100vh - 190px) !important; /* tie to viewport minus header/controls */ height: calc(100dvh - 190px) !important; /* tie to viewport minus header/controls */
max-height: calc(100vh - 190px) !important; max-height: calc(100dvh - 190px) !important;
}
#classic-display{
height: 92%;
} }
/* Keep the main canvas panels above the tabbar/action bar */ /* Keep the main canvas panels above the tabbar/action bar */
#canvas-panel, #canvas-panel,