/** * DMP ๅ‰็ซฏๅบ”็”จ * * ๐Ÿ“š ไบคไบ’้€ป่พ‘ v2๏ผš * 1. ้ป˜่ฎคๆ€๏ผšๆฏๅผ ๅก็‰‡ๅชๆ˜พ็คบๅ…จ้‡ไบบๆ•ฐ๏ผˆcoverage๏ผ‰ * 2. ้€‰็ฌฌไธ€ไธชๆ ‡็ญพๅŽ๏ผšๅ…ถไป–ๅก็‰‡ๅฎžๆ—ถ้ข„็ฎ—"ๅฆ‚ๆžœๅŠ ไธŠ่ฟ™ไธชๆ ‡็ญพ็ป“ๆžœ้›†ๆœ‰ๅคšๅฐ‘ไบบ" * 3. ๆฏๆฌก็‚นๅ‡ปๆ–ฐๅขžๆ ‡็ญพ๏ผšๅก็‰‡ๅณไธŠ่ง’ๆ˜พ็คบ็›ธๆฏ”็‚นๅ‡ปๅ‰็š„ไบบๆ•ฐ/่ฝฌๅŒ–็އๅ˜ๅŒ– (ยฑฮ”) * 4. ้˜ฒๆŠ– 350ms๏ผŒ้ฟๅ…ๅฟซ้€Ÿ็‚นๅ‡ป้ข‘็น่ฏทๆฑ‚ */ const API = ''; // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ๅ…จๅฑ€็Šถๆ€ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const state = { totalUsers: 0, categories: [], // ๅ…จ้ƒจๅˆ†็ฑป+ๆ ‡็ญพ selected: new Map(), // tagId โ†’ { tagId, name, mode, color } lastResult: null, // ๆœ€ๆ–ฐ่ฎก็ฎ—็ป“ๆžœ prevResult: null, // ็‚นๅ‡ปๅ‰็š„็ป“ๆžœ๏ผˆ็”จไบŽ่ฎก็ฎ— delta๏ผ‰ computeTimer: null, previewTimer: null, previewAbortCtrl: null, // ็”จไบŽๅ–ๆถˆ่ฟ‡ๆœŸ็š„้ข„่งˆ่ฏทๆฑ‚ phase: 'default', // 'default' | 'preview' | 'computed' }; const CURRENT_THEME = 'onion'; // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ๅˆๅง‹ๅŒ– // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function init() { try { const data = await apiFetch(`/api/tags?theme=${CURRENT_THEME}`); if (!data) return; state.categories = data.categories; state.totalUsers = data.totalUsers; renderBoard(data.categories, data.totalUsers); document.getElementById('tagBoard').classList.add('phase-default'); document.getElementById('rcTotal').textContent = fmtNum(data.totalUsers); document.getElementById('rcRate').textContent = 'โ€”'; } catch (e) { console.error('Init failed', e); } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ๆธฒๆŸ“ๆ ‡็ญพ็œ‹ๆฟ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function renderBoard(categories, totalUsers) { const board = document.getElementById('tagBoard'); board.innerHTML = ''; for (const cat of categories) { const col = document.createElement('div'); col.className = 'col'; col.dataset.catKey = cat.key; const header = document.createElement('div'); header.className = 'col-header'; header.innerHTML = `
${cat.name}
${cat.tags.length}
`; col.appendChild(header); for (const tag of cat.tags) { col.appendChild(createTagCard(tag, totalUsers, cat.color)); } board.appendChild(col); } document.getElementById('boardLoading').style.display = 'none'; } function createTagCard(tag, totalUsers, catColor) { const card = document.createElement('div'); card.className = 'tag-card'; card.dataset.tagId = tag.id; card.dataset.tagKey = tag.key; card.dataset.tagName = tag.name; card.dataset.catColor = catColor; const sourceLabel = tag.source === 'inferred' ? 'ๆŽจๆ–ญ' : 'ๅŽŸๅง‹'; const sourceText = tag.source ? `ๆฅๆบ๏ผš${sourceLabel}` : ''; const coverageW = totalUsers > 0 ? (tag.coverage / totalUsers * 100).toFixed(0) : 0; card.innerHTML = `
${tag.name}
${[tag.description || '', sourceText].filter(Boolean).join(' ยท ')}
${fmtNum(tag.coverage)}
${tag.coverage_rate || 0}%
โ€”
${fmtNum(tag.coverage)} / ${tag.coverage_rate || 0}%
โ€”
`; card.addEventListener('click', (e) => { e.preventDefault(); handleTagClick(tag, catColor, 'include'); }); card.addEventListener('contextmenu', (e) => { e.preventDefault(); showContextMenu(e, tag, catColor); }); return card; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ๆ ‡็ญพ็‚นๅ‡ปไธปๅ…ฅๅฃ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function handleTagClick(tag, color, mode) { // 1. ่ฎฐๅฝ•็‚นๅ‡ปๅ‰็š„็ป“ๆžœ state.prevResult = state.lastResult ? { ...state.lastResult } : null; // 2. ๆ›ดๆ–ฐ้€‰ๆ‹ฉ็Šถๆ€ const existing = state.selected.get(tag.id); if (existing) { if (existing.mode === mode) { state.selected.delete(tag.id); } else { existing.mode = mode; } } else { state.selected.set(tag.id, { tagId: tag.id, name: tag.name, mode, color }); } // ็งป้™ค/ๆทปๅŠ ้ป˜่ฎคๆจกๅผ็ฑปๅ document.getElementById('tagBoard').classList.toggle('phase-default', state.selected.size === 0); updateCardState(tag.id); updateSelectedBar(); if (state.selected.size === 0) { resetToDefault(); return; } // 3. ่งฆๅ‘่ฎก็ฎ— + ๅฎžๆ—ถ้ข„่งˆ scheduledCompute(); } function toggleTag(tag, color, mode) { handleTagClick(tag, color, mode); } function removeTag(tagId) { state.prevResult = state.lastResult ? { ...state.lastResult } : null; state.selected.delete(tagId); updateCardState(tagId); updateSelectedBar(); if (state.selected.size === 0) { resetToDefault(); } else { scheduledCompute(); } } function updateCardState(tagId) { const card = document.querySelector(`.tag-card[data-tag-id="${tagId}"]`); if (!card) return; const sel = state.selected.get(tagId); card.classList.remove('selected-include', 'selected-exclude'); if (sel) card.classList.add(`selected-${sel.mode}`); } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Selected Bar // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function updateSelectedBar() { const bar = document.getElementById('selectedBar'); const tagsEl = document.getElementById('selTags'); const computeBtn = document.getElementById('btnCompute'); const sampleBtn = document.getElementById('btnSample'); if (state.selected.size === 0) { bar.style.display = 'none'; computeBtn.disabled = true; sampleBtn.style.display = 'none'; return; } bar.style.display = 'flex'; computeBtn.disabled = false; tagsEl.innerHTML = ''; for (const [id, tag] of state.selected) { const el = document.createElement('span'); el.className = `sel-tag ${tag.mode}`; const modeLabel = tag.mode === 'exclude' ? ' โœ•' : ''; el.innerHTML = `${tag.name}${modeLabel} ร—`; el.addEventListener('click', () => removeTag(id)); tagsEl.appendChild(el); } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ่ฎก็ฎ—๏ผˆไธป็ป“ๆžœ๏ผ‰ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function scheduledCompute() { clearTimeout(state.computeTimer); if (state.selected.size === 0) return; state.computeTimer = setTimeout(() => compute(), 350); } async function compute() { if (state.selected.size === 0) return; const payload = { selected: [...state.selected.values()].map(s => ({ tagId: s.tagId, mode: s.mode })) }; showOverlay(true); const result = await apiFetch(`/api/compute?theme=${CURRENT_THEME}`, { method: 'POST', body: JSON.stringify(payload) }); showOverlay(false); if (!result) return; // ๆ›ดๆ–ฐไธป็ป“ๆžœ๏ผŒๅŒๆ—ถไฟ็•™ prevResult ็”จไบŽ delta ๅฑ•็คบ const prev = state.prevResult; state.lastResult = result; state.phase = 'computed'; // ้กถ้ƒจ่ฎกๆ•ฐๅ™จ const numEl = document.getElementById('rcNum'); numEl.textContent = fmtNum(result.count); numEl.classList.remove('num-pop'); void numEl.offsetWidth; numEl.classList.add('num-pop'); document.getElementById('rcRate').textContent = result.rate; document.getElementById('rcTotal').textContent = fmtNum(result.totalUsers); document.getElementById('resultCounter').classList.add('has-result'); // delta ไฟกๆฏๆ˜พ็คบๅœจ้กถ้ƒจ updateDeltaDisplay(prev, result); // ๆŠŠ breakdown ๅ›žๅกซๅˆฐๅ„ๅก็‰‡๏ผŒๅนถ้™„ไธŠ delta applyBreakdownWithDelta(result, prev); // ้ข„่งˆๆ‰€ๆœ‰ๆœช้€‰ๆ‹ฉ็š„ๅก็‰‡๏ผˆ"ๅฆ‚ๆžœๅŠ ไธŠ่ฟ™ไธชๆ ‡็ญพ๏ผŒ็ป“ๆžœๆœ‰ๅคšๅฐ‘"๏ผ‰ schedulePreviewAll(); document.getElementById('btnSample').style.display = ''; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Delta ๅฑ•็คบ๏ผˆ้กถ้ƒจ่ฎกๆ•ฐๅ™จไธ‹ๆ–น๏ผ‰ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function updateDeltaDisplay(prev, current) { const el = document.getElementById('rcDelta'); if (!el) return; if (!prev || !current) { el.innerHTML = ''; el.style.display = 'none'; return; } const deltaCount = current.count - prev.count; const deltaRate = (current.rate - prev.rate).toFixed(2); const sign = deltaCount >= 0 ? '+' : ''; const rateSign = deltaRate >= 0 ? '+' : ''; const cls = deltaCount >= 0 ? 'delta-up' : 'delta-down'; el.innerHTML = ` ่พƒไธŠๆฌก๏ผš ${sign}${fmtNum(Math.abs(deltaCount))}ไบบ ยท ${rateSign}${deltaRate}% `; el.style.display = 'flex'; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Breakdown ๅ›žๅกซ๏ผˆๅทฒ้€‰ๆ ‡็ญพ๏ผš็งป้™ค้ข„่งˆๆ€๏ผŒๆ›ดๆ–ฐ่ฟ›ๅบฆๆก๏ผ‰ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function applyBreakdownWithDelta(result, prev) { const selectedIds = new Set([...state.selected.keys()]); // ๅทฒ้€‰ๅก็‰‡๏ผš้€€ๅ‡บ้ข„่งˆๆจกๅผ๏ผŒๆธ… lift badge for (const tagId of selectedIds) { const card = document.querySelector(`.tag-card[data-tag-id="${tagId}"]`); if (card) card.classList.remove('in-preview'); const lb = document.getElementById(`tclb-${tagId}`); if (lb) { lb.className = 'tc-lift-badge'; lb.textContent = ''; } } // ๆ›ดๆ–ฐๅทฒ้€‰ๆ ‡็ญพ็š„่ฟ›ๅบฆๆก๏ผˆ็›ธๅฏน็ป“ๆžœ้›†ๆฏ”ไพ‹๏ผ‰ๅŠๅฎž้™…ๅ‘ฝไธญๆ•ฐๆฎ for (const bd of (result.breakdown || [])) { const progEl = document.getElementById(`tcp-${bd.tagId}`); if (progEl) progEl.style.width = bd.rate + '%'; // ๆŠŠ็ป“ๆžœๅ†™ๅˆฐๅŽŸๆœฌ้ป˜่ฎคๆ€็š„ไฝ็ฝฎไธŠ const covEl = document.getElementById(`tcc-${bd.tagId}`); const rateEl = document.getElementById(`tccov-${bd.tagId}`); if (covEl) covEl.textContent = fmtNum(bd.count); if (rateEl) rateEl.textContent = bd.rate + '%'; } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ้ข„่งˆ๏ผšๆœช้€‰ๆ ‡็ญพ"ๅŠ ไธŠๅŽๆœ‰ๅคšๅฐ‘ไบบ" // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function schedulePreviewAll() { clearTimeout(state.previewTimer); state.previewTimer = setTimeout(() => previewAll(), 200); } async function previewAll() { if (state.selected.size === 0) return; // ๅ–ๆถˆไธŠไธ€ๆ‰น้ข„่งˆ if (state.previewAbortCtrl) state.previewAbortCtrl.abort(); state.previewAbortCtrl = new AbortController(); const signal = state.previewAbortCtrl.signal; const currentResult = state.lastResult; // ๆ”ถ้›†ๆ‰€ๆœ‰ๆœช้€‰็š„ include ๆ ‡็ญพ const unselectedTags = []; for (const cat of state.categories) { for (const tag of cat.tags) { if (!state.selected.has(tag.id)) { unselectedTags.push({ tag, color: cat.color }); } } } if (unselectedTags.length === 0) return; // ๆ‰น้‡่ฎก็ฎ—๏ผšๆฏไธชๆœช้€‰ๆ ‡็ญพ + ๅฝ“ๅ‰ๅทฒ้€‰ โ†’ ้ข„ไผฐไบบๆ•ฐ const crossTagIds = unselectedTags.map(t => t.tag.id); const currentSelected = [...state.selected.values()].map(s => ({ tagId: s.tagId, mode: s.mode })); try { const crossResult = await apiFetch(`/api/compute/cross?theme=${CURRENT_THEME}`, { method: 'POST', body: JSON.stringify({ selected: currentSelected, crossTagIds }), signal }); if (!crossResult || signal.aborted) return; const { matrix } = crossResult; const matrixMap = new Map(matrix.map(m => [m.tagId, m])); for (const { tag } of unselectedTags) { if (signal.aborted) break; const m = matrixMap.get(tag.id); if (!m) continue; const tagInfo = findTag(tag.id); const baseRate = tagInfo ? (tagInfo.coverage_rate || 0) : 0; // ไบค้›†ไบบๆ•ฐ const intersectionCount = m.count; // ๆกไปถๆฆ‚็އ P(B|A) = ไบค้›† / ๅฝ“ๅ‰้€‰ไธญไบบๆ•ฐ const selectedCount = currentResult ? currentResult.count : 1; const conditionalRate = selectedCount > 0 ? +(intersectionCount / selectedCount * 100).toFixed(2) : 0; // Lift = P(B|A) / P(B) const lift = baseRate > 0 ? +(conditionalRate / baseRate).toFixed(2) : 1; const isUp = lift >= 1; // โ‘  ๆ›ดๆ–ฐ lift badge const liftEl = document.getElementById(`tclb-${tag.id}`); if (liftEl) { liftEl.className = `tc-lift-badge ${isUp ? 'lift-up' : 'lift-down'}`; liftEl.textContent = `${isUp ? 'โ†‘' : 'โ†“'}${lift.toFixed(2)}`; } // โ‘ก ๆ›ดๆ–ฐไบค้›†ไบบๆ•ฐ๏ผˆๅคงๅญ—๏ผ‰ const intCountEl = document.getElementById(`tcic-${tag.id}`); if (intCountEl) { intCountEl.textContent = fmtNum(intersectionCount); intCountEl.className = `tc-int-count ${isUp ? 'int-up' : 'int-down'}`; } // โ‘ข ๆ›ดๆ–ฐๆกไปถๆฆ‚็އ๏ผˆๅฝฉ่‰ฒๅฐๅญ—๏ผ‰ const condRateEl = document.getElementById(`tccr-${tag.id}`); if (condRateEl) { condRateEl.textContent = conditionalRate.toFixed(2) + '%'; condRateEl.className = `tc-cond-rate ${isUp ? 'cond-up' : 'cond-down'}`; } // โ‘ฃ ๅˆ‡ๆขๅก็‰‡ๅˆฐ้ข„่งˆๆจกๅผ const cardEl = document.querySelector(`.tag-card[data-tag-id="${tag.id}"]`); if (cardEl) cardEl.classList.add('in-preview'); } } catch (e) { if (e.name !== 'AbortError') console.error('preview error', e); } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ้‡็ฝฎๅˆฐ้ป˜่ฎคๆ€ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function resetToDefault() { document.getElementById('tagBoard').classList.add('phase-default'); state.lastResult = null; state.prevResult = null; state.phase = 'default'; document.getElementById('rcNum').textContent = 'โ€”'; document.getElementById('rcRate').textContent = 'โ€”'; document.getElementById('rcTotal').textContent = fmtNum(state.totalUsers); document.getElementById('resultCounter').classList.remove('has-result'); const deltaEl = document.getElementById('rcDelta'); if (deltaEl) { deltaEl.innerHTML = ''; deltaEl.style.display = 'none'; } // ็งป้™คๆ‰€ๆœ‰ๅก็‰‡้ข„่งˆๆ€ + ๆธ… lift badge document.querySelectorAll('.tag-card.in-preview').forEach(c => c.classList.remove('in-preview')); document.querySelectorAll('.tc-lift-badge').forEach(el => { el.className = 'tc-lift-badge'; el.textContent = ''; }); // ๆขๅค่ฟ›ๅบฆๆกๅ’Œ้ป˜่ฎคๆ•ฐๆฎๅˆฐๅ…จ้‡ๆฏ”ไพ‹ for (const cat of state.categories) { for (const tag of cat.tags) { const progEl = document.getElementById(`tcp-${tag.id}`); if (progEl) { const w = state.totalUsers > 0 ? (tag.coverage / state.totalUsers * 100) : 0; progEl.style.width = w.toFixed(0) + '%'; } const covEl = document.getElementById(`tcc-${tag.id}`); const rateEl = document.getElementById(`tccov-${tag.id}`); if (covEl) covEl.textContent = fmtNum(tag.coverage); if (rateEl) rateEl.textContent = (tag.coverage_rate || 0) + '%'; } } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // clearResults๏ผˆๅชๅœจ resetAll ๆ—ถ่ฐƒ็”จ๏ผ‰ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function clearResults() { resetToDefault(); } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Reset All // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function resetAll() { const ids = [...state.selected.keys()]; state.selected.clear(); ids.forEach(id => updateCardState(id)); updateSelectedBar(); clearResults(); closePanel(); } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Context Menu // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let ctxMenu = null; function showContextMenu(e, tag, color) { removeContextMenu(); const menu = document.createElement('div'); menu.className = 'context-menu'; menu.style.cssText = `display:block; left:${e.clientX}px; top:${e.clientY}px`; menu.innerHTML = `
โœ… ๅŒ…ๅซๆญคๆ ‡็ญพ
๐Ÿšซ ๆŽ’้™คๆญคๆ ‡็ญพ
โœ• ็งป้™คๆกไปถ
`; document.body.appendChild(menu); ctxMenu = { el: menu, tag, color }; setTimeout(() => document.addEventListener('click', removeContextMenu, { once: true }), 0); } function handleCtx(action, tagId) { if (!ctxMenu) return; const { tag, color } = ctxMenu; if (action === 'remove') removeTag(tagId); else toggleTag(tag, color, action); removeContextMenu(); } function removeContextMenu() { if (ctxMenu) { ctxMenu.el.remove(); ctxMenu = null; } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Right Panel // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function closePanel() { document.getElementById('rightPanel').style.display = 'none'; } async function loadSample(body) { if (state.selected.size === 0) return; body.innerHTML = '
ๅŠ ่ฝฝไธญ...
'; const result = await apiFetch(`/api/users/sample?theme=${CURRENT_THEME}`, { method: 'POST', body: JSON.stringify({ selected: [...state.selected.values()].map(s => ({ tagId: s.tagId, mode: s.mode })), limit: 50 }) }); if (!result || result.users.length === 0) { body.innerHTML = '
ๆš‚ๆ— ็”จๆˆทๆ•ฐๆฎ
'; return; } const note = state.lastResult ? `
ๅ…ฑ ${fmtNum(state.lastResult.count)} ไบบ (${state.lastResult.rate}%)๏ผŒๅฑ•็คบๅ‰ ${result.users.length} ๆก
` : ''; body.innerHTML = note + ` ${result.users.map(u => ` `).join('')}
UID Name Email
${u.uid} ${u.name || '-'} ${u.email || '-'}
`; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Duration Stats // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function loadDurationStats(body) { body.innerHTML = '
ๅŠ ่ฝฝไธญ...
'; const result = await apiFetch(`/api/duration-stats?theme=${CURRENT_THEME}`); if (!result) { body.innerHTML = '
ๅŠ ่ฝฝๅคฑ่ดฅ
'; return; } const { totalUsers, durationBreakdown } = result; let html = `
ๆ€ปๅ‚ไธŽไบบๆ•ฐ
${fmtNum(totalUsers)}
ๆŒ‡ๅฏผๅ‘จๆœŸๅˆ†ๅธƒ
`; for (const duration of durationBreakdown) { const pct = duration.count > 0 ? (duration.count / totalUsers * 100).toFixed(1) : 0; html += `
${duration.name} ${fmtNum(duration.count)}
${pct}%
`; } html += `
๐Ÿ“Œ ่ฏดๆ˜Ž๏ผš
โ€ข 60ๅคฉ่ฏพ็จ‹๏ผš็ŸญๆœŸ้›†ไธญๆŒ‡ๅฏผ
โ€ข 180ๅคฉ่ฏพ็จ‹๏ผšๆทฑๅบฆ้•ฟๆœŸๆŒ‡ๅฏผ
ๅ…ฑ่ฎก ${durationBreakdown.reduce((sum, d) => sum + d.count, 0)} ไบบๅ‚ไธŽ
`; body.innerHTML = html; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Import Modal // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function showImportModal() { document.getElementById('importModal').style.display = 'flex'; document.getElementById('importModalBody').innerHTML = renderImportDocs(); } function renderImportDocs() { return `
ไปฅไธ‹ๆ˜ฏๆ‰€ๆœ‰ๆ•ฐๆฎๆŽฅๅ…ฅๆŽฅๅฃใ€‚ๆ”ฏๆŒๅฎšๆ—ถไปปๅŠก๏ผˆcron๏ผ‰ใ€ETL ็ฎก้“็›ดๆŽฅ่ฐƒ็”จใ€‚
POST /api/import/users ๆ‰น้‡ๅฏผๅ…ฅ/ๆ›ดๆ–ฐ็”จๆˆทๅŸบ็ก€ไฟกๆฏ
{
  "source": "crm_sync",
  "users": [
    { "uid": "u_001", "name": "Alice", "email": "alice@example.com" },
    { "uid": "u_002", "name": "Bob",   "email": "bob@example.com",
      "extra_json": { "plan": "pro", "country": "US" } }
  ]
}
โœ… Upsert๏ผšๅทฒๅญ˜ๅœจ็š„ uid ไผšๆ›ดๆ–ฐ๏ผŒๆ–ฐ uid ไผšๆ’ๅ…ฅ
โœ… ๆ‰น้‡ๆไบค๏ผšๆฏ 1000 ๆกไธ€ไธชไบ‹ๅŠก๏ผŒ้ฟๅ…้”่ถ…ๆ—ถ
โœ… ่ฟ”ๅ›ž๏ผš{ batchId, imported, total }
POST /api/import/user-tags ๆ‰น้‡ๅปบ็ซ‹็”จๆˆทโ†”ๆ ‡็ญพๅ…ณ่”
{
  "source": "ml_model_v2",
  "mode": "replace",
  "assignments": [
    { "uid": "u_001", "tagKey": "sub_plus" },
    { "uid": "u_001", "tagKey": "uc_coding" }
  ]
}
โœ… mode=replace๏ผšๅ…ˆๅˆ ้™ค่ฏฅ็”จๆˆทๅ…จ้ƒจๆ—งๆ ‡็ญพ
โœ… mode=append๏ผšไป…่ฟฝๅŠ ๏ผŒ้€‚ๅˆๅขž้‡ๆ›ดๆ–ฐ
โœ… ่‡ชๅŠจ้‡ๆ–ฐ่ฎก็ฎ—ๆ‰€ๆœ‰ๆ ‡็ญพ่ฆ†็›–็އ
GET /api/import/batches ๆŸฅ็œ‹ๅฏผๅ…ฅๅކๅฒ่ฎฐๅฝ•
่ฟ”ๅ›žๆœ€่ฟ‘ 50 ๆกๅฏผๅ…ฅๆ‰นๆฌก๏ผŒๅซ็Šถๆ€ใ€่ฎฐๅฝ•ๆ•ฐใ€่€—ๆ—ถใ€‚
๐Ÿ’ก ๆŽจ่ๆŽฅๅ…ฅๆ–นๅผ๏ผš
โ€ข ๅฎšๆœŸๅ…จ้‡๏ผšๆฏๆ—ฅๅ‡Œๆ™จ cron๏ผŒ่ฐƒ็”จ import/users + import/user-tags(mode=replace)
โ€ข ๅฎžๆ—ถๅขž้‡๏ผš็”จๆˆท่กŒไธบไบ‹ไปถ่งฆๅ‘๏ผŒappend ๆจกๅผ่ฟฝๅŠ ๆ–ฐๆ ‡็ญพ
โ€ข ML ๆจกๅž‹่พ“ๅ‡บ๏ผš้ข„ๆต‹ๆจกๅž‹ๆฏๅ‘จ่ท‘ไธ€ๆฌก๏ผŒๆ‰น้‡ๅ†™ๅ…ฅๅ€พๅ‘ๆ ‡็ญพ
`; } function closeModal(id) { document.getElementById(id).style.display = 'none'; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Overlay // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let overlayTimer; function showOverlay(show) { clearTimeout(overlayTimer); const el = document.getElementById('computeOverlay'); if (show) { // ๅปถ่ฟŸ 300ms ๆ˜พ็คบ๏ผŒ้ฟๅ…ๆž้€Ÿๅ“ๅบ”ๆ—ถ็š„ๅฑๅน•้—ช็ƒ overlayTimer = setTimeout(() => { el.style.display = 'flex'; }, 300); } else { el.style.display = 'none'; } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Utilities // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function fmtNum(n) { if (n === null || n === undefined) return 'โ€”'; if (n >= 10000) return (n / 10000).toFixed(1) + 'w'; return n.toLocaleString('zh-CN'); } async function apiFetch(url, opts = {}) { try { const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); } catch (e) { if (e.name !== 'AbortError') console.error(url, e); return null; } } function findTag(id) { for (const cat of state.categories) { const t = cat.tags.find(t => t.id === id); if (t) return { ...t, _color: cat.color }; } return null; } function findTagById(id) { return findTag(id); } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // showPanel (global) // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ window.showPanel = function(type) { if (type === 'import') { showImportModal(); return; } const panel = document.getElementById('rightPanel'); const title = document.getElementById('rpTitle'); const body = document.getElementById('rpBody'); panel.style.display = 'flex'; if (type === 'sample') { title.textContent = '๐Ÿ‘ฅ ็”จๆˆทๆ ทๆœฌ'; loadSample(body); } else if (type === 'duration') { title.textContent = '๐Ÿ“Š ๆŒ‡ๅฏผๅ‘จๆœŸๅˆ†ๆž'; loadDurationStats(body); } }; // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Keyboard shortcuts // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal('importModal'); closePanel(); removeContextMenu(); } if (e.key === 'Enter' && state.selected.size > 0 && !e.target.matches('input,textarea')) { compute(); } if (e.key === 'r' && !e.target.matches('input,textarea')) resetAll(); }); document.getElementById('importModal')?.addEventListener('click', e => { if (e.target.id === 'importModal') closeModal('importModal'); }); document.getElementById('computeOverlay')?.addEventListener('click', () => showOverlay(false)); // Boot init();