1/** ============================================================ 2 * BOGA9 Interior Studio â Canvas 13-Layer Recoloring Engine 3 * Vanilla JS, no jQuery dependency 4 * ============================================================ */ 5(function () { 6 'use strict'; 7 8 /* ---------------------------------------------------------- 9 CONSTANTS 10 ---------------------------------------------------------- */ 11 var CANVAS_W = 2000, CANVAS_H = 1500; 12 // Stacking: backâfront. Floor(001) moved before door_c(002) so it doesn't cover it. 13 // 017 replaces 016 in-place (same image, new file). 010 split into 010-1..4 at top. 14 var LAYER_ORDER = [13, 14, 7, 11, 6, 8, 1, 2, 3, 5, 4, 15, 12, 17, 9, 21, 22, 23, 24]; 15 var FIXED_LAYERS = [13]; 16 var SEATBELT_LAYER = 11; 17 var ASSET_BASE = '/skin-skin3/SkinImg/configurator/interior/'; 18 var LAYER_IMG_VERSION = '20260507b'; // bust browser cache when layer webp is replaced 19 // Per-layer file slug override (skips zero-padded default). 20 // Use when a layer asset is replaced and the CDN would otherwise serve the cached old name, 21 // or when a logical layer ID maps to a non-numeric file name (e.g. 010 split into 010-1..4). 22 var LAYER_FILE_OVERRIDE = { 23 2: '002-1', 24 21: '010-1', 25 22: '010-2', 26 23: '010-3', 27 24: '010-4' 28 // 17 â uses default '017' 29 }; 30 31 var COLOR_PRESETS = [ 32 { id: 'white', name: 'Snow White', hex: '#F5F3EF' }, 33 { id: 'ivory', name: 'ë°ëë¼', hex: '#E8DEC8' }, 34 { id: 'sand', name: 'Warm Sand', hex: '#D4B896' }, 35 { id: 'cognac', name: 'ë¸ë¼ì´', hex: '#C08B5C' }, 36 { id: 'brown', name: 'Saddle Brown', hex: '#8B5E3C' }, 37 { id: 'mocha', name: 'Dark Mocha', hex: '#4A3728' }, 38 { id: 'navy', name: 'Midnight Navy', hex: '#1E2A45' }, 39 { id: 'black', name: 'Obsidian', hex: '#1A1A1E' } 40 ]; 41 42 var OPTION_MAP = { 43 seat: { layers: [9, 21, 22, 12, 17], label: 'ìí¸ ì»¬ë¬', defaultColor: 'ivory' }, 44 seat_accent: { layers: [15, 23, 24], label: 'ìí¸ ë°°ì', defaultColor: 'ivory' }, 45 seatbelt:{ layers: [11], label: 'ìì 벨í¸', defaultColor: 'ivory' }, 46 floor: { layers: [1], label: 'ë°ë¥ íë¡ì´', defaultPattern: '003' }, 47 handle: { layers: [8], label: 'ìì¡ì´ í¸ë¦¬ë°', defaultColor: 'ivory' }, 48 door_a: { layers: [4], label: 'ëì´ í¨ë A', defaultColor: 'ivory' }, 49 door_b: { layers: [3], label: 'ëì´ í¨ë B', defaultColor: 'ivory' }, 50 door_c: { layers: [2], label: 'ëì´ í¨ë C', defaultColor: 'ivory' }, 51 luggage: { layers: [5], label: 'ë¬ê¸°ì§', defaultColor: 'ivory' }, 52 ceiling: { layers: [7], label: 'ì²ì í¨ë¸ë¦', defaultColor: 'white' }, 53 ceiling_mold: { layers: [14], label: 'ì²ì 몰ë©', defaultColor: 'white' }, 54 blind: { layers: [6], label: 'ë¸ë¼ì¸ë 커í¼', defaultColor: 'ivory' } 55 }; 56 57 var FLOOR_PATTERNS = [ 58 { id: '003', name: 'ì°ë' }, 59 { id: '005', name: 'íì´í¸' }, 60 { id: '007', name: 'ë¸ë' } 61 ]; 62 63 var THEMES = [ 64 { id: 'classic', name: 'Classic Ivory', 65 seat: 'ivory', seatbelt: 'ivory', floor: 'marble_white', handle: 'brown', door_a: 'ivory', door_b: 'ivory', door_c: 'ivory', luggage: 'ivory', ceiling: 'white', blind: 'ivory' }, 66 { id: 'mocha', name: 'Dark Mocha', 67 seat: 'mocha', seatbelt: 'mocha', floor: 'wood_dark', handle: 'black', door_a: 'mocha', door_b: 'mocha', door_c: 'mocha', luggage: 'mocha', ceiling: 'mocha', blind: 'mocha' }, 68 { id: 'cognac', name: 'Cognac Luxe', 69 seat: 'cognac', seatbelt: 'sand', floor: 'wood_light', handle: 'cognac', door_a: 'sand', door_b: 'sand', door_c: 'sand', luggage: 'sand', ceiling: 'ivory', blind: 'sand' }, 70 { id: 'navy', name: 'Navy Night', 71 seat: 'navy', seatbelt: 'navy', floor: 'marble_dark', handle: 'black', door_a: 'navy', door_b: 'navy', door_c: 'navy', luggage: 'navy', ceiling: 'black', blind: 'navy' } 72 ]; 73 74 // Preset palettes â reviewed against /customBoga/기본 ì ê³µ ìì ëê°/ images (2026-04-16) 75 var INTERIOR_PRESETS = [ 76 { id: 'brown_two', name: 'ë¸ë¼ì´ í¬í¤', 77 seat: '#C08B5C', seat_accent: '#E8DEC8', seatbelt: '#E8DEC8', floor: '005', 78 handle: '#C08B5C', door_a: '#C08B5C', door_b: '#E8DEC8', door_c: '#C08B5C', 79 luggage: '#C08B5C', ceiling: '#C08B5C', ceiling_mold: '#E8DEC8', blind: '#C08B5C' }, 80 { id: 'vanilla_two', name: 'ë°ëë¼ í¬í¤', 81 seat: '#E8DEC8', seat_accent: '#C08B5C', seatbelt: '#C08B5C', floor: '005', 82 handle: '#E8DEC8', door_a: '#E8DEC8', door_b: '#C08B5C', door_c: '#E8DEC8', 83 luggage: '#E8DEC8', ceiling: '#E8DEC8', ceiling_mold: '#C08B5C', blind: '#E8DEC8' }, 84 { id: 'full_black', name: 'í ë¸ë', 85 seat: '#1A1A1E', seat_accent: '#1A1A1E', seatbelt: '#1A1A1E', floor: '005', 86 handle: '#1A1A1E', door_a: '#1A1A1E', door_b: '#1A1A1E', door_c: '#1A1A1E', 87 luggage: '#1A1A1E', ceiling: '#1A1A1E', ceiling_mold: '#1A1A1E', blind: '#1A1A1E' }, 88 { id: 'full_vanilla', name: 'í ë°ëë¼', 89 seat: '#E8DEC8', seat_accent: '#E8DEC8', seatbelt: '#E8DEC8', floor: '005', 90 handle: '#E8DEC8', door_a: '#E8DEC8', door_b: '#E8DEC8', door_c: '#E8DEC8', 91 luggage: '#E8DEC8', ceiling: '#E8DEC8', ceiling_mold: '#E8DEC8', blind: '#E8DEC8' }, 92 { id: 'vanilla_black',name: 'ë°ëë¼ ë¸ë', 93 seat: '#E8DEC8', seat_accent: '#E8DEC8', seatbelt: '#E8DEC8', floor: '005', 94 handle: '#1A1A1E', door_a: '#1A1A1E', door_b: '#E8DEC8', door_c: '#1A1A1E', 95 luggage: '#1A1A1E', ceiling: '#1A1A1E', ceiling_mold: '#1A1A1E', blind: '#1A1A1E' } 96 ]; 97 98 // Skyview roof themes 99 var ROOF_BASE = ASSET_BASE + 'roof/'; 100 var SKYVIEW_THEMES = [ 101 { id: 'default', name: '기본 íë', type: 'original' }, 102 { id: 'roof_1', name: 'ìì°', type: 'image', src: ROOF_BASE + 'roof_1.webp' }, 103 { id: 'roof_2', name: 'ì¤ë¡ë¼', type: 'image', src: ROOF_BASE + 'roof_2.webp' }, 104 { id: 'roof_3', name: 'ì¤ì°', type: 'image', src: ROOF_BASE + 'roof_3.webp' }, 105 { id: 'roof_4', name: 'ìì´í°', type: 'image', src: ROOF_BASE + 'roof_4.webp' }, 106 { id: 'roof_5', name: 'ì°ë¹ì¸í¸', type: 'image', src: ROOF_BASE + 'roof_5.webp' } 107 ]; 108 109 // Option order for panel rendering (floor handled separately) 110 var COLOR_OPTIONS = ['seat', 'seat_accent', 'seatbelt', 'handle', 'door_a', 'door_b', 'door_c', 'luggage', 'ceiling', 'ceiling_mold', 'blind']; 111 112 /* ---------------------------------------------------------- 113 STATE 114 ---------------------------------------------------------- */ 115 var state = { 116 seat: '#E8DEC8', 117 seat_accent: '#E8DEC8', 118 seatbelt: '#E8DEC8', 119 floor: '003', 120 handle: '#E8DEC8', 121 door_a: '#E8DEC8', 122 door_b: '#E8DEC8', 123 door_c: '#E8DEC8', 124 luggage: '#E8DEC8', 125 ceiling: '#F5F3EF', 126 ceiling_mold: '#F5F3EF', 127 blind: '#E8DEC8', 128 skyview: 'default', 129 loadedCount: 0, 130 activePickerOption: null 131 }; 132 133 /* ---------------------------------------------------------- 134 CACHE 135 ---------------------------------------------------------- */ 136 var originalImageData = {}; 137 var offscreenCanvases = {}; 138 var layerLumStats = {}; // per-layer {min, max, range} for dark-target recoloring 139 var baseCompositeCanvas; // original full composite (backdrop) 140 var skyviewAlphaMask; // layer 013 alpha channel cache 141 var skyviewOriginalData; // layer 013 original pixel data 142 var seatbeltOriginalData; // layer 011 original for restoration (light seat colors) 143 var mainCanvas, mainCtx; 144 145 /* ---------------------------------------------------------- 146 INIT 147 ---------------------------------------------------------- */ 148 document.addEventListener('DOMContentLoaded', init); 149
150 function init() { 151 mainCanvas = document.getElementById('intCanvas'); 152 if (!mainCanvas) return; 153 mainCanvas.width = CANVAS_W; 154 mainCanvas.height = CANVAS_H; 155 mainCtx = mainCanvas.getContext('2d'); 156 cleanTextNodes(); 157 loadAllLayers(); 158 } 159 160 /** Strip stray text nodes (Cafe24 optimizer injects ) */ 161 function cleanTextNodes() { 162 document.querySelectorAll('.int-skyview__item').forEach(function (el) { 163 for (var i = el.childNodes.length - 1; i >= 0; i--) { 164 if (el.childNodes[i].nodeType === 3) el.removeChild(el.childNodes[i]); 165 } 166 }); 167 } 168 169 /* ---------------------------------------------------------- 170 LAYER LOADING 171 ---------------------------------------------------------- */ 172 function loadAllLayers() { 173 var total = LAYER_ORDER.length; 174 var loaded = 0; 175 176 LAYER_ORDER.forEach(function (id) { 177 var img = new Image(); 178 var padded = String(id).padStart(3, '0'); 179 var fileSlug = LAYER_FILE_OVERRIDE[id] || padded; 180 img.src = ASSET_BASE + 'layers/' + fileSlug + '.webp?v=' + LAYER_IMG_VERSION; 181 182 img.onload = function () { 183 var osc = document.createElement('canvas'); 184 osc.width = CANVAS_W; 185 osc.height = CANVAS_H; 186 var ctx = osc.getContext('2d'); 187 ctx.drawImage(img, 0, 0, CANVAS_W, CANVAS_H); 188 offscreenCanvases[id] = osc; 189 // Cache original pixel data for colorable layers 190 if (FIXED_LAYERS.indexOf(id) === -1) { 191 originalImageData[id] = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H); 192 // Pre-compute luminance stats for dark-target recoloring 193 var ld = originalImageData[id].data; 194 var lMin = 255, lMax = 0; 195 for (var li = 0; li < ld.length; li += 4) { 196 if (ld[li + 3] === 0) continue; 197 var ll = ld[li] * 0.299 + ld[li + 1] * 0.587 + ld[li + 2] * 0.114; 198 if (ll < lMin) lMin = ll; 199 if (ll > lMax) lMax = ll; 200 } 201 layerLumStats[id] = { min: lMin, max: lMax, range: Math.max(lMax - lMin, 1) }; 202 } 203 // Cache seatbelt original for restoration when seat is light 204 if (id === SEATBELT_LAYER) { 205 seatbeltOriginalData = new Uint8ClampedArray(originalImageData[id].data); 206 } 207 // Cache layer 013 alpha mask for skyview swap 208 if (id === 13) { 209 var fullData = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H); 210 skyviewOriginalData = new Uint8ClampedArray(fullData.data); 211 skyviewAlphaMask = new Uint8Array(CANVAS_W * CANVAS_H); 212 for (var a = 0; a < skyviewAlphaMask.length; a++) { 213 skyviewAlphaMask[a] = fullData.data[a * 4 + 3]; 214 } 215 } 216 loaded++; 217 updateLoadingProgress(loaded, total); 218 if (loaded === total) onAllLayersLoaded(); 219 }; 220 221 img.onerror = function () { 222 // Fallback to PNG 223 if (img.src.indexOf('.webp') !== -1) { 224 img.src = ASSET_BASE + 'layers/' + fileSlug + '.png?v=' + LAYER_IMG_VERSION; 225 } else { 226 loaded++; 227 updateLoadingProgress(loaded, total); 228 if (loaded === total) onAllLayersLoaded(); 229 } 230 }; 231 }); 232 } 233 234 function updateLoadingProgress(loaded, total) { 235 var pct = Math.round((loaded / total) * 100); 236 var bar = document.getElementById('intLoaderBar'); 237 var txt = document.getElementById('intLoaderPct'); 238 if (bar) bar.style.width = pct + '%'; 239 if (txt) txt.textContent = pct; 240 } 241 242 function onAllLayersLoaded() { 243 // Load original full image as backdrop 244 var bgImg = new Image(); 245 bgImg.src = ASSET_BASE + 'layers/original.webp'; 246 bgImg.onload = function () { 247 baseCompositeCanvas = document.createElement('canvas'); 248 baseCompositeCanvas.width = CANVAS_W; 249 baseCompositeCanvas.height = CANVAS_H; 250 baseCompositeCanvas.getContext('2d').drawImage(bgImg, 0, 0, CANVAS_W, CANVAS_H); 251 applyDefaultColors(); 252 compositeAll(); 253 finishInit(); 254 }; 255 bgImg.onerror = function () { 256 // Fallback: no backdrop 257 applyDefaultColors(); 258 compositeAll(); 259 finishInit(); 260 }; 261 } 262 263 function finishInit() { 264 265 // Hide loader 266 var loader = document.getElementById('intLoader'); 267 if (loader) loader.classList.add('is-hidden'); 268 269 // Bind UI events 270 bindEvents(); 271 272 // Mobile scroll hint â show/hide based on scroll position 273 initScrollHint(); 274 } 275 276 function initScrollHint() { 277 var scrollEl = document.querySelector('.int-panel__scroll'); 278 var hintEl = document.getElementById('intScrollHint'); 279 if (!scrollEl || !hintEl) return; 280 281 var checkScroll = function () { 282 var isScrollable = scrollEl.scrollHeight > scrollEl.clientHeight + 20; 283 var nearBottom = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 50; 284 if (!isScrollable || nearBottom) { 285 hintEl.classList.add('is-hidden'); 286 } else { 287 hintEl.classList.remove('is-hidden'); 288 } 289 }; 290 291 scrollEl.addEventListener('scroll', checkScroll, { passive: true }); 292 // Check after layout settles 293 setTimeout(checkScroll, 200); 294 // Re-check on resize (orientation changes) 295 window.addEventListener('resize', debounce(checkScroll, 150)); 296 } 297 298 /* ---------------------------------------------------------- 299 RECOLORING ENGINE 300 ---------------------------------------------------------- */ 301 function recolorPixels(imageData, targetR, targetG, targetB, lumStats) { 302 var d = imageData.data; 303 var targetLum = targetR * 0.299 + targetG * 0.587 + targetB * 0.114; 304 305 // Dark targets: enhanced contrast + specular highlights 306 if (targetLum < 80 && lumStats) { 307 recolorDarkTarget(d, targetR, targetG, targetB, targetLum, lumStats); 308 return imageData; 309 } 310 311 // Original algorithm for light/mid colors (unchanged) 312 var gamma, strength, boost; 313 if (targetLum < 120) { 314 gamma = 0.9; strength = 0.85; boost = 1.1; 315 } else { 316 gamma = 1.1; strength = 0.90; boost = 1.0; 317 } 318 319 for (var i = 0; i < d.length; i += 4) { 320 if (d[i + 3] === 0) continue; 321 var lum = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) / 255; 322 var lumAdj = Math.min(Math.pow(lum, gamma) * boost, 1.0); 323 d[i] = Math.min(255, lumAdj * targetR * strength + d[i] * (1 - strength) * (targetR / 255)); 324 d[i + 1] = Math.min(255, lumAdj * targetG * strength + d[i + 1] * (1 - strength) * (targetG / 255)); 325 d[i + 2] = Math.min(255, lumAdj * targetB * strength + d[i + 2] * (1 - strength) * (targetB / 255)); 326 } 327 return imageData; 328 } 329 330 /** 331 * Dark-target recoloring: normalizes source luminance, applies S-curve 332 * contrast enhancement, and adds specular highlights for realistic
333 * dark leather / fabric rendering. 334 */ 335 function recolorDarkTarget(d, tR, tG, tB, targetLum, stats) { 336 var sMin = stats.min, sRange = stats.range; 337 338 // How dark: 0 (near threshold 80) â 1 (pure black) 339 var darkness = Math.max(0, 1.0 - targetLum / 80); 340 341 // Parameters: dark base + sharp specular peaks (black leather reference match) 342 var shadowDepth = 2.0 + darkness * 1.0; // deep shadow creases (gamma 3.0) 343 var highlightLift = 0.30; // sharp highlight concentration 344 var contrastBoost = 0.7 + darkness * 0.5; // LOW base keeps surface dark (max 1.2) 345 var specPeak = 0.25 + darkness * 0.35; // STRONG specular at peaks (max 0.60) 346 var specStart = 0.65; // only top ridges get specular shine 347 348 for (var i = 0; i < d.length; i += 4) { 349 if (d[i + 3] === 0) continue; 350 351 var lum = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; 352 var norm = (lum - sMin) / sRange; // 0-1 normalized 353 if (norm < 0) norm = 0; 354 if (norm > 1) norm = 1; 355 356 // S-curve: deepen shadows, lift highlights 357 var curved; 358 if (norm < 0.5) { 359 var t = norm / 0.5; 360 curved = 0.5 * Math.pow(t, shadowDepth); 361 } else { 362 var t2 = (norm - 0.5) / 0.5; 363 curved = 0.5 + 0.5 * Math.pow(t2, highlightLift); 364 } 365 366 // Base color modulated by curved luminance 367 var baseFactor = curved * contrastBoost; 368 369 // Specular: blend toward white only at sharpest highlight peaks 370 var specAmount = 0; 371 if (curved > specStart) { 372 var specT = (curved - specStart) / (1.0 - specStart); 373 specAmount = specT * specT * specT * specPeak; // cubic for sharper falloff 374 } 375 376 var invSpec = 1.0 - specAmount; 377 var oR = tR * baseFactor * invSpec + 255 * specAmount; 378 var oG = tG * baseFactor * invSpec + 255 * specAmount; 379 var oB = tB * baseFactor * invSpec + 255 * specAmount; 380 381 d[i] = oR > 255 ? 255 : (oR < 0 ? 0 : (oR + 0.5) | 0); 382 d[i + 1] = oG > 255 ? 255 : (oG < 0 ? 0 : (oG + 0.5) | 0); 383 d[i + 2] = oB > 255 ? 255 : (oB < 0 ? 0 : (oB + 0.5) | 0); 384 } 385 } 386 387 // Layers with very dark source pixels need stronger recoloring. 388 // Layer 002 was re-mastered from the beige base (Asset2) so it now recolors 389 // identically to door_a/b via the standard path. Kept as empty array as a 390 // hook for future dark-source layers. 391 var DARK_SOURCE_LAYERS = []; 392 393 function recolorLayer(layerId, hex) { 394 if (!originalImageData[layerId]) return; 395 var rgb = hexToRgb(hex); 396 var original = originalImageData[layerId]; 397 var copy = new ImageData( 398 new Uint8ClampedArray(original.data), 399 CANVAS_W, CANVAS_H 400 ); 401 if (DARK_SOURCE_LAYERS.indexOf(layerId) !== -1) { 402 recolorDarkSource(copy, rgb.r, rgb.g, rgb.b, layerLumStats[layerId]); 403 } else { 404 recolorPixels(copy, rgb.r, rgb.g, rgb.b, layerLumStats[layerId]); 405 } 406 offscreenCanvases[layerId].getContext('2d').putImageData(copy, 0, 0); 407 } 408 409 /** For layers with compressed (dark) source luminance â stretches source range 410 * to 0..1 via lumStats, then applies the same tone curve as re
410colorPixels so 411 * output matches sibling layers (e.g. door_a/b) at the same target hex. */ 412 function recolorDarkSource(imageData, targetR, targetG, targetB, lumStats) { 413 var d = imageData.data; 414 var sMin = lumStats.min, sRange = lumStats.range; 415 var targetLum = targetR * 0.299 + targetG * 0.587 + targetB * 0.114; 416 417 // Dark target: reuse recolorPixels' dark branch (already lum-normalized) 418 if (targetLum < 80) { 419 recolorDarkTarget(d, targetR, targetG, targetB, targetLum, lumStats); 420 return imageData; 421 } 422 423 // Light/mid target: mirror recolorPixels' curve on normalized luminance 424 var gamma, strength, boost; 425 if (targetLum < 120) { gamma = 0.9; strength = 0.85; boost = 1.1; } 426 else { gamma = 1.1; strength = 0.90; boost = 1.0; } 427 428 for (var i = 0; i < d.length; i += 4) { 429 if (d[i + 3] === 0) continue; 430 var lum = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; 431 var norm = (lum - sMin) / sRange; 432 if (norm < 0) norm = 0; else if (norm > 1) norm = 1; 433 var virtualSrc = norm * 255; 434 var lumAdj = Math.min(Math.pow(norm, gamma) * boost, 1.0); 435 d[i] = Math.min(255, lumAdj * targetR * strength + virtualSrc * (1 - strength) * (targetR / 255)); 436 d[i + 1] = Math.min(255, lumAdj * targetG * strength + virtualSrc * (1 - strength) * (targetG / 255)); 437 d[i + 2] = Math.min(255, lumAdj * targetB * strength + virtualSrc * (1 - strength) * (targetB / 255)); 438 } 439 return imageData; 440 } 441 442 function compositeAll() { 443 mainCtx.clearRect(0, 0, CANVAS_W, CANVAS_H); 444 // Draw original composite as backdrop (fills gaps between layers) 445 if (baseCompositeCanvas) { 446 mainCtx.drawImage(baseCompositeCanvas, 0, 0); 447 } 448 // Draw recolored layers on top 449 for (var i = 0; i < LAYER_ORDER.length; i++) { 450 var osc = offscreenCanvases[LAYER_ORDER[i]]; 451 if (osc) mainCtx.drawImage(osc, 0, 0); 452 } 453 } 454 455 function applyDefaultColors() { 456 var initial = INTERIOR_PRESETS.find(function (p) { return p.id === 'full_vanilla'; }); 457 if (!initial) return; 458 COLOR_OPTIONS.forEach(function (optId) { 459 var hex = initial[optId]; 460 if (hex) { 461 applyColor(optId, hex, true); 462 updateSwatchSelection(optId, hex); 463 } 464 }); 465 if (initial.floor) { 466 loadFloorPattern(initial.floor); 467 updateFloorSelection(initial.floor); 468 } 469 updatePresetSelection('full_vanilla'); 470 } 471 472 /* ---------------------------------------------------------- 473 FLOOR PATTERN SWAP 474 ---------------------------------------------------------- */ 475 function loadFloorPattern(patternId, callback) { 476 var img = new Image(); 477 img.src = ASSET_BASE + 'floor/' + patternId + '.webp'; 478 img.onload = function () { 479 var ctx = offscreenCanvases[1].getContext('2d'); 480 ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); 481 ctx.drawImage(img, 0, 0, CANVAS_W, CANVAS_H); 482 originalImageData[1] = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H); 483 state.floor = patternId; 484 compositeAll(); 485 if (callback) callback(); 486 }; 487 img.onerror = function () { 488 if (img.src.indexOf('.webp') !== -1) { 489 img.src = ASSET_BASE + 'floor/' + patternId + '.png'; 490 } 491 }; 492 } 493 494 /* ---------------------------------------------------------- 495 SKYVIEW THEME SWAP 496 ---------------------------------------------------------- */ 497 function applySkyview(themeId) { 498 var theme = SKYVIEW_THEMES.find(function (t) { return t.id === themeId; }); 499 if (!theme || !skyviewAlphaMask) return; 500 501 state.skyview = themeId; 502 var ctx13 = offscreenCanvases[13].getContext('2d'); 503 504 if (theme.type === 'original') { 505 // Restore original 013 pixels 506 var restore = new ImageData(new Uint8ClampedArray(skyviewOriginalData), CANVAS_W, CANVAS_H); 507 ctx13.putImageData(restore, 0, 0); 508 compositeAll(); 509 updateSkyviewSelection(themeId); 510 return; 511 } 512 513 if (theme.type === 'image') { 514 applySkyviewFromImage(themeId, theme.src); 515 return; 516 } 517 } 518 519 function applySkyviewFromImage(themeId, imgSrc) { 520 if (!skyviewAlphaMask) return; 521 state.skyview = themeId; 522 523 var img = new Image(); 524 img.src = imgSrc; 525 img.onload = function () { 526 // Find skylight bounding box from alpha mask 527 var bMinX = CANVAS_W, bMaxX = 0, bMinY = CANVAS_H, bMaxY = 0; 528 for (var py = 0; py < CANVAS_H; py++) { 529 for (var px = 0; px < CANVAS_W; px++) { 530 if (skyviewAlphaMask[py * CANVAS_W + px] > 10) { 531 if (px < bMinX) bMinX = px; 532 if (px > bMaxX) bMaxX = px; 533 if (py < bMinY) bMinY = py; 534 if (py > bMaxY) bMaxY = py; 535 } 536 } 537 } 538 var bw = bMaxX - bMinX, bh = bMaxY - bMinY; 539 540 var skyCanvas = document.createElement('canvas'); 541 skyCanvas.width = CANVAS_W; 542 skyCanvas.height = CANVAS_H; 543 var skyCtx = skyCanvas.getContext('2d'); 544 545 // Fit image to skylight bbox using contain (show more of image)
546 var iw = img.width, ih = img.height; 547 var scale = Math.max(bw / iw, bh / ih); 548 var dw = iw * scale, dh = ih * scale; 549 var dx = bMinX + (bw - dw) / 2; 550 var dy = bMinY + (bh - dh) / 2; 551 skyCtx.drawImage(img, dx, dy, dw, dh); 552 553 // Apply alpha mask 554 var skyData = skyCtx.getImageData(0, 0, CANVAS_W, CANVAS_H); 555 var sd = skyData.data; 556 for (var i = 0; i < skyviewAlphaMask.length; i++) { 557 sd[i * 4 + 3] = skyviewAlphaMask[i]; 558 } 559 560 var ctx13 = offscreenCanvases[13].getContext('2d'); 561 ctx13.clearRect(0, 0, CANVAS_W, CANVAS_H); 562 ctx13.putImageData(skyData, 0, 0); 563 compositeAll(); 564 updateSkyviewSelection(themeId); 565 }; 566 } 567 568 function updateSkyviewSelection(themeId) { 569 document.querySelectorAll('.int-skyview__item').forEach(function (el) { 570 el.classList.remove('is-selected'); 571 if (el.getAttribute('data-skyview') === themeId) el.classList.add('is-selected'); 572 }); 573 var valEl = document.querySelector('.int-option[data-option="skyview"] .int-option__value'); 574 if (valEl) { 575 var theme = SKYVIEW_THEMES.find(function (t) { return t.id === themeId; }); 576 valEl.textContent = theme ? theme.name : themeId; 577 } 578 } 579 580 /* ---------------------------------------------------------- 581 UI EVENT BINDING 582 ---------------------------------------------------------- */ 583 function bindEvents() { 584 // Color swatches 585 document.querySelectorAll('.int-swatch[data-option][data-hex]').forEach(function (el) { 586 el.addEventListener('click', function () { 587 var optId = el.getAttribute('data-option'); 588 var hex = el.getAttribute('data-hex'); 589 applyColor(optId, hex); 590 updateSwatchSelection(optId, hex); 591 }); 592 }); 593 594 // Color picker trigger swatches 595 document.querySelectorAll('.int-swatch--picker').forEach(function (el) { 596 el.addEventListener('click', function () { 597 var optId = el.getAttribute('data-option'); 598 openColorPicker(optId); 599 }); 600 }); 601 602 // Floor pattern items 603 document.querySelectorAll('.int-floor__item[data-floor]').forEach(function (el) { 604 el.addEventListener('click', function () { 605 var floorId = el.getAttribute('data-floor'); 606 loadFloorPattern(floorId); 607 updateFloorSelection(floorId); 608 }); 609 }); 610 611 // Interior preset items 612 document.querySelectorAll('.int-floor__item[data-preset]').forEach(function (el) { 613 el.addEventListener('click', function () { 614 var presetId = el.getAttribute('data-preset'); 615 applyPreset(presetId); 616 }); 617 }); 618 619 // Skyview themes 620 document.querySelectorAll('.int-skyview__item[data-skyview]').forEach(function (el) { 621 el.addEventListener('click', function () { 622 var themeId = el.getAttribute('data-skyview'); 623 applySkyview(themeId); 624 }); 625 }); 626 627 // Theme cards 628 document.querySelectorAll('.int-themes__card[data-theme]').forEach(function (el) { 629 el.addEventListener('click', function () { 630 var themeId = el.getAttribute('data-theme'); 631 applyTheme(themeId); 632 }); 633 }); 634 635 // Save buttons 636 var btnPng = document.getElementById('intSavePng'); 637 var btnPdf = document.getElementById('intSavePdf'); 638 if (btnPng) btnPng.addEventListener('click', savePNG); 639 if (btnPdf) btnPdf.addEventListener('click', savePDF); 640 641 // Color picker modal 642 var pickerInput = document.getElementById('intColorInput'); 643 if (pickerInput) { 644 pickerInput.addEventListener('input', debounce(function () { 645 var hex = pickerInput.value; 646 document.getElementById('intPickerHex').textContent = hex.toUpperCase(); 647 }, 30)); 648 } 649 650 var applyBtn = document.getElementById('intPickerApply'); 651 var cancelBtn = document.getElementById('intPickerCancel'); 652 if (applyBtn) applyBtn.addEventListener('click', applyPickerColor); 653 if (cancelBtn) cancelBtn.addEventListener('click', closeColorPicker); 654 655 // Close picker on backdrop click 656 var modal = document.getElementById('intPickerModal'); 657 if (modal) { 658 modal.addEventListener('click', function (e) { 659 if (e.target === modal) closeColorPicker(); 660 }); 661 } 662 } 663 664 /* ---------------------------------------------------------- 665 COLOR APPLICATION â with interpolation transition 666 ---------------------------------------------------------- */ 667 var activeTransitions = {}; // optionId â animationFrameId 668 var TRANSITION_DURATION = 350; // ms 669 670 function applyColor(optionId, hex, skipTransition) { 671 var opt = OPTION_MAP[optionId]; 672 if (!opt) return; 673 674 var fromHex = state[optionId] || hex; 675 state[optionId] = hex; 676 677 // Update value label immediately 678 var valEl = document.querySelector('.int-option[data-option="' + optionId + '"] .int-option__value'); 679 if (valEl) { 680 var preset = COLOR_PRESETS.find(function (p) { return p.hex === hex; }); 681 valEl.textContent = preset ? preset.name : hex.toUpperCase(); 682 } 683 684 // Skip transition on init or if same color 685 if (skipTransition || fromHex === hex) {
686 opt.layers.forEach(function (lid) { recolorLayer(lid, hex); }); 687 compositeAll(); 688 return; 689 } 690 691 // Cancel any running transition for this option 692 if (activeTransitions[optionId]) { 693 cancelAnimationFrame(activeTransitions[optionId]); 694 } 695 696 var fromRgb = hexToRgb(fromHex); 697 var toRgb = hexToRgb(hex); 698 var startTime = null; 699 700 function step(timestamp) { 701 if (!startTime) startTime = timestamp; 702 var elapsed = timestamp - startTime; 703 var t = Math.min(elapsed / TRANSITION_DURATION, 1); 704 // Ease-out cubic for smooth deceleration 705 var ease = 1 - Math.pow(1 - t, 3); 706 707 // Interpolate RGB 708 var curR = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * ease); 709 var curG = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * ease); 710 var curB = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * ease); 711 var curHex = rgbToHex(curR, curG, curB); 712 713 opt.layers.forEach(function (lid) { recolorLayer(lid, curHex); }); 714 compositeAll(); 715 716 if (t < 1) { 717 activeTransitions[optionId] = requestAnimationFrame(step); 718 } else { 719 delete activeTransitions[optionId]; 720 } 721 } 722 723 activeTransitions[optionId] = requestAnimationFrame(step); 724 } 725 726 function updateSwatchSelection(optionId, hex) { 727 var container = document.querySelector('.int-option[data-option="' + optionId + '"]'); 728 if (!container) return; 729 container.querySelectorAll('.int-swatch').forEach(function (sw) { 730 sw.classList.remove('is-selected'); 731 if (sw.getAttribute('data-hex') === hex) sw.classList.add('is-selected'); 732 }); 733 } 734 735 function updateFloorSelection(patternId) { 736 var floorContainer = document.querySelector('.int-option[data-option="floor"]'); 737 if (!floorContainer) return; 738 floorContainer.querySelectorAll('.int-floor__item[data-floor]').forEach(function (el) { 739 el.classList.remove('is-selected'); 740 if (el.getAttribute('data-floor') === patternId) el.classList.add('is-selected'); 741 }); 742 var valEl = floorContainer.querySelector('.int-option__value'); 743 if (valEl) { 744 var pat = FLOOR_PATTERNS.find(function (p) { return p.id === patternId; }); 745 valEl.textContent = pat ? pat.name : patternId; 746 } 747 } 748 749 function updatePresetSelection(presetId) { 750 var container = document.querySelector('.int-option[data-option="preset"]'); 751 if (!container) return; 752 container.querySelectorAll('.int-floor__item[data-preset]').forEach(function (el) { 753 el.classList.remove('is-selected'); 754 if (el.getAttribute('data-preset') === presetId) el.classList.add('is-selected'); 755 }); 756 var valEl = container.querySelector('.int-option__value'); 757 if (valEl) { 758 var p = INTERIOR_PRESETS.find(function (x) { return x.id === presetId; }); 759 valEl.textContent = p ? p.name : presetId; 760 } 761 } 762 763 /* ---------------------------------------------------------- 764 INTERIOR PRESETS â apply 9 colors + floor + selection state 765 ---------------------------------------------------------- */ 766 // handle(008) & seatbelt(011) are stock parts â locked to ivory on every preset/theme change 767 var STOCK_IVORY_HEX = '#E8DEC8'; 768 var STOCK_LOCKED_OPTIONS = ['handle', 'seatbelt']; 769 770 function applyPreset(presetId) { 771 var preset = INTERIOR_PRESETS.find(function (p) { return p.id === presetId; }); 772 if (!preset) return; 773 774 COLOR_OPTIONS.forEach(function (optId) { 775 if (STOCK_LOCKED_OPTIONS.indexOf(optId) !== -1) { 776 applyColor(optId, STOCK_IVORY_HEX); 777 updateSwatchSelection(optId, STOCK_IVORY_HEX); 778 return; 779 } 780 var hex = preset[optId]; 781 if (hex) { 782 applyColor(optId, hex); 783 updateSwatchSelection(optId, hex); 784 } 785 }); 786 787 if (preset.floor && preset.floor !== state.floor) { 788 loadFloorPattern(preset.floor); 789 } 790 if (preset.floor) updateFloorSelection(preset.floor); 791 792 updatePresetSelection(presetId); 793 } 794 795 /* ---------------------------------------------------------- 796 THEMES 797 ---------------------------------------------------------- */ 798 function applyTheme(themeId) { 799 var theme = THEMES.find(function (t) { return t.id === themeId; }); 800 if (!theme) return; 801 802 // Apply colors 803 COLOR_OPTIONS.forEach(function (optId) { 804 if (STOCK_LOCKED_OPTIONS.indexOf(optId) !== -1) { 805 applyColor(optId, STOCK_IVORY_HEX); 806 updateSwatchSelection(optId, STOCK_IVORY_HEX); 807 return; 808 } 809 var colorId = theme[optId]; 810 var preset = findPreset(colorId); 811 if (preset) { 812 applyColor(optId, preset.hex); 813 updateSwatchSelection(optId, preset.hex); 814 } 815 }); 816 817 // Floor is fixed â no pattern swap 818 819 // Update theme selection 820 document.querySelectorAll('.int-themes__card').forEach(function (el) { 821 el.classList.remove('is-selected'); 822 if (el.getAttribute('data-theme') === themeId) el.classList.add('is-selected'); 823 }); 824 } 825 826 /* ---------------------------------------------------------- 827 COLOR PICKER 828 ---------------------------------------------------------- */ 829 function openColorPicker(optionId) { 830 state.activePickerOption = optionId; 831 var modal = document.getElementById('intPickerModal'); 832 var input = document.getElementById('intColorInput'); 833 var hexLabel = document.getElementById('intPickerHex'); 834 var titleEl = document.getElementById('intPickerTitle'); 835 if (input) input.value = state[optionId] || '#E8DEC8'; 836 if (hexLabel) hexLabel.textContent = (state[optionId] || '#E8DEC8').toUpperCase(); 837 if (titleEl) titleEl.textContent = OPTION_MAP[optionId].label + ' â ìì 컬ë¬'; 838 if (modal) modal.classList.add('is-open'); 839 } 840 841 function closeColorPicker() { 842 var modal = document.getElementById('intPickerModal'); 843 if (modal) modal.classList.remove('is-open'); 844 state.activePickerOption = null; 845 } 846 847 function applyPickerColor() { 848 var input = document.getElementById('intColorInput'); 849 if (!input || !state.activePickerOption) return; 850 var hex = input.value; 851 var optId = state.activePickerOption; 852 applyColor(optId, hex); 853 // Deselect all preset swatches, select picker 854 var container = document.querySelector('.int-option[data-option="' + optId + '"]'); 855 if (container) { 856 container.querySelectorAll('.int-swatch').forEach(function (sw) { 857 sw.classList.remove('is-selected'); 858 }); 859 var pickerSw = container.querySelector('.int-swatch--picker'); 860 if (pickerSw) pickerSw.classList.add('is-selected'); 861 } 862 closeColorPicker(); 863 } 864 865 /* ---------------------------------------------------------- 866 SAVE â PNG 867 ---------------------------------------------------------- */ 868 function savePNG() { 869 var dataUrl = mainCanvas.toDataURL('image/png'); 870 var a = document.createElement('a'); 871 a.download = 'BOGA9-interior-' + Date.now() + '.png'; 872 a.href = dataUrl; 873 a.click(); 874 } 875 876 /* ---------------------------------------------------------- 877 SAVE â PDF 878 ---------------------------------------------------------- */ 879 function savePDF() { 880 if (typeof window.jspdf === 'undefined') { 881 alert('PDF ë¼ì´ë¸ë¬ë¦¬ë¥¼ ë¡ë© ì¤ì ëë¤. ì ì í ë¤ì ìëí´ì£¼ì¸ì.'); 882 return; 883 } 884 885 var jsPDF = window.jspdf.jsPDF; 886 var imgData = mainCanvas.toDataURL('image/jpeg', 0.92); 887 888 var doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); 889 var pageW = 297, pageH = 210; 890 var margin = 15; 891 var imgW = pageW - margin * 2; 892 var imgH = imgW * (CANVAS_H / CANVAS_W); 893 894 // Header 895 doc.setFontSize(10); 896 doc.setTextColor(100); 897 doc.text('BOGA9 INTERIOR STUDIO', margin, margin - 2); 898 899 // Image 900 doc.addImage(imgData, 'JPEG', margin, margin + 4, imgW, imgH); 901 902 // Options summary 903 var summaryY = margin + imgH + 10; 904 if (summaryY + 15 > pageH) { 905 doc.addPage(); 906 summaryY = margin; 907 } 908 doc.setFontSize(8); 909 doc.setTextColor(80); 910 var labels = { 911 seat: 'Seat', seat_accent: 'Seat Accent', seatbelt: 'Seatbelt', handle: 'Handle', 912 door_a: 'Door A', door_b: 'Door B', door_c: 'Door C', 913 luggage: 'Luggage', ceiling: 'Ceiling', blind: 'Blind', floor: 'Floor' 914 }; 915 var x = margin;
916 Object.keys(labels).forEach(function (key) { 917 var val = state[key]; 918 doc.text(labels[key] + ': ' + val, x, summaryY); 919 x += 33; 920 }); 921 922 // Footer 923 doc.setFontSize(7); 924 doc.setTextColor(150); 925 doc.text('boga-tec.co.kr | 1599-8088', margin, pageH - 5); 926 doc.text(new Date().toLocaleDateString('ko-KR'), pageW - margin, pageH - 5, { align: 'right' }); 927 928 doc.save('BOGA9-interior-' + Date.now() + '.pdf'); 929 } 930 931 /* ---------------------------------------------------------- 932 UTILITIES 933 ---------------------------------------------------------- */ 934 function hexToRgb(hex) { 935 hex = hex.replace('#', ''); 936 return { 937 r: parseInt(hex.substring(0, 2), 16), 938 g: parseInt(hex.substring(2, 4), 16), 939 b: parseInt(hex.substring(4, 6), 16) 940 }; 941 } 942 943 function rgbToHex(r, g, b) { 944 return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 945 } 946 947 function findPreset(colorId) { 948 return COLOR_PRESETS.find(function (p) { return p.id === colorId; }) || null; 949 } 950 951 function debounce(fn, ms) { 952 var timer; 953 return function () { 954 var ctx = this, args = arguments; 955 clearTimeout(timer); 956 timer = setTimeout(function () { fn.apply(ctx, args); }, ms); 957 }; 958 } 959 960})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.