1398 lines
46 KiB
JavaScript
1398 lines
46 KiB
JavaScript
/**
|
|
* 비즈트렌드 달력 페이지 전용 스크립트
|
|
* ===================================
|
|
* biztrend.html (성공예감&별책부록)에서 사용합니다.
|
|
* - 연·월 선택 날짜피커 (휠 UI)
|
|
* - 현재 달 기준 동적 달력 그리드 생성
|
|
* - 스크롤 바닥 감지 시 다음 달 자동 추가 (무한 스크롤)
|
|
*/
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// ------------------------------
|
|
// 설정 상수
|
|
// ------------------------------
|
|
var YEAR_START = 2023;
|
|
var YEAR_END = 2026;
|
|
/** 그리드 최상단에서 살짝 내린 시작 위치(이전 달 프리로드를 위해 scrollTop>0 유지) */
|
|
var GRID_SCROLL_TOP_OFFSET = 54;
|
|
var ITEM_HEIGHT = 36; // (폴백) 휠 한 항목 높이(px)
|
|
/** _biztrend.scss 의 mq(tablet-wide, max) 와 동일 (가로 스크롤 달력 구간) */
|
|
var MOBILE_MQ = "(max-width: 1024px)";
|
|
/** 모바일: 맨 왼쪽에서 오른쪽으로 당겨 이전 달 로드 — 최소 이동(px) */
|
|
var LEFT_EDGE_TOUCH_PULL_PX = 48;
|
|
|
|
// ------------------------------
|
|
// 무한 스크롤 상태
|
|
// ------------------------------
|
|
var loadedMonths = []; // [{year, month}]
|
|
var scrollObserver = null; // 하단 무한 스크롤 (IntersectionObserver)
|
|
var labelObserver = null; // 라벨 업데이트
|
|
var articleData = {}; // 날짜별 기사 데이터
|
|
var videoModal = null; // 공통 비디오 모달 (VideoModalBase)
|
|
var topLoadReady = false; // 상단 스크롤 활성화 플래그
|
|
/** 날짜피커 확정 직후, 구분선 IntersectionObserver가 라벨을 덮어쓰지 않도록 잠시 무시 */
|
|
var labelObserverQuietUntil = 0;
|
|
var lastLabelYear = null;
|
|
var lastLabelMonth = null;
|
|
|
|
function isBiztrendMobileLayout() {
|
|
return typeof window.matchMedia === "function" && window.matchMedia(MOBILE_MQ).matches;
|
|
}
|
|
|
|
/**
|
|
* PC: 세로 스크롤 최상단 / MO: 가로 스크롤 맨 왼쪽에서 이전 달 prepend
|
|
*/
|
|
function attemptPrependPreviousMonth() {
|
|
if (!topLoadReady || loadedMonths.length === 0) return;
|
|
var first = loadedMonths[0];
|
|
var prev = getPrevMonth(first.year, first.month);
|
|
if (prev.year < YEAR_START) return;
|
|
|
|
var lenBefore = loadedMonths.length;
|
|
topLoadReady = false;
|
|
prependMonth(prev.year, prev.month);
|
|
if (loadedMonths.length === lenBefore) {
|
|
topLoadReady = true;
|
|
}
|
|
}
|
|
|
|
// ------------------------------
|
|
// 날짜 값 읽기/쓰기
|
|
// ------------------------------
|
|
|
|
function getDateValue() {
|
|
var input = document.querySelector(".biztrend .date-select-value");
|
|
if (!input) return { year: new Date().getFullYear(), month: new Date().getMonth() + 1 };
|
|
|
|
var match = (input.value || "").match(/^(\d{4})-(\d{2})$/);
|
|
if (match) {
|
|
return {
|
|
year: parseInt(match[1], 10),
|
|
month: parseInt(match[2], 10),
|
|
};
|
|
}
|
|
|
|
var now = new Date();
|
|
return {
|
|
year: now.getFullYear(),
|
|
month: now.getMonth() + 1,
|
|
};
|
|
}
|
|
|
|
function setDateValue(year, month) {
|
|
var input = document.querySelector(".biztrend .date-select-value");
|
|
var label = document.querySelector(".biztrend .date-select-label");
|
|
|
|
if (input) {
|
|
input.value = year + "-" + String(month).padStart(2, "0");
|
|
}
|
|
if (label) {
|
|
var yearEl = label.querySelector(".date-select-year");
|
|
var monthEl = label.querySelector(".date-select-month");
|
|
if (yearEl) yearEl.textContent = year;
|
|
if (monthEl) monthEl.textContent = month;
|
|
}
|
|
|
|
lastLabelYear = year;
|
|
lastLabelMonth = month;
|
|
}
|
|
|
|
/** 달력·그리드에서 허용하는 최대 연·월(실시간 오늘 기준) */
|
|
function getMaxCalendarYearMonth() {
|
|
var now = new Date();
|
|
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
|
}
|
|
|
|
/** (year, month)가 오늘 이후면 오늘 연·월로 제한 */
|
|
function clampYearMonthToNow(year, month) {
|
|
var max = getMaxCalendarYearMonth();
|
|
if (year > max.year || (year === max.year && month > max.month)) {
|
|
return { year: max.year, month: max.month };
|
|
}
|
|
return { year: year, month: month };
|
|
}
|
|
|
|
/** 날짜피커 연 휠: YEAR_START ~ min(YEAR_END, 올해) */
|
|
function buildYearWheelItems() {
|
|
var max = getMaxCalendarYearMonth();
|
|
var hi = Math.min(YEAR_END, max.year);
|
|
var years = [];
|
|
for (var y = YEAR_START; y <= hi; y++) {
|
|
years.push({ value: y, label: y + "년" });
|
|
}
|
|
if (!years.length) {
|
|
var y = Math.min(YEAR_END, Math.max(YEAR_START, max.year));
|
|
years.push({ value: y, label: y + "년" });
|
|
}
|
|
return years;
|
|
}
|
|
|
|
/** 날짜피커 월 휠: 과거 연은 1~12월, 올해는 1~이번 달만 */
|
|
function buildMonthWheelItemsForYear(year) {
|
|
var max = getMaxCalendarYearMonth();
|
|
var endM = 12;
|
|
if (year > max.year) {
|
|
endM = 0;
|
|
} else if (year === max.year) {
|
|
endM = max.month;
|
|
}
|
|
var months = [];
|
|
for (var m = 1; m <= endM; m++) {
|
|
months.push({ value: m, label: m + "월" });
|
|
}
|
|
if (!months.length) {
|
|
months.push({ value: 1, label: "1월" });
|
|
}
|
|
return months;
|
|
}
|
|
|
|
// ------------------------------
|
|
// 달력 그리드 동적 생성
|
|
// ------------------------------
|
|
|
|
/**
|
|
* 해당 월의 총 일수를 반환합니다.
|
|
*/
|
|
function getDaysInMonth(year, month) {
|
|
return new Date(year, month, 0).getDate();
|
|
}
|
|
|
|
/**
|
|
* 월의 날짜를 월~토 6열 기준 행 배열로 반환합니다.
|
|
* 일요일은 제외하며, 빈 셀은 null로 채웁니다.
|
|
* @returns {Array<Array<number|null>>}
|
|
*/
|
|
function buildMonthRows(year, month) {
|
|
var daysInMonth = getDaysInMonth(year, month);
|
|
var rows = [];
|
|
var currentRow = [];
|
|
|
|
for (var d = 1; d <= daysInMonth; d++) {
|
|
var jsDay = new Date(year, month - 1, d).getDay(); // 0=일, 1=월, ..., 6=토
|
|
|
|
if (jsDay === 0) continue; // 일요일 제외
|
|
|
|
// 월요일이면 새 행 시작 (이전 행이 있으면 6칸 채우고 저장)
|
|
if (jsDay === 1 && currentRow.length > 0) {
|
|
while (currentRow.length < 6) currentRow.push(null);
|
|
rows.push(currentRow);
|
|
currentRow = [];
|
|
}
|
|
|
|
// 1일이 월요일이 아닐 때 앞 빈 칸 추가
|
|
if (d === 1 && jsDay !== 1) {
|
|
var emptyCount = jsDay - 1; // 월=0, 화=1, ..., 토=5
|
|
for (var i = 0; i < emptyCount; i++) {
|
|
currentRow.push(null);
|
|
}
|
|
}
|
|
|
|
currentRow.push(d);
|
|
|
|
// 토요일이면 행 완료
|
|
if (jsDay === 6) {
|
|
rows.push(currentRow);
|
|
currentRow = [];
|
|
}
|
|
}
|
|
|
|
// 마지막 행 처리
|
|
if (currentRow.length > 0) {
|
|
while (currentRow.length < 6) currentRow.push(null);
|
|
rows.push(currentRow);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
// ------------------------------
|
|
// 기사 데이터 로드
|
|
// ------------------------------
|
|
|
|
/** HTML의 #biztrend-data JSON을 읽어 articleData에 저장 */
|
|
function loadArticleData() {
|
|
var el = document.getElementById("biztrend-data");
|
|
if (!el) return;
|
|
try { articleData = JSON.parse(el.textContent); } catch (e) { articleData = {}; }
|
|
}
|
|
|
|
function syncLikedStateByContentId(contentId, isBookmarked) {
|
|
var cid = String(contentId || "");
|
|
if (!cid) return;
|
|
|
|
Object.keys(articleData).forEach(function (dateKey) {
|
|
var items = articleData[dateKey];
|
|
if (!Array.isArray(items)) return;
|
|
items.forEach(function (item) {
|
|
if (String(item && item.contentId || "") !== cid) return;
|
|
item.liked = !!isBookmarked;
|
|
});
|
|
});
|
|
}
|
|
|
|
function syncLikeButtonsByContentId(contentId, isBookmarked) {
|
|
var cid = String(contentId || "");
|
|
if (!cid) return;
|
|
|
|
var selector = '.article-card-content[data-content-id="' + cssEscapeValue(cid) + '"] .article-card-like';
|
|
var buttons = document.querySelectorAll(selector);
|
|
buttons.forEach(function (btn) {
|
|
btn.classList.toggle("is-active", !!isBookmarked);
|
|
btn.setAttribute("aria-label", isBookmarked ? "좋아요 취소" : "좋아요");
|
|
});
|
|
|
|
// data-content-id가 없는 구버전 DOM 대비 fallback
|
|
if (buttons.length) return;
|
|
Object.keys(articleData).forEach(function (dateKey) {
|
|
var items = articleData[dateKey];
|
|
if (!Array.isArray(items)) return;
|
|
items.forEach(function (item, idx) {
|
|
if (String(item && item.contentId || "") !== cid) return;
|
|
var contentEl = document.querySelector(
|
|
'.article-card-content[data-date-key="' + cssEscapeValue(dateKey) + '"][data-item-idx="' + idx + '"]'
|
|
);
|
|
if (!contentEl) return;
|
|
var btn = contentEl.querySelector('.article-card-like');
|
|
if (!btn) return;
|
|
btn.classList.toggle("is-active", !!isBookmarked);
|
|
btn.setAttribute("aria-label", isBookmarked ? "좋아요 취소" : "좋아요");
|
|
});
|
|
});
|
|
}
|
|
|
|
function cssEscapeValue(value) {
|
|
if (window.CSS && typeof window.CSS.escape === "function") {
|
|
return window.CSS.escape(String(value));
|
|
}
|
|
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
/** XSS 방지용 HTML 이스케이프 */
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
/**
|
|
* 기사 콘텐츠 블록 HTML을 반환합니다.
|
|
* @param {Object} item - articleData의 항목 하나
|
|
* @param {string} dateKey - 날짜 키 (예: "2026-02-01")
|
|
* @param {number} idx - 해당 날짜 내 인덱스
|
|
*/
|
|
function createContentHTML(item, dateKey, idx) {
|
|
var contentId = String(item && (item.contentId || item.content_id) || "");
|
|
|
|
// 제목 + 게이지 + 배지
|
|
var titleBoxHTML =
|
|
'<div class="article-card-title-box">' +
|
|
'<h4 class="article-card-title">' + escapeHtml(item.title) + "</h4>";
|
|
|
|
if (item.badge) {
|
|
titleBoxHTML += '<span class="article-card-badge">' + escapeHtml(item.badge) + "</span>";
|
|
}
|
|
if (item.gauge != null) {
|
|
titleBoxHTML +=
|
|
'<div class="gauge-bar"><div class="gauge-fill" style="width:' + item.gauge + '%"></div></div>';
|
|
}
|
|
titleBoxHTML += "</div>";
|
|
|
|
// 리스트
|
|
var listHTML = "";
|
|
if (item.list && item.list.length) {
|
|
listHTML = '<ul class="article-card-list">';
|
|
item.list.forEach(function (li) {
|
|
listHTML += "<li>" + escapeHtml(li) + "</li>";
|
|
});
|
|
listHTML += "</ul>";
|
|
}
|
|
|
|
// 이미지 여부에 따라 내부 구조 분기
|
|
var bodyHTML = item.image
|
|
? '<div class="article-card-image">' +
|
|
'<div class="article-card-image-placeholder" aria-hidden="true">' +
|
|
'<img src="' + escapeHtml(item.image) + '" alt="' + escapeHtml(item.title) + '">' +
|
|
"</div>" +
|
|
listHTML +
|
|
"</div>"
|
|
: listHTML;
|
|
|
|
var likedClass = item.liked ? " is-active" : "";
|
|
var likedLabel = item.liked ? "좋아요 취소" : "좋아요";
|
|
|
|
var dkAttr = dateKey ? ' data-date-key="' + escapeHtml(dateKey) + '" data-item-idx="' + idx + '"' : "";
|
|
|
|
var contentIdAttr = contentId
|
|
? ' data-content-id="' + escapeHtml(contentId) + '" data-content_id="' + escapeHtml(contentId) + '"'
|
|
: "";
|
|
|
|
return (
|
|
'<div class="article-card-content"' + dkAttr + contentIdAttr + '>' +
|
|
'<a href="#" class="article-card-link" data-video-id="' + escapeHtml(item.videoId || "") + '">' +
|
|
titleBoxHTML +
|
|
bodyHTML +
|
|
"</a>" +
|
|
'<button type="button" class="article-card-like' + likedClass + '" aria-label="' + likedLabel + '"></button>' +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 날짜 카드 HTML을 반환합니다.
|
|
*/
|
|
function createCardHTML(day, year, month) {
|
|
var today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
var cardDate = new Date(year, month - 1, day);
|
|
cardDate.setHours(0, 0, 0, 0);
|
|
|
|
var jsDay = new Date(year, month - 1, day).getDay();
|
|
var isWeekend = jsDay === 6; // 토요일
|
|
var isToday = cardDate.getTime() === today.getTime();
|
|
var isFuture = cardDate > today;
|
|
|
|
var classes = ["article-card"];
|
|
if (isWeekend) classes.push("weekend");
|
|
if (isToday) classes.push("today");
|
|
if (isFuture) classes.push("is-future");
|
|
|
|
// 날짜 키로 기사 데이터 조회 (예: "2026-02-01")
|
|
var dateKey =
|
|
year + "-" +
|
|
String(month).padStart(2, "0") + "-" +
|
|
String(day).padStart(2, "0");
|
|
var items = articleData[dateKey] || [];
|
|
|
|
var contentsHTML = items.map(function (item, idx) {
|
|
return createContentHTML(item, dateKey, idx);
|
|
}).join("");
|
|
|
|
return (
|
|
'<article class="' +
|
|
classes.join(" ") +
|
|
'" data-date-key="' +
|
|
escapeHtml(dateKey) +
|
|
'">' +
|
|
'<span class="article-card-num" aria-hidden="true">' + day + "</span>" +
|
|
contentsHTML +
|
|
"</article>"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 빈 셀 HTML을 반환합니다.
|
|
*/
|
|
function createEmptyCardHTML() {
|
|
return '<article class="article-card is-empty" aria-hidden="true"></article>';
|
|
}
|
|
|
|
/**
|
|
* 월 구분선 HTML을 반환합니다.
|
|
*/
|
|
function createMonthSeparatorHTML(year, month) {
|
|
return (
|
|
'<div class="article-grid-month-sep" data-year="' + year + '" data-month="' + month + '">' +
|
|
'<span class="article-grid-month-label">' + year + "년 " + month + "월</span>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 특정 월의 모든 행 HTML을 반환합니다.
|
|
*/
|
|
function buildMonthHTML(year, month, addSeparator) {
|
|
var rows = buildMonthRows(year, month);
|
|
var html = addSeparator ? createMonthSeparatorHTML(year, month) : "";
|
|
|
|
rows.forEach(function (row) {
|
|
html += '<div class="article-grid-row">';
|
|
row.forEach(function (day) {
|
|
if (day === null) {
|
|
html += createEmptyCardHTML();
|
|
} else {
|
|
html += createCardHTML(day, year, month);
|
|
}
|
|
});
|
|
html += "</div>";
|
|
});
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* 다음 연·월을 반환합니다.
|
|
*/
|
|
function getNextMonth(year, month) {
|
|
if (month === 12) return { year: year + 1, month: 1 };
|
|
return { year: year, month: month + 1 };
|
|
}
|
|
|
|
/**
|
|
* 이전 연·월을 반환합니다.
|
|
*/
|
|
function getPrevMonth(year, month) {
|
|
if (month === 1) return { year: year - 1, month: 12 };
|
|
return { year: year, month: month - 1 };
|
|
}
|
|
|
|
/**
|
|
* 그리드 상단에 특정 월을 추가합니다. (스크롤 위치 유지)
|
|
*/
|
|
function prependMonth(year, month) {
|
|
var gridBody = document.querySelector(".biztrend .article-grid-body");
|
|
if (!gridBody) return;
|
|
|
|
// 이미 로드된 월 체크
|
|
var alreadyLoaded = loadedMonths.some(function (m) {
|
|
return m.year === year && m.month === month;
|
|
});
|
|
if (alreadyLoaded) return;
|
|
|
|
// YEAR_START 이전은 추가하지 않음
|
|
if (year < YEAR_START || (year === YEAR_START && month < 1)) return;
|
|
|
|
loadedMonths.unshift({ year: year, month: month });
|
|
|
|
var grid = document.querySelector(".biztrend .article-grid");
|
|
// 새 달만 삽입 — appendMonth가 첫 달도 구분선을 포함하도록 맞춤
|
|
var html = buildMonthHTML(year, month, true);
|
|
|
|
if (isBiztrendMobileLayout()) {
|
|
// MO: 가로 스크롤은 .article-grid-body — 삽입 폭만큼 scrollLeft 보정
|
|
var prevScrollLeft = gridBody.scrollLeft;
|
|
var prevScrollWidth = gridBody.scrollWidth;
|
|
gridBody.insertAdjacentHTML("afterbegin", html);
|
|
var addedWidth = gridBody.scrollWidth - prevScrollWidth;
|
|
gridBody.scrollLeft = prevScrollLeft + addedWidth;
|
|
} else {
|
|
var prevScrollTop = grid ? grid.scrollTop : 0;
|
|
var prevScrollHeight = grid ? grid.scrollHeight : 0;
|
|
gridBody.insertAdjacentHTML("afterbegin", html);
|
|
// PC: 세로 스크롤은 .article-grid — 삽입 높이만큼 scrollTop 보정
|
|
if (grid) {
|
|
var addedHeight = grid.scrollHeight - prevScrollHeight;
|
|
grid.scrollTop = prevScrollTop + addedHeight;
|
|
}
|
|
}
|
|
|
|
// 새 구분선 라벨 옵저버 등록
|
|
observeMonthSeparators();
|
|
|
|
// 상단(또는 MO에서 가로 맨 왼쪽) 연속 prepend 방지: 잠시 잠금 후 해제
|
|
topLoadReady = false;
|
|
var newSep = gridBody.querySelector(
|
|
'.article-grid-month-sep[data-year="' + year + '"][data-month="' + month + '"]'
|
|
);
|
|
|
|
if (isBiztrendMobileLayout()) {
|
|
// MO: scrollLeft 보정으로 새 월·구분선이 가로 스크롤 ‘왼쪽 바깥’에 있어
|
|
// root=null 교차 관찰이 끝나지 않고 topLoadReady가 false에 고정되는 경우가 많음.
|
|
// 레이아웃 반영 뒤 잠금 해제.
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
topLoadReady = true;
|
|
applyBiztrendMonthLabel();
|
|
});
|
|
});
|
|
} else if (newSep) {
|
|
var centerObserver = new IntersectionObserver(
|
|
function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (entry.isIntersecting) {
|
|
topLoadReady = true;
|
|
centerObserver.disconnect();
|
|
}
|
|
});
|
|
},
|
|
{ root: null, rootMargin: "0px", threshold: 0 }
|
|
);
|
|
centerObserver.observe(newSep);
|
|
} else {
|
|
topLoadReady = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 그리드에 특정 월을 추가합니다.
|
|
*/
|
|
function appendMonth(year, month) {
|
|
var gridBody = document.querySelector(".biztrend .article-grid-body");
|
|
if (!gridBody) return;
|
|
|
|
var capNow = clampYearMonthToNow(year, month);
|
|
if (capNow.year !== year || capNow.month !== month) return;
|
|
|
|
// 이미 로드된 월 체크
|
|
var alreadyLoaded = loadedMonths.some(function (m) {
|
|
return m.year === year && m.month === month;
|
|
});
|
|
if (alreadyLoaded) return;
|
|
|
|
loadedMonths.push({ year: year, month: month });
|
|
|
|
// 첫 달도 구분선 포함 — labelObserver가 스크롤 복귀 시 첫 달을 감지할 수 있게 함
|
|
var html = buildMonthHTML(year, month, true);
|
|
|
|
// 센티넬 제거 → 콘텐츠 추가 → 센티넬 재추가
|
|
var sentinel = document.getElementById("article-grid-sentinel");
|
|
if (sentinel) sentinel.remove();
|
|
|
|
gridBody.insertAdjacentHTML("beforeend", html);
|
|
gridBody.insertAdjacentHTML(
|
|
"beforeend",
|
|
'<div id="article-grid-sentinel" class="article-grid-sentinel"></div>'
|
|
);
|
|
|
|
// 새로 추가된 센티넬 관찰
|
|
if (scrollObserver) {
|
|
var newSentinel = document.getElementById("article-grid-sentinel");
|
|
if (newSentinel) scrollObserver.observe(newSentinel);
|
|
}
|
|
|
|
// 새로 추가된 월 구분선을 라벨 옵저버에 등록
|
|
observeMonthSeparators();
|
|
}
|
|
|
|
/**
|
|
* 선택한 연·월의 구분선이 그리드 뷰 상단 근처에 오도록 스크롤합니다.
|
|
* (날짜피커로 점프했을 때 이전 scrollTop이 남아 달 상단이 안 보이는 문제 방지)
|
|
*/
|
|
function scrollGridToMonthSeparator(year, month) {
|
|
var grid = document.querySelector(".biztrend .article-grid");
|
|
if (!grid) return;
|
|
var sep = grid.querySelector(
|
|
'.article-grid-month-sep[data-year="' + year + '"][data-month="' + month + '"]'
|
|
);
|
|
if (!sep) return;
|
|
var gridRect = grid.getBoundingClientRect();
|
|
var sepRect = sep.getBoundingClientRect();
|
|
var nextTop =
|
|
grid.scrollTop + (sepRect.top - gridRect.top) - GRID_SCROLL_TOP_OFFSET;
|
|
// scrollTop===0이면 상단 무한 스크롤이 바로 동작할 수 있어, 기존과 같이 최소 오프셋 유지
|
|
grid.scrollTop = Math.max(GRID_SCROLL_TOP_OFFSET, Math.max(0, nextTop));
|
|
}
|
|
|
|
/**
|
|
* 그리드를 초기화하고 지정 월부터 렌더링합니다.
|
|
*/
|
|
function renderCalendar(year, month) {
|
|
var gridBody = document.querySelector(".biztrend .article-grid-body");
|
|
if (!gridBody) return;
|
|
|
|
// 휠/데이터 범위(YEAR_START~YEAR_END) 보정 후, 오늘 이후 연·월은 생성하지 않음
|
|
month = Math.max(1, Math.min(12, month));
|
|
if (year < YEAR_START) {
|
|
year = YEAR_START;
|
|
month = 1;
|
|
} else if (year > YEAR_END) {
|
|
year = YEAR_END;
|
|
month = 12;
|
|
}
|
|
var cap = clampYearMonthToNow(year, month);
|
|
year = cap.year;
|
|
month = cap.month;
|
|
setDateValue(year, month);
|
|
// 기존 옵저버 해제
|
|
if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
|
|
|
|
// 상단 스크롤 리스너 정리 (중복 바인딩 방지)
|
|
if (initTopScroll._handler && initTopScroll._target) {
|
|
initTopScroll._target.removeEventListener("scroll", initTopScroll._handler);
|
|
initTopScroll._handler = null;
|
|
initTopScroll._target = null;
|
|
}
|
|
|
|
if (typeof initLeftEdgePrevMonth._cleanup === "function") {
|
|
initLeftEdgePrevMonth._cleanup();
|
|
initLeftEdgePrevMonth._cleanup = null;
|
|
}
|
|
|
|
// 라벨 옵저버 초기화
|
|
initLabelObserver();
|
|
|
|
// 상태 초기화
|
|
loadedMonths = [];
|
|
topLoadReady = false;
|
|
gridBody.innerHTML = "";
|
|
|
|
// 첫 달 렌더링
|
|
appendMonth(year, month);
|
|
|
|
// 하단·상단 무한 스크롤 초기화
|
|
initInfiniteScroll();
|
|
initTopScroll();
|
|
initLeftEdgePrevMonth();
|
|
|
|
// 레이아웃 반영 후: 항상 해당 달 상단(월 구분선)이 보이도록 스크롤 초기화
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
scrollGridToMonthSeparator(year, month);
|
|
applyBiztrendMonthLabel();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ------------------------------
|
|
// 월 구분선 진입 시 라벨 업데이트 (IntersectionObserver)
|
|
// ------------------------------
|
|
|
|
/**
|
|
* PC/MO 공통: 스크롤 루트 안에서 가시 영역에 걸친 월 구분선 중 ‘앵커’ 한 개를 고릅니다.
|
|
* - PC: root = .article-grid, top 기준
|
|
* - MO: root = .article-grid-body, left 기준 (구분선은 MO에서 1px·시각 비표시이나 레이아웃에 존재)
|
|
*/
|
|
function pickActiveMonthSeparatorFromLayout() {
|
|
var grid = document.querySelector(".biztrend .article-grid");
|
|
var gridBody = document.querySelector(".biztrend .article-grid-body");
|
|
var labelRoot =
|
|
isBiztrendMobileLayout() && gridBody ? gridBody : grid;
|
|
if (!labelRoot) return null;
|
|
|
|
var mobile = isBiztrendMobileLayout();
|
|
var rootRect = labelRoot.getBoundingClientRect();
|
|
var seps = document.querySelectorAll(".biztrend .article-grid-month-sep");
|
|
var bestSep = null;
|
|
var bestDist = Infinity;
|
|
|
|
seps.forEach(function (sep) {
|
|
var r = sep.getBoundingClientRect();
|
|
if (mobile) {
|
|
if (r.right <= rootRect.left || r.left >= rootRect.right) return;
|
|
var dist = r.left - rootRect.left;
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestSep = sep;
|
|
}
|
|
} else {
|
|
var distTop = r.top - rootRect.top;
|
|
if (distTop >= -2 && distTop < bestDist) {
|
|
bestDist = distTop;
|
|
bestSep = sep;
|
|
}
|
|
}
|
|
});
|
|
|
|
return bestSep;
|
|
}
|
|
|
|
/**
|
|
* 모바일: 가로 스크롤 영역에 보이는 날짜 카드 중, 달력상 가장 늦은 날짜의 연·월로 라벨을 맞춤.
|
|
* @returns {boolean} true면 처리 완료(또는 이미 동일) — 구분선 폴백 불필요
|
|
*/
|
|
function applyBiztrendMonthLabelFromLastVisibleDate() {
|
|
var gridBody = document.querySelector(".biztrend .article-grid-body");
|
|
if (!gridBody) return false;
|
|
|
|
var br = gridBody.getBoundingClientRect();
|
|
var cards = gridBody.querySelectorAll(".article-card[data-date-key]");
|
|
var bestUtc = -Infinity;
|
|
var bestY = null;
|
|
var bestM = null;
|
|
|
|
for (var i = 0; i < cards.length; i++) {
|
|
var card = cards[i];
|
|
if (card.classList.contains("is-empty")) continue;
|
|
var dk = card.getAttribute("data-date-key");
|
|
if (!dk) continue;
|
|
var p = dk.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
if (!p) continue;
|
|
|
|
var r = card.getBoundingClientRect();
|
|
if (r.right <= br.left + 0.5 || r.left >= br.right - 0.5) continue;
|
|
if (r.bottom <= br.top + 0.5 || r.top >= br.bottom - 0.5) continue;
|
|
|
|
var y = parseInt(p[1], 10);
|
|
var mo = parseInt(p[2], 10);
|
|
var d = parseInt(p[3], 10);
|
|
var utc = Date.UTC(y, mo - 1, d);
|
|
if (utc > bestUtc) {
|
|
bestUtc = utc;
|
|
bestY = y;
|
|
bestM = mo;
|
|
}
|
|
}
|
|
|
|
if (bestY == null || bestM == null) return false;
|
|
if (lastLabelYear === bestY && lastLabelMonth === bestM) return true;
|
|
setDateValue(bestY, bestM);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 연·월 표시 동기화.
|
|
* - MO: 가시 영역 안에서 가장 늦은 날짜 카드 기준 → 없으면 구분선 폴백
|
|
* - PC: 구분선만
|
|
*/
|
|
function applyBiztrendMonthLabel() {
|
|
if (Date.now() < labelObserverQuietUntil) return;
|
|
if (isBiztrendMobileLayout() && applyBiztrendMonthLabelFromLastVisibleDate()) {
|
|
return;
|
|
}
|
|
var chosen = pickActiveMonthSeparatorFromLayout();
|
|
if (!chosen || !chosen.dataset) return;
|
|
var y = parseInt(chosen.dataset.year, 10);
|
|
var m = parseInt(chosen.dataset.month, 10);
|
|
if (isNaN(y) || isNaN(m)) return;
|
|
if (lastLabelYear === y && lastLabelMonth === m) return;
|
|
setDateValue(y, m);
|
|
}
|
|
|
|
/**
|
|
* article-grid-month-sep가 스크롤 영역에 들어올 때 라벨을 갱신합니다.
|
|
* PC: 구분선 + IO / MO: 가시 카드 최종일 기준(위 applyBiztrendMonthLabel)
|
|
*/
|
|
function initLabelObserver() {
|
|
if (labelObserver) labelObserver.disconnect();
|
|
|
|
var grid = document.querySelector(".biztrend .article-grid");
|
|
var gridBody = document.querySelector(".biztrend .article-grid-body");
|
|
var labelRoot =
|
|
isBiztrendMobileLayout() && gridBody ? gridBody : grid;
|
|
|
|
labelObserver = new IntersectionObserver(
|
|
function (entries) {
|
|
if (Date.now() < labelObserverQuietUntil) return;
|
|
|
|
if (isBiztrendMobileLayout()) {
|
|
applyBiztrendMonthLabel();
|
|
return;
|
|
}
|
|
|
|
// PC: 구분선 entry만 사용
|
|
var candidates = [];
|
|
entries.forEach(function (entry) {
|
|
if (!entry.isIntersecting) return;
|
|
var target = entry.target;
|
|
if (!target || !target.dataset) return;
|
|
if (!target.dataset.year || !target.dataset.month) return;
|
|
candidates.push(entry);
|
|
});
|
|
|
|
var chosen = null;
|
|
if (candidates.length) {
|
|
candidates.sort(function (a, b) {
|
|
return a.boundingClientRect.top - b.boundingClientRect.top;
|
|
});
|
|
chosen = candidates[0].target;
|
|
} else {
|
|
chosen = pickActiveMonthSeparatorFromLayout();
|
|
}
|
|
|
|
if (!chosen || !chosen.dataset) return;
|
|
var year = parseInt(chosen.dataset.year, 10);
|
|
var month = parseInt(chosen.dataset.month, 10);
|
|
if (isNaN(year) || isNaN(month)) return;
|
|
|
|
if (lastLabelYear === year && lastLabelMonth === month) return;
|
|
setDateValue(year, month);
|
|
},
|
|
{
|
|
root: labelRoot || null,
|
|
rootMargin: "0px 0px 0px 0px",
|
|
threshold: 0,
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 새로 추가된 월 구분선을 라벨 옵저버에 등록합니다.
|
|
*/
|
|
function observeMonthSeparators() {
|
|
if (!labelObserver) return;
|
|
document.querySelectorAll(".biztrend .article-grid-month-sep").forEach(function (sep) {
|
|
labelObserver.observe(sep);
|
|
});
|
|
}
|
|
|
|
// ------------------------------
|
|
// 무한 스크롤 (IntersectionObserver)
|
|
// ------------------------------
|
|
|
|
/** 하단: 다음 달 추가 */
|
|
function initInfiniteScroll() {
|
|
var sentinel = document.getElementById("article-grid-sentinel");
|
|
if (!sentinel) return;
|
|
|
|
scrollObserver = new IntersectionObserver(
|
|
function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (entry.isIntersecting && loadedMonths.length > 0) {
|
|
var last = loadedMonths[loadedMonths.length - 1];
|
|
var next = getNextMonth(last.year, last.month);
|
|
// 현재 달 이후는 로드하지 않음
|
|
var now = new Date();
|
|
var nowYear = now.getFullYear();
|
|
var nowMonth = now.getMonth() + 1;
|
|
if (next.year > nowYear || (next.year === nowYear && next.month > nowMonth)) return;
|
|
appendMonth(next.year, next.month);
|
|
}
|
|
});
|
|
},
|
|
{ root: null, rootMargin: "200px", threshold: 0 }
|
|
);
|
|
|
|
scrollObserver.observe(sentinel);
|
|
}
|
|
|
|
/** 상단: .article-grid 스크롤 최상단(scrollTop === 0)에서 이전 달 추가 (PC 전용) */
|
|
function initTopScroll() {
|
|
if (!document.querySelector(".biztrend .article-grid-body")) return;
|
|
|
|
var grid = document.querySelector(".biztrend .article-grid");
|
|
if (!grid) return;
|
|
|
|
topLoadReady = false;
|
|
|
|
if (initTopScroll._handler && initTopScroll._target) {
|
|
initTopScroll._target.removeEventListener("scroll", initTopScroll._handler);
|
|
}
|
|
|
|
initTopScroll._target = grid;
|
|
initTopScroll._handler = function () {
|
|
if (isBiztrendMobileLayout()) return;
|
|
if (!topLoadReady || loadedMonths.length === 0) return;
|
|
if (grid.scrollTop > 0) return;
|
|
attemptPrependPreviousMonth();
|
|
};
|
|
|
|
grid.addEventListener("scroll", initTopScroll._handler, { passive: true });
|
|
|
|
setTimeout(function () { topLoadReady = true; }, 300);
|
|
}
|
|
|
|
/**
|
|
* 모바일: .article-grid-body 가로 스크롤이 맨 왼쪽일 때 이전 달 로드
|
|
* - scrollLeft 가 0으로 돌아올 때
|
|
* - 맨 왼쪽에서 왼쪽으로 휠(또는 Shift+세로 휠)
|
|
* - 맨 왼쪽에서 오른쪽으로 당기는 터치(이전 주간 열 방향)
|
|
*/
|
|
function initLeftEdgePrevMonth() {
|
|
var gridBody = document.querySelector(".biztrend .article-grid-body");
|
|
if (!gridBody) return;
|
|
|
|
if (typeof initLeftEdgePrevMonth._cleanup === "function") {
|
|
initLeftEdgePrevMonth._cleanup();
|
|
}
|
|
|
|
var touchStartX = 0;
|
|
var touchPulled = false;
|
|
|
|
function onBodyScroll() {
|
|
if (!isBiztrendMobileLayout()) return;
|
|
// PC와 동일하게 월은 구분선(data)만 사용. IO가 밀릴 때 대비해 스크롤에도 동일 피커 적용.
|
|
applyBiztrendMonthLabel();
|
|
if (gridBody.scrollLeft > 1) return;
|
|
attemptPrependPreviousMonth();
|
|
}
|
|
|
|
function onWheel(e) {
|
|
if (!isBiztrendMobileLayout()) return;
|
|
if (gridBody.scrollLeft > 2) return;
|
|
var dx = e.deltaX;
|
|
var dy = e.deltaY;
|
|
// 맨 왼쪽에서 ‘더 왼쪽(이전 주간·이전 달 방향)’ 휠 / Shift+세로 휠
|
|
var wantEarlier = dx < -4 || (e.shiftKey && dy < -4);
|
|
if (!wantEarlier) return;
|
|
attemptPrependPreviousMonth();
|
|
}
|
|
|
|
function onTouchStart(e) {
|
|
if (!isBiztrendMobileLayout() || !e.touches.length) return;
|
|
touchStartX = e.touches[0].clientX;
|
|
touchPulled = false;
|
|
}
|
|
|
|
function onTouchMove(e) {
|
|
if (!isBiztrendMobileLayout() || !e.touches.length) return;
|
|
if (gridBody.scrollLeft > 2 || touchPulled) return;
|
|
var x = e.touches[0].clientX;
|
|
if (x - touchStartX >= LEFT_EDGE_TOUCH_PULL_PX) {
|
|
touchPulled = true;
|
|
attemptPrependPreviousMonth();
|
|
}
|
|
}
|
|
|
|
gridBody.addEventListener("scroll", onBodyScroll, { passive: true });
|
|
gridBody.addEventListener("wheel", onWheel, { passive: true });
|
|
gridBody.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
gridBody.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
|
|
initLeftEdgePrevMonth._cleanup = function () {
|
|
gridBody.removeEventListener("scroll", onBodyScroll);
|
|
gridBody.removeEventListener("wheel", onWheel);
|
|
gridBody.removeEventListener("touchstart", onTouchStart);
|
|
gridBody.removeEventListener("touchmove", onTouchMove);
|
|
};
|
|
}
|
|
|
|
// ------------------------------
|
|
// 모달 팝업
|
|
// ------------------------------
|
|
|
|
/**
|
|
* 공통 비디오 모달 인스턴스를 준비합니다.
|
|
*/
|
|
function initModal() {
|
|
if (videoModal) return;
|
|
if (typeof VideoModalBase === "undefined") {
|
|
console.warn("[biztrend] VideoModalBase를 찾을 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
videoModal = new VideoModalBase({
|
|
modalPath: "./_modal/video.php",
|
|
modalPathTemplate: "./_modal/video-{type}.php",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 비디오 팝업을 열고 item 데이터를 채웁니다. (공통 비디오 모달 사용)
|
|
*/
|
|
function openModal(dateKey, idx) {
|
|
var items = articleData[dateKey];
|
|
if (!items || !items[idx]) return;
|
|
|
|
var item = items[idx];
|
|
if (!item.videoId) {
|
|
if (typeof alert === "function") alert("방송 영상을 준비 중입니다.");
|
|
return;
|
|
}
|
|
|
|
// 인라인 모달(#biztrendVideoModal)을 통해 열기
|
|
if (typeof window._biztrendOpenModal === "function") {
|
|
window._biztrendOpenModal({
|
|
contentId: item.contentId,
|
|
videoUrl: item.contentUrl || item.videoId,
|
|
title: item.title || "",
|
|
description: item.description || "",
|
|
categoryName: "비즈트렌드",
|
|
subCategory: item.badge || "성공예감&별책부록",
|
|
watchTm: item.watchTm || 0,
|
|
contentTm: item.contentTm || 0,
|
|
bookmark: !!item.liked
|
|
});
|
|
} else {
|
|
console.warn("[biztrend] _biztrendOpenModal을 찾을 수 없습니다.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 그리드 클릭 이벤트 위임 — 주말 카드 제외, article-card-content 클릭 시 비디오 팝업 오픈
|
|
*/
|
|
function initModalEvents() {
|
|
var grid = document.querySelector(".biztrend .article-grid");
|
|
if (!grid) return;
|
|
|
|
grid.addEventListener("click", function (e) {
|
|
// 링크 기본 동작 막기
|
|
var link = e.target.closest(".article-card-link");
|
|
if (link) e.preventDefault();
|
|
|
|
// 좋아요(북마크) 버튼 클릭
|
|
var likeBtn = e.target.closest(".article-card-like");
|
|
if (likeBtn) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleLikeClick(likeBtn);
|
|
return;
|
|
}
|
|
|
|
var content = e.target.closest(".article-card-content");
|
|
if (!content) return;
|
|
|
|
var dateKey = content.dataset.dateKey;
|
|
var idx = parseInt(content.dataset.itemIdx, 10);
|
|
if (!dateKey || isNaN(idx)) return;
|
|
|
|
openModal(dateKey, idx);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 좋아요(북마크) 버튼 클릭 핸들러
|
|
*/
|
|
function handleLikeClick(btn) {
|
|
var contentEl = btn.closest(".article-card-content");
|
|
if (!contentEl) return;
|
|
|
|
var dateKey = contentEl.dataset.dateKey;
|
|
var idx = parseInt(contentEl.dataset.itemIdx, 10);
|
|
if (!dateKey || isNaN(idx)) return;
|
|
|
|
var items = articleData[dateKey];
|
|
if (!items || !items[idx]) return;
|
|
|
|
var item = items[idx];
|
|
if (!item.contentId) return;
|
|
|
|
// 현재 상태 토글
|
|
var isCurrentlyLiked = btn.classList.contains("is-active");
|
|
var nextActive = isCurrentlyLiked ? "0" : "1";
|
|
|
|
// UI 즉시 반영 (낙관적 업데이트)
|
|
btn.disabled = true;
|
|
btn.classList.toggle("is-active");
|
|
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요" : "좋아요 취소");
|
|
|
|
var params = new URLSearchParams({
|
|
content_id: String(item.contentId),
|
|
is_active: nextActive
|
|
});
|
|
|
|
fetch("/bbs/api/save_wishlist.php", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
|
body: params.toString()
|
|
})
|
|
.then(function (res) { return res.json(); })
|
|
.then(function (data) {
|
|
if (!data || !data.success) {
|
|
// 실패 시 롤백
|
|
btn.classList.toggle("is-active");
|
|
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요");
|
|
console.warn("[biztrend] 북마크 저장 실패", data);
|
|
return;
|
|
}
|
|
// articleData 동기화
|
|
item.liked = !isCurrentlyLiked;
|
|
})
|
|
.catch(function (err) {
|
|
// 에러 시 롤백
|
|
btn.classList.toggle("is-active");
|
|
btn.setAttribute("aria-label", isCurrentlyLiked ? "좋아요 취소" : "좋아요");
|
|
console.warn("[biztrend] 북마크 요청 오류", err);
|
|
})
|
|
.finally(function () {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
// ------------------------------
|
|
// 날짜피커 휠 (연·월)
|
|
// ------------------------------
|
|
|
|
/**
|
|
* 휠 트랙에 padding/가운데 정렬 마스크가 있으면 scrollTop ≈ index×높이 공식이 깨집니다.
|
|
* 선택 항목 중심을 트랙 가운데에 맞춥니다.
|
|
*/
|
|
function setWheelScrollToIndex(trackEl, index) {
|
|
var nodeList = trackEl.querySelectorAll(".datepicker-wheel-item");
|
|
var len = nodeList.length;
|
|
if (!len) return;
|
|
var idx = Math.max(0, Math.min(index, len - 1));
|
|
var item = nodeList[idx];
|
|
trackEl.style.scrollBehavior = "auto";
|
|
var maxScroll = Math.max(0, trackEl.scrollHeight - trackEl.clientHeight);
|
|
var guard = 0;
|
|
while (guard++ < 12) {
|
|
var trackRect = trackEl.getBoundingClientRect();
|
|
var itemRect = item.getBoundingClientRect();
|
|
var trackMid = trackRect.top + trackRect.height / 2;
|
|
var itemMid = itemRect.top + itemRect.height / 2;
|
|
var delta = itemMid - trackMid;
|
|
if (Math.abs(delta) < 0.6) break;
|
|
var nextTop = trackEl.scrollTop + delta;
|
|
nextTop = Math.max(0, Math.min(nextTop, maxScroll));
|
|
if (nextTop === trackEl.scrollTop) break;
|
|
trackEl.scrollTop = nextTop;
|
|
}
|
|
trackEl.style.removeProperty("scroll-behavior");
|
|
}
|
|
|
|
function renderWheel(trackEl, items, selectedValue) {
|
|
var idx = items.findIndex(function (i) {
|
|
return i.value === selectedValue;
|
|
});
|
|
if (idx < 0) idx = 0;
|
|
|
|
var itemsHtml = items
|
|
.map(function (item, i) {
|
|
var isSelected = i === idx ? " is-selected" : "";
|
|
return (
|
|
'<div class="datepicker-wheel-item' +
|
|
isSelected +
|
|
'" data-value="' +
|
|
item.value +
|
|
'">' +
|
|
item.label +
|
|
"</div>"
|
|
);
|
|
})
|
|
.join("");
|
|
|
|
/*
|
|
* 마지막 항목(11·12월 등)도 선택 가이드(트랙 중앙)에 올리려면
|
|
* 스크롤 가능 영역이 아래로 충분히 있어야 합니다. 상·하 패딩 스페이서로 보충합니다.
|
|
*/
|
|
function layoutWheelWithPads() {
|
|
trackEl.innerHTML = itemsHtml;
|
|
var first = trackEl.querySelector(".datepicker-wheel-item");
|
|
var ih = first && first.offsetHeight ? first.offsetHeight : ITEM_HEIGHT;
|
|
var ch = trackEl.clientHeight;
|
|
var pad =
|
|
ch > 0
|
|
? Math.max(0, Math.floor(ch / 2 - ih / 2))
|
|
: Math.max(54, Math.floor(ih * 1.5));
|
|
var padHtml =
|
|
'<div class="datepicker-wheel-pad" aria-hidden="true" style="min-height:' +
|
|
pad +
|
|
"px;height:" +
|
|
pad +
|
|
'px;flex-shrink:0"></div>';
|
|
trackEl.innerHTML = padHtml + itemsHtml + padHtml;
|
|
setWheelScrollToIndex(trackEl, idx);
|
|
syncWheelSelectedClass(trackEl);
|
|
}
|
|
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
layoutWheelWithPads();
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 트랙 시야 중앙에 가장 가까운 항목 값 */
|
|
function getSelectedFromWheel(trackEl) {
|
|
var items = trackEl.querySelectorAll(".datepicker-wheel-item");
|
|
if (!items.length) return null;
|
|
var trackRect = trackEl.getBoundingClientRect();
|
|
var trackMid = trackRect.top + trackRect.height / 2;
|
|
var bestIdx = 0;
|
|
var bestDist = Infinity;
|
|
for (var i = 0; i < items.length; i++) {
|
|
var r = items[i].getBoundingClientRect();
|
|
var itemMid = r.top + r.height / 2;
|
|
var d = Math.abs(itemMid - trackMid);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
return parseInt(items[bestIdx].dataset.value, 10);
|
|
}
|
|
|
|
/** 현재 scrollTop 기준으로 해당 트랙의 is-selected만 맞춤 */
|
|
function syncWheelSelectedClass(trackEl) {
|
|
var val = getSelectedFromWheel(trackEl);
|
|
if (val == null) return;
|
|
trackEl.querySelectorAll(".datepicker-wheel-item").forEach(function (el) {
|
|
el.classList.toggle(
|
|
"is-selected",
|
|
parseInt(el.dataset.value, 10) === val
|
|
);
|
|
});
|
|
}
|
|
|
|
function scrollToSelected(trackEl, value) {
|
|
var item = trackEl.querySelector(
|
|
'.datepicker-wheel-item[data-value="' + value + '"]'
|
|
);
|
|
if (!item) return;
|
|
|
|
var allItems = trackEl.querySelectorAll(".datepicker-wheel-item");
|
|
var idx = Array.prototype.indexOf.call(allItems, item);
|
|
if (idx < 0) return;
|
|
|
|
setWheelScrollToIndex(trackEl, idx);
|
|
allItems.forEach(function (el) {
|
|
el.classList.toggle(
|
|
"is-selected",
|
|
parseInt(el.dataset.value, 10) === value
|
|
);
|
|
});
|
|
}
|
|
|
|
// ------------------------------
|
|
// 날짜피커 드롭다운 초기화
|
|
// ------------------------------
|
|
|
|
function initDatepickerDropdown() {
|
|
var trigger = document.getElementById("datepicker-trigger");
|
|
var dropdown = document.getElementById("datepicker-dropdown");
|
|
if (!trigger || !dropdown) return;
|
|
|
|
var yearTrack = dropdown.querySelector('[data-wheel="year"]');
|
|
var monthTrack = dropdown.querySelector('[data-wheel="month"]');
|
|
var lastYearForMonthWheel = null;
|
|
|
|
function refreshMonthWheelFromYear() {
|
|
var y = getSelectedFromWheel(yearTrack);
|
|
if (y == null) return;
|
|
if (lastYearForMonthWheel === y) return;
|
|
lastYearForMonthWheel = y;
|
|
var months = buildMonthWheelItemsForYear(y);
|
|
var curM = getSelectedFromWheel(monthTrack);
|
|
var maxM = months[months.length - 1].value;
|
|
var minM = months[0].value;
|
|
if (curM == null || isNaN(curM)) curM = minM;
|
|
curM = Math.max(minM, Math.min(curM, maxM));
|
|
renderWheel(monthTrack, months, curM);
|
|
}
|
|
|
|
function openDropdown() {
|
|
dropdown.classList.add("is-open");
|
|
dropdown.setAttribute("aria-hidden", "false");
|
|
trigger.setAttribute("aria-expanded", "true");
|
|
|
|
var raw = getDateValue();
|
|
var v = clampYearMonthToNow(raw.year, raw.month);
|
|
var yearItems = buildYearWheelItems();
|
|
if (yearItems.length && v.year < yearItems[0].value) {
|
|
v.year = yearItems[0].value;
|
|
}
|
|
if (yearItems.length && v.year > yearItems[yearItems.length - 1].value) {
|
|
v.year = yearItems[yearItems.length - 1].value;
|
|
}
|
|
var monthItems = buildMonthWheelItemsForYear(v.year);
|
|
var maxM = monthItems[monthItems.length - 1].value;
|
|
var minM = monthItems[0].value;
|
|
v.month = Math.max(minM, Math.min(v.month, maxM));
|
|
|
|
lastYearForMonthWheel = v.year;
|
|
renderWheel(yearTrack, yearItems, v.year);
|
|
renderWheel(monthTrack, monthItems, v.month);
|
|
|
|
document.addEventListener("keydown", onEscape);
|
|
setTimeout(function () {
|
|
document.addEventListener("click", onDocumentClick);
|
|
}, 0);
|
|
}
|
|
|
|
function closeDropdown() {
|
|
dropdown.classList.remove("is-open");
|
|
dropdown.setAttribute("aria-hidden", "true");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
|
|
document.removeEventListener("keydown", onEscape);
|
|
document.removeEventListener("click", onDocumentClick);
|
|
}
|
|
|
|
function onEscape(e) {
|
|
if (e.key === "Escape") closeDropdown();
|
|
}
|
|
|
|
function onDocumentClick(e) {
|
|
if (!dropdown.contains(e.target) && e.target !== trigger) {
|
|
closeDropdown();
|
|
}
|
|
}
|
|
|
|
function onConfirm() {
|
|
// 모바일 관성 스크롤 직후 scrollTop 반영을 한 프레임 양보
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(function () {
|
|
var year = getSelectedFromWheel(yearTrack);
|
|
var month = getSelectedFromWheel(monthTrack);
|
|
if (year != null && month != null) {
|
|
var picked = clampYearMonthToNow(year, month);
|
|
labelObserverQuietUntil = Date.now() + 600;
|
|
setDateValue(picked.year, picked.month);
|
|
renderCalendar(picked.year, picked.month);
|
|
}
|
|
closeDropdown();
|
|
});
|
|
});
|
|
}
|
|
|
|
trigger.addEventListener("click", function (e) {
|
|
e.stopPropagation();
|
|
if (dropdown.classList.contains("is-open")) {
|
|
closeDropdown();
|
|
} else {
|
|
openDropdown();
|
|
}
|
|
});
|
|
|
|
dropdown
|
|
.querySelector(".btn-datepicker-cancel")
|
|
.addEventListener("click", function (e) {
|
|
e.stopPropagation();
|
|
closeDropdown();
|
|
});
|
|
|
|
dropdown
|
|
.querySelector(".btn-datepicker-confirm")
|
|
.addEventListener("click", function (e) {
|
|
e.stopPropagation();
|
|
onConfirm();
|
|
});
|
|
|
|
yearTrack.addEventListener("scroll", function () {
|
|
syncWheelSelectedClass(yearTrack);
|
|
refreshMonthWheelFromYear();
|
|
});
|
|
monthTrack.addEventListener("scroll", function () {
|
|
syncWheelSelectedClass(monthTrack);
|
|
});
|
|
|
|
yearTrack.addEventListener("click", function (e) {
|
|
var item = e.target.closest(".datepicker-wheel-item");
|
|
if (item) {
|
|
scrollToSelected(yearTrack, parseInt(item.dataset.value, 10));
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(refreshMonthWheelFromYear);
|
|
});
|
|
}
|
|
});
|
|
monthTrack.addEventListener("click", function (e) {
|
|
var item = e.target.closest(".datepicker-wheel-item");
|
|
if (item) {
|
|
scrollToSelected(monthTrack, parseInt(item.dataset.value, 10));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ------------------------------
|
|
// 페이지 초기화
|
|
// ------------------------------
|
|
|
|
function initBiztrendCalendar() {
|
|
if (!document.querySelector(".wrap.biztrend")) return;
|
|
|
|
// 인라인 모달 스크립트에서 발생시키는 북마크 변경 이벤트를 수신해
|
|
// 페이지의 articleData를 동기화한다.
|
|
function onBookmarkChanged(event) {
|
|
var detail = event && event.detail ? event.detail : null;
|
|
if (!detail) return;
|
|
|
|
var cid = detail.contentId || detail.content_id;
|
|
if (!cid) return;
|
|
|
|
var isBookmarked = !!(detail.isBookmarked || detail.is_bookmarked || detail.isActive === "1");
|
|
syncLikedStateByContentId(cid, isBookmarked);
|
|
syncLikeButtonsByContentId(cid, isBookmarked);
|
|
}
|
|
|
|
window.addEventListener("biztrend:bookmark-changed", onBookmarkChanged);
|
|
window.addEventListener("bookmark-changed", onBookmarkChanged);
|
|
window.addEventListener("wishlist:changed", onBookmarkChanged);
|
|
|
|
// 기사 데이터 로드 (#biztrend-data JSON)
|
|
loadArticleData();
|
|
|
|
// 현재 날짜로 날짜피커 라벨 초기화
|
|
var now = new Date();
|
|
var initYear = now.getFullYear();
|
|
var initMonth = now.getMonth() + 1;
|
|
setDateValue(initYear, initMonth);
|
|
|
|
// 현재 달 기준 동적 그리드 렌더링 (renderCalendar 끝에서 스크롤 위치를 맞춤)
|
|
renderCalendar(initYear, initMonth);
|
|
|
|
initDatepickerDropdown();
|
|
initModal();
|
|
initModalEvents();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initBiztrendCalendar);
|
|
} else {
|
|
initBiztrendCalendar();
|
|
}
|
|
})();
|