diff --git a/classic.js b/classic.js index e9c9288..a1e1acc 100644 --- a/classic.js +++ b/classic.js @@ -86,6 +86,8 @@ const MANUAL_MODE_KEY = 'classic:manualMode:v1'; const MANUAL_OVERRIDES_KEY = 'classic:manualOverrides: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 = { '0': 'output_webp/0.webp', '1': 'output_webp/1.webp', @@ -183,13 +185,35 @@ } catch {} 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) { 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) { return `${patternName || ''}::${rowCount || 0}`; } const manualOverrides = loadManualOverrides(); + const manualRowOffsets = loadManualRowOffsets(); + const manualRowScales = loadManualRowScales(); function manualOverrideCount(patternName, rowCount) { const key = manualKey(patternName, rowCount); const entry = manualOverrides[key]; @@ -239,6 +263,18 @@ }); 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) let projectPaletteBox = null; let renderProjectPalette = () => {}; @@ -294,6 +330,7 @@ let pxUnit = 10; let clusters = 10; + let lengthFt = null; let reverse = false; let topperEnabled = false; let topperType = 'round'; @@ -317,6 +354,10 @@ initialPattern: 'Arch 4', controller: (el) => makeController(el), 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; }, setTopperEnabled(on) { topperEnabled = !!on; }, setTopperType(type) { topperType = type || 'round'; }, @@ -451,6 +492,35 @@ 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); + 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 yPx = p.y * rel * pxUnit; if (model.manualMode && (model.explodedGapPx || 0) > 0) { @@ -474,6 +544,24 @@ 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 }; } @@ -490,6 +578,192 @@ function distinctPaletteSlots(palette) { 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){ @@ -501,9 +775,11 @@ function distinctPaletteSlots(palette) { const rowColorPatterns = {}; const wireframeMode = false; // per-balloon wireframe handled in cellView for unpainted balloons const stackedSlots = (() => { - const slots = distinctPaletteSlots(model.palette); - const limit = Math.max(1, Math.min(slots.length, balloonsPerCluster)); - return slots.slice(0, limit); + const slots = Object.keys(model.palette || {}) + .map(Number) + .filter(n => Number.isFinite(n) && n > 0) + .sort((a, b) => a - b); + return slots.length ? slots : [1]; })(); const colorBlock4 = [ @@ -555,9 +831,9 @@ function distinctPaletteSlots(palette) { const rowIndex = cell.y; if (!rowColorPatterns[rowIndex]) { const totalRows = model.rowCount * (pattern.cellsPerRow || 1); - const isRightHalf = false; // mirror mode removed - const baseRow = rowIndex; - const qEff = baseRow + 1; + const isCorinthianPattern = (model.patternName || '').toLowerCase().includes('corinthian'); + const rowForPattern = rowIndex; + const qEff = rowForPattern + 1; let pat; if (pattern.colorMode === 'stacked') { @@ -577,16 +853,17 @@ function distinctPaletteSlots(palette) { // Swap left/right emphasis every 5 clusters to break repetition (per template override) if (balloonsPerCluster === 5) { const SWAP_EVERY = 5; - const blockIndex = Math.floor(rowIndex / SWAP_EVERY); + const blockIndex = Math.floor(rowForPattern / SWAP_EVERY); if (blockIndex % 2 === 1) { [pat[0], pat[4]] = [pat[4], pat[0]]; } } - if (pat.length > 1) { - let shouldReverse; - shouldReverse = reversed; - if (shouldReverse) pat.reverse(); + if (pat.length > 1 && reversed) { + if (isCorinthianPattern) { + pat.push(pat.shift()); + } + pat.reverse(); } rowColorPatterns[rowIndex] = pat; @@ -869,11 +1146,16 @@ function distinctPaletteSlots(palette) { pattern, cells: [], rowCount: clusters, + corinthianRowOffsets: pattern.sizeProfile === 'corinthian' + ? scaledRowOffsetsFor(pattern, clusters, name, lengthFt) + : null, + manualRowScales: getManualRowScales(name, clusters), palette: buildClassicPalette(), topperColor: getTopperColor(), topperType, shineEnabled, manualMode, + manualRowOffsets: getManualRowOffsets(name, clusters), manualFocusEnabled, manualFloatingQuad, explodedScale, @@ -887,6 +1169,13 @@ function distinctPaletteSlots(palette) { let balloonIndexInCluster = 0; for (let x=0; x { 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); }); @@ -1396,9 +1733,15 @@ function distinctPaletteSlots(palette) { const lengthInp = document.getElementById('classic-length-ft'); const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); const maxSlots = Math.min(MAX_SLOTS, clusters); + const baseSlots = patternSlotCount(patSelect?.value || ''); addSlotBtn.classList.toggle('hidden', !isStacked); 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 sharedActive = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; @@ -1438,6 +1781,7 @@ function distinctPaletteSlots(palette) { const row = slotsContainer.parentElement; if (row) row.style.display = manualModeOn ? 'none' : ''; if (addSlotBtn) addSlotBtn.style.display = manualModeOn ? 'none' : ''; + if (removeSlotBtn) removeSlotBtn.style.display = manualModeOn ? 'none' : ''; } if (activeChip) { activeChip.style.display = manualModeOn ? '' : 'none'; @@ -1519,6 +1863,20 @@ function distinctPaletteSlots(palette) { updateUI(); onColorChange(); 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')); replaceToChip?.addEventListener('click', () => openReplacePicker('to')); replaceFromSel?.addEventListener('change', updateReplaceChips); @@ -1571,7 +1929,7 @@ function distinctPaletteSlots(palette) { try { if (typeof window.m === 'undefined') return fail('Mithril not loaded'); 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 fullscreenBtn = document.getElementById('app-fullscreen-toggle'); const toolbar = document.getElementById('classic-canvas-toolbar'); @@ -1610,12 +1968,22 @@ function distinctPaletteSlots(palette) { const manualFocusSize = 8; manualUndoStack = []; 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 quadModalRow = null; let quadModalStartRect = null; let manualDetailRow = 0; - let manualDetailFrame = null; classicZoom = 1; window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner.randomizeManualFromPalette = () => { @@ -1644,6 +2012,117 @@ function distinctPaletteSlots(palette) { scheduleManualDetail(); 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 if (manualModeState) patternLayout = 'manual'; const topperPresets = { @@ -1716,16 +2195,19 @@ function distinctPaletteSlots(palette) { const computePatternName = () => { const base = patternShape === 'column' ? 'Column' : 'Arch'; 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}`; }; const syncPatternStateFromSelect = () => { const val = (patSel?.value || '').toLowerCase(); patternShape = val.includes('column') ? 'column' : 'arch'; patternCount = val.includes('5') ? 5 : 4; - patternLayout = val.includes('stacked') ? 'stacked' : 'spiral'; + if (val.includes('corinthian')) patternLayout = 'corinthian'; + else patternLayout = val.includes('stacked') ? 'stacked' : 'spiral'; }; const applyPatternButtons = () => { + if (patternLayout === 'corinthian' && patternShape !== 'column') patternLayout = 'spiral'; const displayLayout = manualModeState ? 'manual' : patternLayout; const setActive = (btns, attr, val) => btns.forEach(b => { const active = b.dataset[attr] === val; @@ -1736,7 +2218,16 @@ function distinctPaletteSlots(palette) { setActive(patternShapeBtns, 'patternShape', patternShape); setActive(patternCountBtns, 'patternCount', String(patternCount)); 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) { const active = manualModeState; manualModeBtn.disabled = false; @@ -1754,13 +2245,21 @@ function distinctPaletteSlots(palette) { } if (manualHub) manualHub.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) [floatingPick, floatingErase].forEach(btn => { 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.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(); @@ -1825,6 +2324,7 @@ function distinctPaletteSlots(palette) { } if (manualFullBtn) manualFullBtn.disabled = !manualModeState; if (manualFocusBtn) manualFocusBtn.disabled = !manualModeState; + syncManualSizeUi(); } const focusSectionForRow = (row) => { @@ -2040,8 +2540,22 @@ function distinctPaletteSlots(palette) { patSel.value = computePatternName(); const patternName = patSel.value || 'Arch 4'; 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)); currentRowCount = clusterCount; + GC.setLengthFt(parseFloat(lengthInp.value) || 0); const manualOn = manualModeState; if (!manualOn) { manualFocusEnabled = false; @@ -2069,7 +2583,7 @@ function distinctPaletteSlots(palette) { topperControls.classList.toggle('hidden', !showTopper); // Number tint controls removed; always use base SVG appearance for numbers. 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 (reverseHint) reverseHint.classList.toggle('hidden', !showReverse); if (reverseCb) { @@ -2146,27 +2660,34 @@ function distinctPaletteSlots(palette) { if (!match) { debug('manual paint click ignored (no match)', { id }); return; } const x = parseInt(match[1], 10); const y = parseInt(match[2], 10); - - if (manualFloatingQuad !== y) { - setManualTargetRow(y); - return; + + if (manualTool === 'inflate' || manualTool === 'deflate') { + applyManualInflate(y, manualTool === 'inflate' ? 1 : -1); + 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 palette = buildClassicPalette(); const colorCode = parseInt(g?.getAttribute('data-color-code') || '0', 10); 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 }; manualActiveColorGlobal = window.shared?.setActiveColor?.(picked) || picked; manualRedoStack.length = 0; - manualTool = 'paint'; + manualPaintMode = 'paint'; updateClassicDesign(); return; } manualRedoStack.length = 0; - if (manualTool === 'erase') { + if (manualPaintMode === 'erase') { const ERASE_COLOR = { hex: 'transparent', colour: 'transparent', image: null }; manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: ERASE_COLOR }); setManualOverride(currentPatternName, currentRowCount, x, y, ERASE_COLOR); @@ -2223,7 +2744,8 @@ function distinctPaletteSlots(palette) { const setLengthForPattern = () => { if (!lengthInp || !patSel) return; - const isArch = (computePatternName()).toLowerCase().includes('arch'); + const patternName = computePatternName().toLowerCase(); + const isArch = patternName.includes('arch'); 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)); display?.addEventListener('click', handleManualPaint); + display?.addEventListener('pointerdown', handleRowDragStart); patSel?.addEventListener('change', () => { lastPresetKey = null; syncPatternStateFromSelect(); @@ -2245,10 +2768,16 @@ function distinctPaletteSlots(palette) { setLengthForPattern(); 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(); })); 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; manualModeState = false; saveManualMode(false); @@ -2266,6 +2795,8 @@ function distinctPaletteSlots(palette) { const togglingOn = !manualModeState; if (togglingOn) lastNonManualLayout = patternLayout === 'manual' ? 'spiral' : patternLayout; manualModeState = togglingOn; + manualTool = 'paint'; + manualPaintMode = 'paint'; patternLayout = togglingOn ? 'manual' : lastNonManualLayout; manualFocusStart = 0; 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)); manualPrevBtn?.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', () => { manualFocusEnabled = true; // keep focus so detail/rail stay active manualFloatingQuad = null; @@ -2340,10 +2878,49 @@ function distinctPaletteSlots(palette) { saveManualExpanded(manualExpandedState); updateClassicDesign(); }); - floatingUndo?.addEventListener('click', undoLastManual); floatingRedo?.addEventListener('click', redoLastManual); - floatingPick?.addEventListener('click', () => { manualTool = 'pick'; applyPatternButtons(); }); - floatingErase?.addEventListener('click', () => { manualTool = 'erase'; applyPatternButtons(); }); + floatingPick?.addEventListener('click', () => { + 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', () => { const key = manualKey(currentPatternName, currentRowCount); const prev = manualOverrides[key] ? { ...manualOverrides[key] } : null; diff --git a/index.html b/index.html index bfa26ac..b581412 100644 --- a/index.html +++ b/index.html @@ -257,6 +257,7 @@
+
+