PageSourceSearch

https://openadsthinkingmap-chi.vercel.app/app.js

js openadsthinkingmap-chi.vercel.app collected 2026-10-03 07:26:19 UTC 18,896 bytes, 498 lines download raw bytes

1// ═══════════════════════════════════════════════
2// ThinkingMap · 고정 URL 뷰어
3// ═══════════════════════════════════════════════
4
5const state = {
6  weeks: [],
7  currentWeek: null,
8  dataCache: new Map(),
9  dots: [],
10  curFilter: 'all',
11  isTouch: false,
12  activeDotIdx: -1,
13  hlDot: null
14};
15
16// ── 카테고리 정의 (관리자 툴과 동일) ──
17const CATEGORIES = {
18  ai:          { label:'AI·테크',           emoji:'🤖', color:'#8259FF', keywords:['ai','gpt','llm','생성형','챗봇','자동화','에이전트','머신러닝','딥러닝','geo','aeo','rag','프롬프트','코파일럿','copilot','chatgpt','claude','gemini','openai','엔비디아','nvidia'] },
19  brand:       { label:'브랜드·캠페인',     emoji:'✨', color:'#fb923c', keywords:['브랜드','캠페인','리브랜딩','아이덴티티','네이밍','스토리텔링','크리에이티브 광고','imc','슬로건','브랜딩','브랜드경험','bx'] },
20  performance: { label:'퍼포먼스·광고',     emoji:'📊', color:'#34d399', keywords:['퍼포먼스','roas','cpa','cpc','ctr','cvr','전환율','리타겟팅','메타광고','메타 광고','구글애즈','구글 애즈','google ads','meta ads','네이버gfa','gfa','카카오모먼트','카카오 모먼트','어트리뷰션','광고비','미디어믹스','bidding'] },
21  channel:     { label:'채널·SNS',          emoji:'📱', color:'#f472b6', keywords:['인스타그램','인스타','틱톡','tiktok','유튜브','youtube','sns','숏폼','shorts','릴스','reels','스레드','threads','링크드인','linkedin','페이스북','facebook','twitter','트위터','네이버','카카오톡','카톡','채널'] },
22  content:     { label:'콘텐츠·크리에이티브', emoji:'🎨', color:'#a78bfa', keywords:['콘텐츠','카피','카피라이팅','크리에이티브','디자인','영상','썸네일','편집','제작','스토리보드','비주얼','타이포','포스터','일러스트'] },
23  data:        { label:'데이터·분석',       emoji:'📈', color:'#60a5fa', keywords:['데이터','분석','ga4','구글 애널리틱스','analytics','대시보드','지표','kpi','통계','코호트','퍼널','리텐션','retention','cohort','funnel'] },
24  career:      { label:'업무·커리어',       emoji:'💼', color:'#0891b2', keywords:['업무','업무효율','업무스킬','생산성','productivity','팀장','팀','사수','주니어','신입','시니어','리더','리더십','매니저','상사','직장인','직장','직원','회사','사내','조직문화','조직','커리어','career','이직','연봉','퇴사','입사','워라밸','재택근무','재택','근무','출근','퇴근','회의','미팅','보고서','보고','기획서','제안서','협업','소통','커뮤니케이션','자기계발','면접','자
24소서','자기소개서'] },
25  trend:       { label:'트렌드',            emoji:'🔥', color:'#f87171', keywords:['트렌드','이슈','시대','소비','세대','mz','알파세대','열광','바이럴','신드롬','유행','2025','2026','신년','핫한'] },
26  other:       { label:'기타',              emoji:'🔖', color:'#94a3b8', keywords:[] }
27};
28const CAT_ORDER = ['ai','performance','data','channel','career','content','brand','trend'];
29const CAT_CODES = ['ai','brand','performance','channel','content','data','career','trend','other'];
30
31function classifyCategory(title) {
32  const t = (title || '').toLowerCase();
33  for (const code of CAT_ORDER) {
34    for (const k of CATEGORIES[code].keywords) {
35      if (t.includes(k.toLowerCase())) return code;
36    }
37  }
38  return 'other';
39}
40
41// ── 유틸 ──
42function getQ(x, y) {
43  // x: 트렌드(-) ↔ 실무(+), y: 라이트(+) ↔ 딥(-)
44  if (x < 0 && y >= 0) return 'q1'; // 트렌드+라이트
45  if (x >= 0 && y >= 0) return 'q2'; // 실무+라이트
46  if (x < 0 && y < 0) return 'q3';  // 트렌드+딥
47  return 'q4';                       // 실무+딥
48}
49
50async function fetchJSON(url) {
51  const r = await fetch(url, { cache: 'no-cache' });
52  if (!r.ok) throw new Error(`HTTP ${r.status}: ${url}`);
53  return r.json();
54}
55
56// ── 초기화 ──
57async function init() {
58  try {
59    const index = await fetchJSON('data/index.json');
60    state.weeks = index.weeks || [];
61    if (!state.weeks.length) {
62      showError('등록된 주차 데이터가 없습니다.');
63      return;
64    }
65
66    const urlParams = new URLSearchParams(window.location.search);
67    const weekParam = urlParams.get('week') || index.latest || state.weeks[0].date;
68
69    renderWeekSelector(weekParam);
70    await loadAndRenderWeek(weekParam);
71    attachEvents();
72  } catch (e) {
73    console.error(e);
74    showError('초기화 실패: ' + e.message);
75  }
76}
77
78// ── 주차 드롭다운 ──
79function renderWeekSelector(selected) {
80  const sel = document.getElementById('weekSelector');
81  // 최신이 위로 오도록 정렬
82  const sorted = [...state.weeks].sort((a, b) => b.date.localeCompare(a.date));
83  sel.innerHTML = sorted.map(w => {
84    const isSel = w.date === selected ? 'selected' : '';
85    const vol = w.volume || '';
86    const title = w.title || w.date;
87    return `<option value="${w.date}" ${isSel}>${vol} · ${title} (${w.contentCount || 0}개)</option>`;
88  }).join('');
89}
90
91// ── 주차 데이터 로드 + 렌더 ──
92async function loadAndRenderWeek(date) {
93  try {
94    showLoading(true);
95    let data = state.dataCache.get(date);
96    if (!data) {
97      data = await fetchJSON(`data/${date}.json`);
98      state.dataCache.set(date, data);
99    }
100    state.currentWeek = data;
101    renderAll(data);
102    updateURL(date);
103    showLoading(false);
104  } catch (e) {
105    console.error(e);
106    showError(`${date} 데이터 로딩 실패: ${e.message}`);
107  }
108}
109
110// ── 전체 렌더 ──
111function renderAll(data) {
112  // 헤더
113  const meta = data.meta || {};
114  const wlbl = meta.weekLabel || meta.week || '';
115  document.getElementById('centerNumber').textContent = meta.totalContents || data.contents.length;
116  const clTitleEl = document.getElementById('clTitle');
117  if (clTitleEl) clTitleEl.textContent = wlbl ? `${wlbl} 콘텐츠 목록` : '콘텐츠 목록';
118
119  // 이번 주의 특징 코멘트
120  const commentEl = document.getElementById('weeklyComment');
121  if (meta.weeklyComment && meta.weeklyComment.trim()) {
122    commentEl.textContent = meta.weeklyComment.trim();
123    commentEl.classList.remove('hidden');
124  } else {
125    commentEl.textContent = '';
126    commentEl.classList.add('hidden');
127  }
128
129  // Dots 변환 (겹침 회피)
130  state.dots = computeDots(data.contents);
131  renderDots();
132  renderTabs();
133  renderContentList();
134}
135
136// ── Dots 좌표 계산 (HTML downloader와 동일 로직) ──
137function computeDots(contents) {
138  const gridMap = {};
139  return contents.map((c, idx) => {
140    const q = c.q || getQ(c.x, c.y);
141    let rawX = 5 + ((c.x + 11) / 22) * 90;
142    let rawY = 5 + ((11 - (-c.y)) / 22) * 90;
143    const gk = Math.round(rawX / 3) + '_' + Math.round(rawY / 3);
144    if (!gridMap[gk]) gridMap[gk] = 0;
145    const offset = gridMap[gk]++;
146    const angle = (offset * 137.5) * Math.PI / 180;
147    const dist = offset * 1.8;
148    let pctX = rawX + Math.cos(angle) * dist;
149    let pctY = rawY + Math.sin(angle) * dist;
150    pctX = Math.max(4, Math.min(96, pctX));
151    pctY = Math.max(4, Math.min(96, pctY));
152    // 중앙 배지 회피
153    const dxc = pctX - 50, dyc = pctY - 50;
154    const distC = Math.sqrt(dxc * dxc + dyc * dyc);
155    if (distC < 9) {
156      if (distC > 0) { pctX = 50 + (dxc / distC) * 9; pctY = 50 + (dyc / distC) * 9; }
157      else pctX = 59;
158    }
159    return {
160      title: c.title,
161      url: c.url || '',
162      image: c.image || '',
163      q: q,
164      cat: c.category || classifyCategory(c.title),  // 기존 JSON 호환
165      x: pctX,
166      y: pctY,
167      size: 17,
168      idx: idx
169    };
170  });
171}
172
173// ── Dot DOM 렌더 ──
174function renderDots() {
175  const inner = document.getElementById('scatterInner');
176  // 기존 dots 제거
177  inner.querySelectorAll('.dot').forEach(d => d.remove());
178  const frag = document.createDocumentFragment();
179  state.dots.forEach((dot, i) => {
180    const el = document.createElement('div');
181    el.className = 'dot dot-' + dot.q;
182    el.dataset.idx = i;
183    el.style.width = dot.size + 'px';
184    el.style.height = dot.size + 'px';
185    el.style.left = dot.x.toFixed(1) + '%';
186    el.style.top = dot.y.toFixed(1) + '%';
187    frag.appendChild(el);
188  });
189  inner.appendChild(frag);
190  applyDotFade();
191}
192
193// ── 비선택 카테고리 점 fade ──
194function applyDotFade() {
195  const dots = document.querySelectorAll('.dot');
196  dots.forEach(el => {
197    const idx = parseInt(el.dataset.idx);
198    const d = state.dots[idx];
199    if (!d) return;
200    if (state.curFilter === 'all' || d.cat === state.curFilter) {
201      el.classList.remove('cat-dim');
202    } else {
203      el.classList.add('cat-dim');
204    }
205  });
206}
207
208// ── 탭 카운트 업데이트 (카테고리 기반, 0개는 숨김) ──
209function renderTabs() {
210  const cc = {};
211  CAT_CODES.forEach(c => cc[c] = 0);
212  state.dots.forEach(d => { cc[d.cat] = (cc[d.cat] || 0) + 1; });
213  const tabs = document.querySelectorAll('#clTabs .cl-tab');
214  tabs.forEach(t => {
215    const f = t.dataset.f;
216    if (f === 'all') {
217      t.textContent = `전체 ${state.dots.length}`;
218      t.style.display = '';
219    } else if (CATEGORIES[f]) {
220      const n = cc[f] || 0;
221      t.textContent = `${CATEGORIES[f].emoji} ${CATEGORIES[f].label} ${n}`;
222      t.style.display = n > 0 ? '' : 'none';
223    }
224    t.classList.toggle('active', f === state.curFilter);
225  });
226  // 현재 필터가 0개 카테고리면 '전체'로 복귀
227  if (state.curFilter !== 'all' && (cc[state.curFilter] || 0) === 0) {
228    state.curFilter = 'all';
229    tabs.forEach(t => t.classList.toggle('active', t.dataset.f === 'all'));
230  }
231}
232
233// ── 콘텐츠 리스트 (카테고리 필터) ──
234function renderContentList() {
235  const list = document.getElementById('clList');
236  const cnt = document.getElementById('clCount');
237  const filtered = state.dots
238    .map((d, i) => ({ d, i }))
239    .filter(o => state.curFilter === 'all' || o.d.cat === state.curFilter);
240  cnt.textContent = `${filtered.length}개 콘텐츠`;
241  if (!filtered.length) {
242    list.innerHTML = '<div class="cl-empty">항목이 없습니다</div>';
243    return;
244  }
245  list.innerHTML = filtered.map(o => {
246    const d = o.d;
247    const t = d.title.replace(/&/g, '&amp;').replace(/</g, '&lt;');
248    const href = d.url ? buildUTM(d.url) : '';
249    return `<a class="cl-item ${d.q}" data-idx="${o.i}"${href ? ` href="${href}" target="_blank"` : ''}><span class="cl-item-title">${t}</span>${d.url ? '<span class="cl-item-arrow">→</span>' : ''}</a>`;
250  }).join('');
251}
252
253// ── UTM 부착 ──
254function buildUTM(url) {
255  const week = state.currentWeek?.meta?.week || '';
256  const sep = url.indexOf('?') >= 0 ? '&' : '?';
257  return url + sep + 'utm_source=newsletter&utm_medium=thinking_map&utm_campaign=weekly_' + week;
258}
259
260// ── 툴팁 ──
261function showTooltip(el, dot) {
262  const tt = document.getElementById('tooltip');
263  const ttText = document.getElementById('ttText');
264  const ttThumb = document.getElementById('ttThumb');
265  const ttArrow = document.getElementById('ttArrow');
266  ttText.textContent = dot.title;
267  if (dot.image) { ttThumb.src = dot.image; ttThumb.classList.remove('hide'); }
268  else ttThumb.classList.add('hide');
269  tt.style.left = '-9999px'; tt.style.top = '-9999px';
270  tt.classList.add('show');
271  requestAnimationFrame(() => {
272    const r = el.getBoundingClientRect();
273    const dotCX = r.left + r.width / 2;
274    const dotCY = r.top;
275    const ttH = tt.offsetHeight || 50;
276    const ttW = tt.offsetWidth || 240;
277    let ttLeft = Math.max(8, Math.min(window.innerWidth - ttW - 8, dotCX - ttW / 2));
278    let ttTop = dotCY - ttH - 14;
279    ttArrow.style.borderTop = '9px solid #fff';
280    ttArrow.style.borderBottom = 'none';
281    let arrowTop = dotCY - 9;
282    if (ttTop < 8) {
283      ttTop = r.bottom + 14;
284      ttArrow.style.borderTop = 'none';
285      ttArrow.style.borderBottom = '9px solid #fff';
286      arrowTop = r.bottom + 5;
287    }
288    tt.style.left = ttLeft + 'px';
289    tt.style.top = ttTop + 'px';
290    ttArrow.style.left = (dotCX - 8) + 'px';
291    ttArrow.style.top = arrowTop + 'px';
292    ttArrow.classList.add('show');
293  });
294}
295
296function hideTooltip() {
297  document.getElementById('tooltip').classList.remove('show');
298  document.getElementById('ttArrow').classList.remove('show');
299  state.activeDotIdx = -1;
300}
301
302// ── 이동 ──
303function navigateDot(dot) {
304  if (!dot || !dot.url) return;
305  const link = buildUTM(dot.url);
306  if (typeof gtag === 'function') {
307    const qMap = { q1: 'A_트렌드라이트', q2: 'B_실무라이트', q3: 'C_트렌드딥', q4: 'D_실무딥' };
308    gtag('event', 'dot_click', {
309      content_title: dot.title,
310      content_url: dot.url,
311      quadrant: qMap[dot.q],
312      category: dot.cat || 'other',
313      week_label: state.currentWeek?.meta?.weekLabel || ''
314    });
315  }
316  window.open(link, '_blank');
317}
318
319// ── 이벤트 ──
320function attachEvents() {
321  document.addEventListener('touchstart', () => {
322    state.isTouch = true;
323    document.body.classList.add('touch-device');
324  }, { once: true, passive: true });
325
326  // 주차 변경
327  document.getElementById('weekSelector').addEventListener('change', (e) => {
328    loadAndRenderWeek(e.target.value);
329  });
330
331  // 공유하기 버튼
332  const shareBtn = document.getElementById('shareBtn');
333  if (shareBtn) shareBtn.addEventListener('click', () => copyShareUrl(shareBtn));
334
335  // 탭 (카테고리 필터)
336  document.getElementById('clTabs').addEventListener('click', (e) => {
337    const tab = e.target.closest('.cl-tab');
338    if (!tab) return;
339    state.curFilter = tab.dataset.f;
340    if (typeof gtag === 'function') {
341      gtag('event', 'category_filter', {
342        category: state.curFilter,
343        week_label: state.currentWeek?.meta?.weekLabel || ''
344      });
345    }
346    renderTabs();
347    renderContentList();
348    applyDotFade();
349  });
350
351  // Scatter 이벤트
352  const scatter = document.getElementById('scatter');
353  scatter.addEventListener('click', (e) => {
354    const el = e.target.closest('.dot');
355    if (state.isTouch) {
356      if (!el) { hideTooltip(); return; }
357      const idx = parseInt(el.dataset.idx);
358      const dot = state.dots[idx];
359      if (!dot) return;
360      if (state.activeDotIdx === idx) { navigateDot(dot); hideTooltip(); }
361      else { state.activeDotIdx = idx; showTooltip(el, dot); }
362    } else {
363      if (!el) return;
364      navigateDot(state.dots[parseInt(el.dataset.idx)]);
365    }
366  });
367  scatter.addEventListener('mouseover', (e) => {
368    if (state.isTouch) return;
369    const el = e.target.closest('.dot');
370    if (!el) { hideTooltip(); return; }
371    const dot = state.dots[parseInt(el.dataset.idx)];
372    if (!dot) return;
373    showTooltip(el, dot);
374    el.style.cursor = dot.url ? 'pointer' : 'default';
375  });
376  scatter.addEventListener('mouseout', (e) => {
377    if (state.isTouch) return;
378    if (e.target.closest('.dot')) hideTooltip();
379  });
380
381  // 모바일 Go 버튼
382  document.getElementById('ttGo').addEventListener('click', (e) => {
383    e.stopPropagation();
384    if (state.activeDotIdx >= 0) navigateDot(state.dots[state.activeDotIdx]);
385    hideTooltip();
386  });
387  document.addEventListener('click', (e) => {
388    if (state.isTouch && !e.target.closest('#scatter') && !e.target.closest('#tooltip')) hideTooltip();
389  });
390
391  // 콘텐츠 리스트 hover → dot 확대 + 툴팁
392  const clList = document.getElementById('clList');
393  clList.addEventListener('mouseover', (e) => {
394    if (state.isTouch) return;
395    const item = e.target.closest('.cl-item');
396    if (!item) return;
397    const idx = parseInt(item.dataset.idx);
398    const dot = state.dots[idx];
399    if (!dot) return;
400    const dotEl = document.querySelector(`.dot[data-idx="${idx}"]`);
401    if (!dotEl) return;
402    if (state.hlDot && state.hlDot !== dotEl) state.hlDot.classList.remove('cl-hover');
403    dotEl.classList.add('cl-hover');
404    state.hlDot = dotEl;
405    showTooltip(dotEl, dot);
406  });
407  clList.addEventListener('mouseout', (e) => {
408    if (state.isTouch) return;
409    const item = e.target.closest('.cl-item');
410    if (!item) return;
411    const to = e.relatedTarget;
412    if (to && to.closest && to.closest('.cl-item')) return;
413    if (state.hlDot) { state.hlDot.classList.remove('cl-hover'); state.hlDot = null; }
414    hideTooltip();
415  });
416}
417
418// ── 공유 URL 복사 ──
419function copyShareUrl(btn) {
420  const url = window.location.href;
421  const done = (ok) => {
422    if (ok) {
423      showShareToast();
424      if (btn) {
425        const original = btn.textContent;
426        btn.textContent = '✓ 복사됨!';
427        btn.classList.add('copied');
428        setTimeout(() => {
429          btn.textContent = original;
430          btn.classList.remove('copied');
431        }, 2000);
432      }
433      if (typeof gtag === 'function') {
434        gtag('event', 'share_click', {
435          share_method: 'copy_link',
436          week_label: state.currentWeek?.meta?.weekLabel || '',
437          shared_url: url
438        });
439      }
440    } else {
441      window.prompt('아래 URL을 복사해 주세요 (Ctrl+C):', url);
442    }
443  };
444  if (navigator.clipboard && window.isSecureContext) {
445    navigator.clipboard.writeText(url).then(() => done(true)).catch(() => fallbackCopy(url, done));
446  } else {
447    fallbackCopy(url, done);
448  }
449}
450
451function fallbackCopy(text, done) {
452  try {
453    const ta = document.createElement('textarea');
454    ta.value = text;
455    ta.style.position = 'fixed';
456    ta.style.top = '-1000px';
457    ta.style.opacity = '0';
458    document.body.appendChild(ta);
459    ta.focus(); ta.select();
460    const ok = document.execCommand('copy');
461    document.body.removeChild(ta);
462    done(!!ok);
463  } catch (e) {
464    done(false);
465  }
466}
467
468function showShareToast() {
469  const t = document.getElementById('shareToast');
470  if (!t) return;
471  t.classList.add('show');
472  clearTimeout(showShareToast._tid);
473  showShareToast._tid = setTimeout(() => t.classList.remove('show'), 2000);
474}
475
476// ── URL 업데이트 ──
477function updateURL(date) {
478  const url = new URL(window.location.href);
479  url.searchParams.set('week', date);
480  window.history.replaceState({}, '', url);
481}
482
483// ── 상태 UI ──
484function showLoading(show) {
485  document.getElementById('loadingMsg').classList.toggle('hidden', !show);
486  document.getElementById('mainGrid').style.display = show ? 'none' : 'flex';
487}
488
489function showError(msg) {
490  const err = document.getElementById('errorMsg');
491  err.textContent = msg;
492  err.classList.remove('hidden');
493  document.getElementById('loadingMsg').classList.add('hidden');
494  document.getElementById('mainGrid').style.display = 'none';
495}
496
497// ── 시작 ──
498document.addEventListener('DOMContentLoaded', init);

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.