@adstage/web-sdk 2.1.2 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -284,8 +284,10 @@ export class AdsModule implements BaseModule {
284
284
  adElement.setAttribute('data-adstage-type', type);
285
285
  adElement.setAttribute('data-adstage-slot', slotId);
286
286
 
287
- adElement.style.width = typeof options.width === 'number' ? `${options.width}px` : (options.width || '100%');
288
- adElement.style.height = typeof options.height === 'number' ? `${options.height}px` : (options.height || '250px');
287
+ // 스마트한 크기 설정
288
+ const { width, height } = this.calculateAdSize(container, type, options);
289
+ adElement.style.width = width;
290
+ adElement.style.height = height;
289
291
  adElement.style.border = '1px dashed #ccc';
290
292
  adElement.style.display = 'flex';
291
293
  adElement.style.alignItems = 'center';
@@ -297,7 +299,312 @@ export class AdsModule implements BaseModule {
297
299
  container.appendChild(adElement);
298
300
 
299
301
  if (this._config?.debug) {
300
- console.log(`📦 Placeholder created for slot: ${slotId}`);
302
+ console.log(`📦 Placeholder created for slot: ${slotId} (${width} x ${height})`);
303
+ }
304
+ }
305
+
306
+ /**
307
+ * 컨테이너와 광고 타입에 따른 스마트한 크기 계산
308
+ */
309
+ private calculateAdSize(container: HTMLElement, type: AdType, options: any): { width: string; height: string } {
310
+ // 사용자가 명시적으로 크기를 지정한 경우
311
+ const explicitWidth = options.width;
312
+ const explicitHeight = options.height;
313
+
314
+ // 너비 처리
315
+ let width: string;
316
+ if (typeof explicitWidth === 'number') {
317
+ width = `${explicitWidth}px`;
318
+ } else if (typeof explicitWidth === 'string') {
319
+ width = explicitWidth;
320
+ } else {
321
+ width = '100%'; // 기본값은 100%
322
+ }
323
+
324
+ // 높이 처리 - 핵심 로직
325
+ let height: string;
326
+ if (typeof explicitHeight === 'number') {
327
+ height = `${explicitHeight}px`;
328
+ } else if (typeof explicitHeight === 'string' && explicitHeight !== '100%') {
329
+ height = explicitHeight;
330
+ } else {
331
+ // 100%이거나 높이가 지정되지 않은 경우 스마트 계산
332
+ const containerHeight = this.getContainerHeight(container);
333
+
334
+ if (containerHeight > 0) {
335
+ // 컨테이너에 높이가 있으면 100% 사용
336
+ height = '100%';
337
+ if (this._config?.debug) {
338
+ console.log(`📏 Using 100% height (container: ${containerHeight}px)`);
339
+ }
340
+ } else {
341
+ // 컨테이너에 높이가 없으면 타입별 기본값 사용
342
+ height = this.getDefaultHeightForAdType(type);
343
+ if (this._config?.debug) {
344
+ console.log(`📏 Using default height ${height} (no container height)`);
345
+ }
346
+ }
347
+ }
348
+
349
+ return { width, height };
350
+ }
351
+
352
+ /**
353
+ * 컨테이너의 실제 높이 계산
354
+ */
355
+ private getContainerHeight(container: HTMLElement): number {
356
+ // 현재 계산된 스타일에서 높이 확인
357
+ const computedStyle = window.getComputedStyle(container);
358
+ const height = parseFloat(computedStyle.height);
359
+
360
+ // height가 auto이거나 0이면 다른 방법들 시도
361
+ if (!height || height === 0) {
362
+ // min-height 확인
363
+ const minHeight = parseFloat(computedStyle.minHeight);
364
+ if (minHeight > 0) return minHeight;
365
+
366
+ // CSS로 설정된 고정 높이 확인
367
+ if (container.style.height && container.style.height !== 'auto') {
368
+ const styleHeight = parseFloat(container.style.height);
369
+ if (styleHeight > 0) return styleHeight;
370
+ }
371
+
372
+ // 속성으로 설정된 높이 확인
373
+ const heightAttr = container.getAttribute('height');
374
+ if (heightAttr) {
375
+ const attrHeight = parseFloat(heightAttr);
376
+ if (attrHeight > 0) return attrHeight;
377
+ }
378
+ }
379
+
380
+ return height || 0;
381
+ }
382
+
383
+ /**
384
+ * 광고 타입별 기본 높이 반환
385
+ */
386
+ private getDefaultHeightForAdType(type: AdType): string {
387
+ switch (type) {
388
+ case AdType.BANNER:
389
+ return '250px'; // 일반 배너
390
+ case AdType.TEXT:
391
+ return '120px'; // 텍스트는 좀 더 작게
392
+ case AdType.VIDEO:
393
+ return '360px'; // 비디오는 16:9 비율 고려
394
+ case AdType.NATIVE:
395
+ return '200px'; // 네이티브는 중간 크기
396
+ case AdType.INTERSTITIAL:
397
+ return '400px'; // 전면광고는 크게
398
+ default:
399
+ return '250px';
400
+ }
401
+ }
402
+
403
+ /**
404
+ * 이미지 크기 정보 로드 (프리로딩)
405
+ */
406
+ private async loadImageDimensions(imageUrl: string): Promise<{ width: number; height: number }> {
407
+ return new Promise((resolve, reject) => {
408
+ const img = new Image();
409
+ img.onload = () => {
410
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
411
+ };
412
+ img.onerror = () => {
413
+ reject(new Error(`Failed to load image: ${imageUrl}`));
414
+ };
415
+ img.src = imageUrl;
416
+ });
417
+ }
418
+
419
+ /**
420
+ * 여러 광고의 최적 컨테이너 크기 계산 (동적 크기 조정)
421
+ */
422
+ private async calculateOptimalContainerSize(
423
+ advertisements: Advertisement[],
424
+ containerWidth: number,
425
+ adType: AdType
426
+ ): Promise<{ width: string; height: string; aspectRatio: number }> {
427
+ if (!advertisements.length || adType !== AdType.BANNER) {
428
+ // 배너가 아니거나 광고가 없으면 기본값
429
+ return {
430
+ width: '100%',
431
+ height: this.getDefaultHeightForAdType(adType),
432
+ aspectRatio: 16/9
433
+ };
434
+ }
435
+
436
+ try {
437
+ // 모든 배너 이미지의 크기 정보 로드
438
+ const imageDimensions = await Promise.allSettled(
439
+ advertisements
440
+ .filter(ad => ad.imageUrl)
441
+ .map(ad => this.loadImageDimensions(ad.imageUrl!))
442
+ );
443
+
444
+ const validDimensions = imageDimensions
445
+ .filter((result): result is PromiseFulfilledResult<{ width: number; height: number }> =>
446
+ result.status === 'fulfilled'
447
+ )
448
+ .map(result => result.value);
449
+
450
+ if (validDimensions.length === 0) {
451
+ // 이미지 로드 실패시 기본값
452
+ return {
453
+ width: '100%',
454
+ height: this.getDefaultHeightForAdType(adType),
455
+ aspectRatio: 16/9
456
+ };
457
+ }
458
+
459
+ // 최적 전략 선택
460
+ const strategy = this.selectOptimalSizeStrategy(validDimensions);
461
+ const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
462
+
463
+ if (this._config?.debug) {
464
+ console.log(`📐 Optimal container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
465
+ }
466
+
467
+ return {
468
+ width: '100%',
469
+ height: `${optimalHeight}px`,
470
+ aspectRatio: containerWidth / optimalHeight
471
+ };
472
+
473
+ } catch (error) {
474
+ console.warn('Failed to calculate optimal size, using defaults:', error);
475
+ return {
476
+ width: '100%',
477
+ height: this.getDefaultHeightForAdType(adType),
478
+ aspectRatio: 16/9
479
+ };
480
+ }
481
+ }
482
+
483
+ /**
484
+ * 최적 크기 조정 전략 선택
485
+ */
486
+ private selectOptimalSizeStrategy(dimensions: { width: number; height: number }[]): 'average' | 'common' | 'dominant' {
487
+ const aspectRatios = dimensions.map(d => d.width / d.height);
488
+
489
+ // 1. 공통 비율이 있는지 확인 (±0.1 허용)
490
+ const ratioGroups = new Map<string, number>();
491
+ aspectRatios.forEach(ratio => {
492
+ const roundedRatio = Math.round(ratio * 10) / 10;
493
+ const key = roundedRatio.toString();
494
+ ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
495
+ });
496
+
497
+ const maxGroup = Math.max(...ratioGroups.values());
498
+ const totalImages = dimensions.length;
499
+
500
+ // 70% 이상이 비슷한 비율이면 dominant 전략
501
+ if (maxGroup / totalImages >= 0.7) {
502
+ return 'dominant';
503
+ }
504
+
505
+ // 표준 비율들이 많으면 common 전략
506
+ const standardRatios = [16/9, 4/3, 1/1, 3/2];
507
+ const standardCount = aspectRatios.filter(ratio =>
508
+ standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)
509
+ ).length;
510
+
511
+ if (standardCount / totalImages >= 0.5) {
512
+ return 'common';
513
+ }
514
+
515
+ // 기본은 평균 전략
516
+ return 'average';
517
+ }
518
+
519
+ /**
520
+ * 전략에 따른 최적 높이 계산
521
+ */
522
+ private calculateOptimalHeight(
523
+ dimensions: { width: number; height: number }[],
524
+ containerWidth: number,
525
+ strategy: 'average' | 'common' | 'dominant'
526
+ ): number {
527
+ const aspectRatios = dimensions.map(d => d.width / d.height);
528
+
529
+ switch (strategy) {
530
+ case 'dominant':
531
+ // 가장 많은 비율을 기준으로
532
+ const ratioGroups = new Map<string, { ratio: number; count: number }>();
533
+ aspectRatios.forEach(ratio => {
534
+ const roundedRatio = Math.round(ratio * 10) / 10;
535
+ const key = roundedRatio.toString();
536
+ const existing = ratioGroups.get(key);
537
+ if (existing) {
538
+ existing.count++;
539
+ } else {
540
+ ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
541
+ }
542
+ });
543
+
544
+ const dominantGroup = Array.from(ratioGroups.values())
545
+ .reduce((max, current) => current.count > max.count ? current : max);
546
+
547
+ return Math.round(containerWidth / dominantGroup.ratio);
548
+
549
+ case 'common':
550
+ // 표준 비율 중 가장 적합한 것 선택
551
+ const standardRatios = [
552
+ { ratio: 16/9, name: '16:9' },
553
+ { ratio: 4/3, name: '4:3' },
554
+ { ratio: 1/1, name: '1:1' },
555
+ { ratio: 3/2, name: '3:2' }
556
+ ];
557
+
558
+ const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
559
+ const bestStandard = standardRatios.reduce((best, current) =>
560
+ Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best
561
+ );
562
+
563
+ if (this._config?.debug) {
564
+ console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
565
+ }
566
+
567
+ return Math.round(containerWidth / bestStandard.ratio);
568
+
569
+ case 'average':
570
+ default:
571
+ // 평균 비율 사용
572
+ const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
573
+ return Math.round(containerWidth / averageRatio);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * 개별 이미지에 최적화된 렌더링 스타일 적용
579
+ */
580
+ private applyOptimizedImageStyle(
581
+ img: HTMLImageElement,
582
+ imageAspectRatio: number,
583
+ containerAspectRatio: number
584
+ ): void {
585
+ const ratio = imageAspectRatio / containerAspectRatio;
586
+
587
+ if (Math.abs(ratio - 1) < 0.1) {
588
+ // 비율이 거의 같으면 cover 사용
589
+ img.style.objectFit = 'cover';
590
+ img.style.objectPosition = 'center';
591
+ } else if (ratio > 1.3) {
592
+ // 이미지가 훨씬 가로형이면 contain으로 전체 보이기
593
+ img.style.objectFit = 'contain';
594
+ img.style.objectPosition = 'center';
595
+ img.style.backgroundColor = '#f0f0f0'; // 빈 공간 배경색
596
+ } else if (ratio < 0.7) {
597
+ // 이미지가 훨씬 세로형이면 cover로 채우기
598
+ img.style.objectFit = 'cover';
599
+ img.style.objectPosition = 'center';
600
+ } else {
601
+ // 적당한 차이면 스마트 cover
602
+ img.style.objectFit = 'cover';
603
+ img.style.objectPosition = 'center';
604
+ }
605
+
606
+ if (this._config?.debug) {
607
+ console.log(`🎨 Image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
301
608
  }
302
609
  }
303
610
 
@@ -314,6 +621,11 @@ export class AdsModule implements BaseModule {
314
621
  return;
315
622
  }
316
623
 
624
+ // 🆕 동적 크기 조정: 배너 광고의 경우 이미지 크기 기반으로 컨테이너 최적화
625
+ if (slot.adType === AdType.BANNER && adstageData.length > 0) {
626
+ await this.optimizeContainerForBannerAds(slot, adstageData);
627
+ }
628
+
317
629
  // 광고가 여러 개이거나 autoSlide 옵션이 있으면 슬라이더로 렌더링
318
630
  if (adstageData.length > 1 || (slot.config as any)?.autoSlide) {
319
631
  await this.renderAdSlider(slot, adstageData);
@@ -337,6 +649,38 @@ export class AdsModule implements BaseModule {
337
649
  }
338
650
  }
339
651
 
652
+ /**
653
+ * 배너 광고를 위한 컨테이너 최적화
654
+ */
655
+ private async optimizeContainerForBannerAds(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
656
+ try {
657
+ const container = document.getElementById(slot.containerId);
658
+ const adElement = document.getElementById(slot.id);
659
+
660
+ if (!container || !adElement) return;
661
+
662
+ // 현재 컨테이너 너비 확인
663
+ const containerWidth = container.getBoundingClientRect().width || 300;
664
+
665
+ // 최적 크기 계산
666
+ const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth, slot.adType);
667
+
668
+ // 컨테이너 크기 동적 조정
669
+ adElement.style.height = optimalSize.height;
670
+
671
+ // 슬롯 정보 업데이트
672
+ (slot as any).optimizedHeight = optimalSize.height;
673
+ (slot as any).aspectRatio = optimalSize.aspectRatio;
674
+
675
+ if (this._config?.debug) {
676
+ console.log(`🔧 Container optimized for ${advertisements.length} banner ads: ${optimalSize.height}`);
677
+ }
678
+
679
+ } catch (error) {
680
+ console.warn('Container optimization failed, using default size:', error);
681
+ }
682
+ }
683
+
340
684
  /**
341
685
  * 기본 viewability 추적 시작
342
686
  */
@@ -606,15 +950,21 @@ export class AdsModule implements BaseModule {
606
950
 
607
951
  let sliderElement: HTMLElement;
608
952
 
953
+ // 최적화된 슬라이더 옵션 준비
954
+ const optimizedSliderOptions = {
955
+ autoSlideInterval: ((slot.config as any)?.slideInterval || 5000) / 1000,
956
+ ...slot.config,
957
+ // 🆕 동적 크기 정보 전달
958
+ optimizedHeight: (slot as any).optimizedHeight,
959
+ aspectRatio: (slot as any).aspectRatio
960
+ };
961
+
609
962
  // 텍스트 광고는 TextTransitionManager 사용, 그 외는 CarouselSliderManager 사용
610
963
  if (slot.adType === AdType.TEXT) {
611
964
  sliderElement = TextTransitionManager.createTextTransitionContainer(
612
965
  slot,
613
966
  advertisements,
614
- {
615
- autoSlideInterval: ((slot.config as any)?.slideInterval || 5000) / 1000,
616
- ...slot.config
617
- },
967
+ optimizedSliderOptions,
618
968
  trackEventCallback
619
969
  );
620
970
 
@@ -625,15 +975,12 @@ export class AdsModule implements BaseModule {
625
975
  sliderElement = CarouselSliderManager.createSliderContainer(
626
976
  slot,
627
977
  advertisements,
628
- {
629
- autoSlideInterval: ((slot.config as any)?.slideInterval || 5000) / 1000,
630
- ...slot.config
631
- },
978
+ optimizedSliderOptions,
632
979
  trackEventCallback
633
980
  );
634
981
 
635
982
  if (this._config?.debug) {
636
- console.log(`🎠 Carousel slider created for ${slot.adType} slot: ${slot.id} with ${advertisements.length} ads`);
983
+ console.log(`🎠 Carousel slider created for ${slot.adType} slot: ${slot.id} with ${advertisements.length} ads (optimized: ${(slot as any).optimizedHeight || 'default'})`);
637
984
  }
638
985
  }
639
986
 
@@ -664,20 +1011,25 @@ export class AdsModule implements BaseModule {
664
1011
  // 기본 HTML 구조 생성
665
1012
  const adElement = document.createElement('div');
666
1013
  adElement.className = 'adstage-ad';
667
- adElement.style.width = typeof slot.width === 'string' ? slot.width : `${slot.width}px`;
668
- adElement.style.height = typeof slot.height === 'string' ? slot.height : `${slot.height}px`;
1014
+
1015
+ // 스마트한 크기 설정 - 최적화된 크기가 있으면 사용
1016
+ const optimizedHeight = (slot as any).optimizedHeight;
1017
+ const containerElement = container.parentElement || container;
1018
+
1019
+ if (optimizedHeight) {
1020
+ adElement.style.width = '100%';
1021
+ adElement.style.height = optimizedHeight;
1022
+ } else {
1023
+ const { width, height } = this.calculateAdSize(containerElement, slot.adType, slot.config || {});
1024
+ adElement.style.width = width;
1025
+ adElement.style.height = height;
1026
+ }
669
1027
 
670
1028
  // 광고 타입별 렌더링
671
1029
  switch (slot.adType) {
672
1030
  case AdType.BANNER:
673
1031
  if (ad.imageUrl) {
674
- const img = document.createElement('img');
675
- img.src = ad.imageUrl;
676
- img.alt = ad.title;
677
- img.style.width = '100%';
678
- img.style.height = '100%';
679
- img.style.objectFit = 'cover';
680
- adElement.appendChild(img);
1032
+ await this.renderOptimizedBannerImage(adElement, ad, slot);
681
1033
  }
682
1034
  break;
683
1035
 
@@ -718,6 +1070,58 @@ export class AdsModule implements BaseModule {
718
1070
  container.appendChild(adElement);
719
1071
  }
720
1072
 
1073
+ /**
1074
+ * 최적화된 배너 이미지 렌더링
1075
+ */
1076
+ private async renderOptimizedBannerImage(adElement: HTMLElement, ad: Advertisement, slot: AdSlot): Promise<void> {
1077
+ try {
1078
+ // 이미지 크기 정보 로드
1079
+ const imageDimensions = await this.loadImageDimensions(ad.imageUrl!);
1080
+ const imageAspectRatio = imageDimensions.width / imageDimensions.height;
1081
+
1082
+ // 컨테이너 비율 계산
1083
+ const containerAspectRatio = (slot as any).aspectRatio || 16/9;
1084
+
1085
+ // 이미지 요소 생성
1086
+ const img = document.createElement('img');
1087
+ img.src = ad.imageUrl!;
1088
+ img.alt = ad.title;
1089
+ img.style.width = '100%';
1090
+ img.style.height = '100%';
1091
+
1092
+ // 🎨 최적화된 스타일 적용
1093
+ this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
1094
+
1095
+ // 이미지 로드 완료 처리
1096
+ img.onload = () => {
1097
+ if (this._config?.debug) {
1098
+ console.log(`🖼️ Optimized banner image loaded: ${imageDimensions.width}x${imageDimensions.height} (ratio: ${imageAspectRatio.toFixed(2)})`);
1099
+ }
1100
+ };
1101
+
1102
+ // 에러 처리
1103
+ img.onerror = () => {
1104
+ console.warn(`Failed to load banner image: ${ad.imageUrl}`);
1105
+ // 폴백 텍스트 표시
1106
+ adElement.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; background: #f0f0f0; color: #666;">${ad.title}</div>`;
1107
+ };
1108
+
1109
+ adElement.appendChild(img);
1110
+
1111
+ } catch (error) {
1112
+ console.warn('Failed to optimize banner image, using fallback:', error);
1113
+
1114
+ // 기본 이미지 렌더링 (폴백)
1115
+ const img = document.createElement('img');
1116
+ img.src = ad.imageUrl!;
1117
+ img.alt = ad.title;
1118
+ img.style.width = '100%';
1119
+ img.style.height = '100%';
1120
+ img.style.objectFit = 'cover';
1121
+ adElement.appendChild(img);
1122
+ }
1123
+ }
1124
+
721
1125
  /**
722
1126
  * 광고 슬롯 새로고침
723
1127
  */