// ==========================================
// 🚀 통합 분석 센터 (Server: Code.gs) v4.0 - 회귀분석 추가
// ==========================================
// 1. 설정: Gemini API 키
const GEMINI_API_KEY = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
const GEMINI_MODEL = 'gemini-2.5-pro';
// 2. 메뉴 생성
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('🚀 통합 분석 센터')
.addItem('📊 대시보드 열기', 'showDashboard')
.addToUi();
}
// 3. HTML 대시보드 띄우기
function showDashboard() {
const html = HtmlService.createTemplateFromFile('Dashboard')
.evaluate()
.setTitle('📊 마케팅 데이터 종합 분석')
.setWidth(1200)
.setHeight(900);
SpreadsheetApp.getUi().showModalDialog(html, ' ');
}
// 4. [API] 데이터 전송 함수 (기초 통계용)
function getFullData() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dataSheet = ss.getSheetByName('Data_Lake');
if (!dataSheet) throw new Error("'Data_Lake' 시트가 없습니다.");
const range = dataSheet.getDataRange();
const rawValues = range.getValues();
if (rawValues.length < 2) throw new Error("데이터가 부족합니다.");
const headers = rawValues[0];
const rows = rawValues.slice(1).filter(r => r[0] !== "");
const stats = calculateBasicStats(headers, rows);
return { headers: headers, rows: rows, stats: stats };
}
// 5. 기초 통계 계산 로직
function calculateBasicStats(headers, rows) {
let stats = {};
headers.forEach((col, idx) => {
if (typeof rows[0][idx] === 'number') {
let vals = rows.map(r => r[idx]).filter(v => typeof v === 'number');
if (vals.length > 0) {
let mean = vals.reduce((a, b) => a + b, 0) / vals.length;
let sorted = [...vals].sort((a, b) => a - b);
let variance = vals.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / vals.length;
stats[col] = {
mean: mean.toFixed(2),
median: sorted[Math.floor(sorted.length / 2)],
min: sorted[0],
max: sorted[sorted.length - 1],
std: Math.sqrt(variance).toFixed(2),
count: vals.length
};
}
}
});
return stats;
}
// 6. AI 리포트 생성 함수 (EDA용)
function askGeminiInsight(mode, contextData) {
const prompt = `
데이터 분석가로서 아래 통계 데이터를 보고 핵심을 요약해주세요.
모드: ${mode}
데이터: ${JSON.stringify(contextData).substring(0, 3000)}
[작성 규칙]
1. 서론/결론 제외, 바로 본론 시작.
2. 다음 4가지 관점에서 각각 1~2줄로 요약:
- 분포 특성, 이상치, 상관관계, 판매량 영향 요인
3. 마크다운 형식으로 작성.
`;
return callGeminiAPI(prompt, true);
}
// ==========================================
// 🚀 [NEW] 회귀분석 및 시나리오 핸들러 (최종 수정됨)
// ==========================================
// 7-1. [버튼 1] 회귀분석 실행 (계수 + 리포트 + 시트 저장)
function runRegressionFromDashboard() {
const csvString = getCsvData();
const prompt = `
당신은 데이터 과학자입니다. 아래 마케팅 데이터를 바탕으로 'Product_Sold'를 종속변수로 하는 다중회귀분석을 수행하세요.
[데이터]
${csvString}
[요청사항]
1. 각 채널(변수)의 회귀계수(Coefficient)와 절편(Intercept)을 추정하세요.
2. 회귀분석 결과에 대한 비즈니스 인사이트 리포트를 작성하세요.
3. 결과는 반드시 아래 JSON 포맷으로 출력하세요.
{
"coefficients": { "TV": 0.1, "Social_Media": 0.5, "Intercept": 10.0 },
"report": "여기에 마크다운 형식으로 분석 결과를 서술하세요."
}
`;
const result = callGeminiAPI(prompt, false);
// model_summary 시트에 결과 저장
saveRegressionToSheet(result);
return result;
}
// 7-2. [버튼 2] 시나리오 제안 (제약조건 반영 + 시트 저장)
function runScenarioFromDashboard(totalBudget, channels, minShares, maxShares) {
const csvString = getCsvData();
let constraintsInfo = "";
for(let i=0; i<channels.length; i++) {
constraintsInfo += `- ${channels[i]}: 최소 ${(minShares[i]*100).toFixed(0)}% ~ 최대 ${(maxShares[i]*100).toFixed(0)}%\n`;
}
const prompt = `
당신은 CMO(최고 마케팅 책임자)입니다.
데이터를 분석하여 총 예산 $${totalBudget}에 대한 최적의 배분 시나리오 3가지를 제안하세요.
[데이터]
${csvString}
[제약 조건 (반드시 준수)]
${constraintsInfo}
[요청사항]
1. 위 제약 조건을 지키면서 3가지 전략(ROI 극대화, 리스크 분산, 실험적)을 수립하세요.
2. 각 시나리오별 예상 판매량(expected_sales)을 데이터에 기반해 추산하세요.
3. 결과는 반드시 아래 JSON 포맷으로 출력하세요.
{
"scenarios": [
{
"name": "1. ROI 극대화형",
"description": "전략 설명...",
"allocation": { "TV": 1000, "Social_Media": 5000, ... },
"expected_sales": 15000
}
]
}
`;
const result = callGeminiAPI(prompt, false);
// 프론트엔드가 'predictedSales'를 기대하므로 필드명 매핑
if (result.scenarios) {
result.scenarios.forEach(s => {
// expected_sales가 있으면 predictedSales로 복사 (대소문자 무관하게 처리)
if (s.expected_sales !== undefined) s.predictedSales = s.expected_sales;
if (s.PredictedSales !== undefined) s.predictedSales = s.PredictedSales;
});
}
// optimizer_ui 시트에 결과 저장
saveScenariosToSheet(result, totalBudget);
return result;
}
// [Helper] 데이터 CSV 변환 (콤마 처리 강화)
function getCsvData() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dataSheet = ss.getSheetByName('Data_Lake');
if (!dataSheet) throw new Error("'Data_Lake' 시트가 없습니다.");
const rawValues = dataSheet.getDataRange().getValues();
if (rawValues.length < 10) throw new Error("데이터가 너무 적습니다.");
// 콤마가 들어간 데이터가 있을 경우를 대비해 안전하게 처리
return rawValues.map(row =>
row.map(cell => (cell === null ? "" : cell.toString().replace(/,/g, ""))).join(",")
).join("\n");
}
// [Helper] Gemini API 호출 통합 함수 (JSON 파싱 강화)
function callGeminiAPI(prompt, isTextMode) {
if (!GEMINI_API_KEY) throw new Error("API 키가 설정되지 않았습니다.");
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(url, options);
const json = JSON.parse(response.getContentText());
if (json.error) throw new Error(json.error.message);
let content = json.candidates[0].content.parts[0].text;
// 텍스트 모드면 바로 반환
if (isTextMode) return content;
// JSON 파싱 강화 (앞뒤 잡담 제거)
// 1. 마크다운 제거
content = content.replace(/```json/g, "").replace(/```/g, "").trim();
// 2. { 로 시작해서 } 로 끝나는 부분만 추출
const firstBrace = content.indexOf("{");
const lastBrace = content.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
content = content.substring(firstBrace, lastBrace + 1);
}
return JSON.parse(content);
} catch (e) {
throw new Error("AI 호출 실패: " + e.message);
}
}
// [Helper] 회귀분석 결과 시트 저장
function saveRegressionToSheet(result) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('model_summary');
if (!sheet) {
sheet = ss.insertSheet('model_summary');
} else {
sheet.clear();
}
// 헤더 작성
sheet.getRange("A1:C1").setValues([["변수명", "계수", "분석 리포트"]]);
sheet.getRange("A1:C1").setBackground("#EFEFEF").setFontWeight("bold");
// 데이터 작성
let rows = [];
const coeffs = result.coefficients;
for (let key in coeffs) {
rows.push([key, coeffs[key], ""]);
}
if (rows.length > 0) {
sheet.getRange(2, 1, rows.length, 3).setValues(rows);
// 리포트는 첫 번째 행의 C열에 넣고 병합
sheet.getRange(2, 3).setValue(result.report);
sheet.getRange(2, 3, rows.length, 1).merge().setVerticalAlignment("top").setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
}
}
// [Helper] 시나리오 결과 시트 저장
function saveScenariosToSheet(result, totalBudget) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('optimizer_ui');
if (!sheet) {
sheet = ss.insertSheet('optimizer_ui');
} else {
sheet.clear();
}
sheet.getRange("A1").setValue(`총 예산: $${totalBudget}`);
// 데이터 준비
const scenarios = result.scenarios;
if (!scenarios || scenarios.length === 0) return;
// 헤더 만들기
const channelKeys = Object.keys(scenarios[0].allocation);
const headers = ["시나리오명", "설명", "예상 판매량", ...channelKeys];
let startRow = 3;
sheet.getRange(startRow, 1, 1, headers.length).setValues([headers]).setBackground("#EFEFEF").setFontWeight("bold");
let outputData = [];
scenarios.forEach(s => {
let row = [
s.name,
s.description,
s.predictedSales || s.expected_sales || 0 // 필드명 방어
];
channelKeys.forEach(ch => {
row.push(s.allocation[ch] || 0);
});
outputData.push(row);
});
if (outputData.length > 0) {
sheet.getRange(startRow + 1, 1, outputData.length, headers.length).setValues(outputData);
}
}<!DOCTYPE html>
<html>
<head>
<base target="_top">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/@sgratzl/chartjs-chart-boxplot@3.6.0/build/index.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background: #f3f4f6; padding: 20px; color: #1f2937; margin: 0; }
/* 헤더 */
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
h1 { margin: 0; font-size: 1.5rem; color: #111827; }
/* 탭 메뉴 */
.tabs { display: flex; gap: 10px; margin-bottom: 20px; background: white; padding: 8px; border-radius: 12px; }
.tab-btn { flex: 1; padding: 12px; border: none; background: transparent; cursor: pointer; font-weight: 600; color: #6b7280; border-radius: 8px; transition: 0.2s; }
.tab-btn:hover { background: #f9fafb; }
.tab-btn.active { background: #3b82f6; color: white; box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.5); }
/* 컨텐츠 섹션 */
.view-section { display: none; }
.view-section.active { display: block; }
.section-title { font-size: 1.2rem; font-weight: 800; margin: 30px 0 15px 0; border-left: 5px solid #3b82f6; padding-left: 10px; color: #1f2937; }
.card { background: white; border-radius: 16px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); margin-bottom: 20px; position: relative; }
/* 차트 그리드 */
.chart-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; }
.chart-box { height: 250px; position: relative; }
/* 다운로드 버튼 */
.download-btn {
position: absolute; top: 10px; right: 10px;
background: white; border: 1px solid #e5e7eb; border-radius: 6px;
padding: 4px 8px; font-size: 0.75rem; cursor: pointer; color: #6b7280;
z-index: 10;
}
.download-btn:hover { background: #f3f4f6; color: #3b82f6; }
/* 히트맵 테이블 */
.heatmap-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; text-align: center; }
.heatmap-table th, .heatmap-table td { padding: 8px; border: 1px solid #eee; }
.heatmap-table th { background: #f9fafb; font-weight: 600; }
/* AI 박스 */
.ai-box { background: #eff6ff; border: 1px solid #bfdbfe; padding: 20px; border-radius: 12px; line-height: 1.6; margin-bottom: 20px; }
.ai-header { font-weight: bold; color: #1e40af; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
/* 로딩 */
#loading { display: none; text-align: center; padding: 50px; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #3b82f6; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* 도움말 탭 스타일 */
.guide-box { background: #fff; padding: 20px; border-radius: 12px; margin-bottom: 15px; border-left: 4px solid #10b981; }
.guide-title { font-weight: bold; font-size: 1.1rem; color: #059669; margin-bottom: 5px; }
</style>
</head>
<body>
<header>
<h1>🚀 마케팅 데이터 종합 분석</h1>
<div style="font-size:0.8rem; color:#6b7280;">Live Connected</div>
</header>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('basic')">📋 기초 통계 (종합)</button>
<button class="tab-btn" onclick="switchTab('reg')">💰 회귀분석(버짓제안)</button>
<button class="tab-btn" onclick="switchTab('guide')">📘 분석 가이드 (Help)</button>
</div>
<div id="loading">
<div class="spinner"></div>
<p style="margin-top:10px; color:#6b7280;">데이터를 분석하고 차트를 생성 중입니다...</p>
</div>
<div id="view-basic" class="view-section">
<div class="card">
<div class="ai-box">
<div class="ai-header">🤖 AI 데이터 인사이트</div>
<div id="ai-basic-report">"기초 통계 (종합) 버튼 클릭해 주세요"</div>
</div>
</div>
<div class="section-title">1. 데이터 분포 (Distributions)</div>
<div class="chart-grid" id="dist-grid"></div>
<div class="section-title">2. 이상치 확인 (Boxplots)</div>
<div class="chart-grid" id="box-grid"></div>
<div class="section-title">3. 판매량과의 관계 (Scatter vs Product_Sold)</div>
<div class="chart-grid" id="scatter-grid"></div>
<div class="section-title">4. 상관관계 히트맵 (Correlation Matrix)</div>
<div class="card" style="overflow-x:auto;">
<table class="heatmap-table" id="heatmap-table"></table>
</div>
</div>
<div id="view-guide" class="view-section">
<div class="guide-box">
<div class="guide-title">📊 히스토그램 (Histogram)</div>
<p>데이터가 어디에 많이 몰려있는지 보여줍니다. 막대가 높은 구간이 가장 흔한 값입니다. 만약 한쪽으로 치우쳐 있다면(비대칭), 평균값 사용에 주의해야 합니다.</p>
</div>
<div class="guide-box">
<div class="guide-title">📦 박스 플롯 (Box Plot)</div>
<p>데이터의 퍼짐 정도와 '이상치(Outlier)'를 찾습니다. 박스 위아래에 점이 찍혀 있다면, 다른 값들과 동떨어진 특이한 데이터가 있다는 뜻입니다.</p>
</div>
<div class="guide-box">
<div class="guide-title">📉 산점도 (Scatter Plot)</div>
<p>두 변수의 관계를 점으로 찍어 보여줍니다. 점들이 우상향하면 '양의 상관관계(비례)', 우하향하면 '음의 상관관계(반비례)'입니다. 점들이 뭉쳐있을수록 관계가 강합니다.</p>
</div>
<div class="guide-box">
<div class="guide-title">🔗 상관계수 (Correlation Coefficient)</div>
<p><strong>+1에 가까울수록:</strong> 아주 강한 양의 관계 (하나가 늘면 다른 것도 확실히 늠)<br>
<strong>0에 가까울수록:</strong> 관계 없음<br>
<strong>-1에 가까울수록:</strong> 아주 강한 음의 관계 (하나가 늘면 다른 건 줄어듦)</p>
</div>
</div>
<div id="view-reg" class="view-section">
<!-- 회귀 분석 카드 -->
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<div class="section-title" style="margin:0; border-left:none; padding-left:0;">1. 회귀분석 결과</div>
<button class="tab-btn" style="flex:none; padding:8px 12px;" onclick="runRegression()">📈 회귀분석 실행</button>
</div>
<div id="reg-report" class="ai-box" style="margin-top:10px;">
<div class="ai-header">🤖 회귀 기반 인사이트 리포트</div>
<div id="reg-report-body">"회귀분석 실행" 버튼을 클릭</div>
</div>
<div class="section-title" style="margin-top:20px;">계수 테이블</div>
<div id="reg-coeff-table"></div>
</div>
<!-- 산점도 + 회귀선 -->
<div class="section-title">2. 채널별 판매량 관계 (산점도 + 회귀선)</div>
<div class="chart-grid" id="reg-scatter-grid"></div>
<!-- 시나리오 카드 -->
<div class="card" style="margin-top:20px;">
<div class="section-title" style="margin:0; border-left:none; padding-left:0;">3. 예산 시나리오 (3가지 전략)</div>
<div style="margin-top:10px;">
<label>총 예산 ($): </label>
<input id="scenario-total-budget" type="number" style="width:120px; padding:4px; margin-right:20px;" />
</div>
<div style="margin-top:10px; overflow-x:auto;">
<table class="heatmap-table" style="font-size:0.8rem; min-width:600px;">
<thead>
<tr>
<th>채널</th>
<th>최소 비중 (0~1)</th>
<th>최대 비중 (0~1)</th>
</tr>
</thead>
<tbody>
<!-- 고정 6개 채널 -->
<tr>
<td>TV</td>
<td><input id="min-TV" type="number" step="0.01" style="width:80px;" value="0.05"></td>
<td><input id="max-TV" type="number" step="0.01" style="width:80px;" value="0.5"></td>
</tr>
<tr>
<td>Billboards</td>
<td><input id="min-Billboards" type="number" step="0.01" style="width:80px;" value="0.05"></td>
<td><input id="max-Billboards" type="number" step="0.01" style="width:80px;" value="0.5"></td>
</tr>
<tr>
<td>Google_Ads</td>
<td><input id="min-Google_Ads" type="number" step="0.01" style="width:80px;" value="0.05"></td>
<td><input id="max-Google_Ads" type="number" step="0.01" style="width:80px;" value="0.5"></td>
</tr>
<tr>
<td>Social_Media</td>
<td><input id="min-Social_Media" type="number" step="0.01" style="width:80px;" value="0.05"></td>
<td><input id="max-Social_Media" type="number" step="0.01" style="width:80px;" value="0.5"></td>
</tr>
<tr>
<td>Influencer_Marketing</td>
<td><input id="min-Influencer_Marketing" type="number" step="0.01" style="width:80px;" value="0.05"></td>
<td><input id="max-Influencer_Marketing" type="number" step="0.01" style="width:80px;" value="0.5"></td>
</tr>
<tr>
<td>Affiliate_Marketing</td>
<td><input id="min-Affiliate_Marketing" type="number" step="0.01" style="width:80px;" value="0.05"></td>
<td><input id="max-Affiliate_Marketing" type="number" step="0.01" style="width:80px;" value="0.5"></td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top:10px; text-align:right;">
<button class="tab-btn" style="flex:none; padding:8px 12px;" onclick="runScenarios()">💡 3가지 전략 시나리오 제안</button>
</div>
<div id="scenario-result" style="margin-top:15px;"></div>
</div>
</div>
<script>
let globalData = null;
// 초기 로딩
window.onload = function() {
document.getElementById('loading').style.display = 'block';
google.script.run.withSuccessHandler(initDashboard).getFullData();
};
function initDashboard(data) {
globalData = data;
document.getElementById('loading').style.display = 'none';
switchTab('basic');
}
function switchTab(tabName) {
document.querySelectorAll('.view-section').forEach(el => el.classList.remove('active'));
document.getElementById('view-' + tabName).classList.add('active');
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
event.target.classList.add('active');
if (tabName === 'basic') renderBasicAnalysis();
// 회귀 탭은 버튼 클릭으로 동작. 필요하면 여기서도 렌더 호출 가능.
}
// === 종합 분석 렌더링 ===
function renderBasicAnalysis() {
const headers = globalData.headers;
const rows = globalData.rows;
const numericHeaders = headers.filter(h => globalData.stats[h]);
const targetVar = 'Product_Sold';
callAI('EDA_SUMMARY', globalData.stats, 'ai-basic-report');
// 1. 분포 (Histograms)
const distGrid = document.getElementById('dist-grid');
distGrid.innerHTML = '';
numericHeaders.forEach(col => {
const div = createChartCard(`dist-${col}`);
distGrid.appendChild(div);
const vals = rows.map(r => r[headers.indexOf(col)]);
drawHistogram(`dist-${col}`, vals, col);
});
// 2. 이상치 (Boxplots)
const boxGrid = document.getElementById('box-grid');
boxGrid.innerHTML = '';
numericHeaders.forEach(col => {
const div = createChartCard(`box-${col}`);
boxGrid.appendChild(div);
const vals = rows.map(r => r[headers.indexOf(col)]);
drawBoxplot(`box-${col}`, vals, col);
});
// 3. 산점도 (Scatter)
const scatterGrid = document.getElementById('scatter-grid');
scatterGrid.innerHTML = '';
numericHeaders.forEach(col => {
if (col === targetVar) return;
const div = createChartCard(`scat-${col}`);
scatterGrid.appendChild(div);
const xVals = rows.map(r => r[headers.indexOf(col)]);
const yVals = rows.map(r => r[headers.indexOf(targetVar)]);
drawScatter(`scat-${col}`, xVals, yVals, col, targetVar);
});
renderHeatmap(numericHeaders, rows, headers);
}
// 차트 카드 생성 (다운로드 버튼 포함)
function createChartCard(canvasId) {
const div = document.createElement('div');
div.className = 'card chart-box';
div.innerHTML = `
<button class="download-btn" onclick="downloadChart('${canvasId}')">📷 저장</button>
<canvas id="${canvasId}"></canvas>
`;
return div;
}
// 차트 이미지 다운로드 함수
function downloadChart(canvasId) {
const link = document.createElement('a');
link.download = canvasId + '.png';
link.href = document.getElementById(canvasId).toDataURL();
link.click();
}
// --- 차트 그리기 헬퍼 함수들 ---
function drawHistogram(canvasId, data, label) {
const ctx = document.getElementById(canvasId).getContext('2d');
const bins = 10;
const min = Math.min(...data);
const max = Math.max(...data);
const step = (max - min) / bins;
let freq = new Array(bins).fill(0);
let labels = [];
for(let i=0; i<bins; i++) labels.push((min + step*i).toFixed(0));
data.forEach(v => {
let bucket = Math.floor((v - min) / step);
if (bucket >= bins) bucket = bins - 1;
freq[bucket]++;
});
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{ label: label, data: freq, backgroundColor: '#3b82f6', borderRadius: 4 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, title: { display: true, text: label } }, scales: { x: { display: false } } }
});
}
function drawBoxplot(canvasId, data, label) {
const ctx = document.getElementById(canvasId).getContext('2d');
new Chart(ctx, {
type: 'boxplot',
data: {
labels: [label],
datasets: [{ label: label, data: [data], backgroundColor: 'rgba(59, 130, 246, 0.5)', borderColor: '#3b82f6', borderWidth: 1 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, title: { display: true, text: label } } }
});
}
function drawScatter(canvasId, xData, yData, xLabel, yLabel) {
const ctx = document.getElementById(canvasId).getContext('2d');
const points = xData.map((x, i) => ({ x: x, y: yData[i] }));
new Chart(ctx, {
type: 'scatter',
data: { datasets: [{ label: `${xLabel} vs Sales`, data: points, backgroundColor: 'rgba(16, 185, 129, 0.6)', pointRadius: 3 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, title: { display: true, text: `${xLabel} vs Sales` } }, scales: { x: { display: true, title: {display:false} }, y: { display: false } } }
});
}
function renderHeatmap(vars, rows, allHeaders) {
const table = document.getElementById('heatmap-table');
let html = '<thead><tr><th></th>';
vars.forEach(v => html += `<th>${v}</th>`);
html += '</tr></thead><tbody>';
vars.forEach((v1) => {
html += `<tr><th>${v1}</th>`;
vars.forEach((v2) => {
const val1 = rows.map(r => r[allHeaders.indexOf(v1)]);
const val2 = rows.map(r => r[allHeaders.indexOf(v2)]);
const corr = calculateCorr(val1, val2);
const color = corr > 0 ? `rgba(239, 68, 68, ${corr})` : `rgba(59, 130, 246, ${Math.abs(corr)})`;
const textColor = Math.abs(corr) > 0.5 ? 'white' : 'black';
html += `<td style="background:${color}; color:${textColor}">${corr.toFixed(2)}</td>`;
});
html += '</tr>';
});
html += '</tbody>';
table.innerHTML = html;
}
function calculateCorr(x, y) {
let n = x.length;
let sumX = x.reduce((a, b) => a + b, 0);
let sumY = y.reduce((a, b) => a + b, 0);
let sumXY = x.reduce((s, xi, i) => s + xi * y[i], 0);
let sumX2 = x.reduce((s, xi) => s + xi * xi, 0);
let sumY2 = y.reduce((s, yi) => s + yi * yi, 0);
let num = (n * sumXY) - (sumX * sumY);
let den = Math.sqrt((n * sumX2 - sumX*sumX) * (n * sumY2 - sumY*sumY));
return den === 0 ? 0 : num / den;
}
function callAI(mode, context, elementId) {
google.script.run.withSuccessHandler(text => {
document.getElementById(elementId).innerHTML = marked.parse(text);
}).askGeminiInsight(mode, context);
}
let regressionResult = null;
function runRegression() {
if (!globalData) {
alert("데이터가 아직 로드되지 않았습니다.");
return;
}
document.getElementById('reg-report-body').innerText = "Gemini가 회귀분석을 수행 중입니다...";
google.script.run
.withSuccessHandler(showRegressionResult)
.withFailureHandler(showError)
.runRegressionFromDashboard();
}
function showRegressionResult(result) {
regressionResult = result;
const report = result.report || "(리포트 없음)";
const coeffs = result.coefficients || {};
// 리포트 (마크다운 → HTML)
document.getElementById('reg-report-body').innerHTML = marked.parse(report);
// 계수 테이블
const tableDiv = document.getElementById('reg-coeff-table');
const order = ["TV", "Billboards", "Google_Ads", "Social_Media", "Influencer_Marketing", "Affiliate_Marketing", "Intercept"];
let html = '<table class="heatmap-table"><thead><tr><th>변수명</th><th>계수</th></tr></thead><tbody>';
order.forEach(k => {
if (coeffs.hasOwnProperty(k)) {
html += `<tr><td>${k}</td><td>${coeffs[k]}</td></tr>`;
}
});
html += '</tbody></table>';
tableDiv.innerHTML = html;
// 산점도 + 회귀선 렌더
renderRegressionScatters();
}
function computeSimpleRegression(x, y) {
const n = x.length;
if (!n) return { slope: 0, intercept: 0 };
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (let i = 0; i < n; i++) {
sumX += x[i];
sumY += y[i];
sumXY += x[i] * y[i];
sumX2 += x[i] * x[i];
}
const denom = (n * sumX2 - sumX * sumX);
if (denom === 0) return { slope: 0, intercept: sumY / n };
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
return { slope, intercept };
}
function renderRegressionScatters() {
const targetVar = 'Product_Sold';
const headers = globalData.headers;
const rows = globalData.rows;
const scatterGrid = document.getElementById('reg-scatter-grid');
scatterGrid.innerHTML = '';
const channelNames = ['TV', 'Billboards', 'Google_Ads', 'Social_Media', 'Influencer_Marketing', 'Affiliate_Marketing'];
channelNames.forEach(col => {
const xIdx = headers.indexOf(col);
const yIdx = headers.indexOf(targetVar);
if (xIdx === -1 || yIdx === -1) return;
const xVals = rows.map(r => Number(r[xIdx])).filter(v => !isNaN(v));
const yValsRaw = rows.map(r => Number(r[yIdx]));
const yVals = yValsRaw.filter((v, i) => !isNaN(xVals[i]) && !isNaN(v));
if (!xVals.length || !yVals.length) return;
const div = createChartCard(`regscat-${col}`);
scatterGrid.appendChild(div);
const points = [];
for (let i = 0; i < xVals.length; i++) {
points.push({ x: xVals[i], y: yVals[i] });
}
const { slope, intercept } = computeSimpleRegression(xVals, yVals);
const minX = Math.min(...xVals);
const maxX = Math.max(...xVals);
const linePoints = [
{ x: minX, y: slope * minX + intercept },
{ x: maxX, y: slope * maxX + intercept }
];
const ctx = document.getElementById(`regscat-${col}`).getContext('2d');
new Chart(ctx, {
type: 'scatter',
data: {
datasets: [
{
type: 'scatter',
label: `${col} vs Product_Sold`,
data: points,
backgroundColor: 'rgba(16, 185, 129, 0.6)',
pointRadius: 3
},
{
type: 'line',
label: '회귀선',
data: linePoints,
borderColor: 'rgba(37, 99, 235, 0.9)',
borderWidth: 2,
fill: false,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
title: { display: true, text: `${col} vs Product_Sold (회귀선 포함)` }
},
scales: {
x: { display: true, title: { display: true, text: col } },
y: { display: true, title: { display: true, text: 'Product_Sold' } }
}
}
});
});
}
function runScenarios() {
const totalBudget = Number(document.getElementById('scenario-total-budget').value || 0);
if (!totalBudget || totalBudget <= 0) {
alert("총 예산을 입력해 주세요.");
return;
}
const channels = ['TV', 'Billboards', 'Google_Ads', 'Social_Media', 'Influencer_Marketing', 'Affiliate_Marketing'];
const minShares = [];
const maxShares = [];
channels.forEach(ch => {
const minVal = Number(document.getElementById('min-' + ch).value || 0);
const maxVal = Number(document.getElementById('max-' + ch).value || 1);
minShares.push(minVal);
maxShares.push(maxVal);
});
const resultDiv = document.getElementById('scenario-result');
resultDiv.innerHTML = "Gemini가 3가지 전략 시나리오를 생성 중입니다...";
google.script.run
.withSuccessHandler(showScenarioResult)
.withFailureHandler(showError)
.runScenarioFromDashboard(totalBudget, channels, minShares, maxShares);
}
function showScenarioResult(result) {
const scenarios = result.scenarios || [];
if (!scenarios.length) {
document.getElementById('scenario-result').innerHTML = "시나리오 결과가 없습니다.";
return;
}
let html = '';
scenarios.forEach(s => {
html += `
<div class="ai-box" style="margin-bottom:10px;">
<div class="ai-header">${s.name || '시나리오'}</div>
<div style="margin-bottom:8px;">${(s.description || '').replace(/\n/g, '<br>')}</div>
<div><strong>📈 예상 판매량:</strong> ${s.predictedSales || 0}</div>
<div style="margin-top:8px;">
<strong>채널별 예산 배분:</strong>
<ul style="margin:4px 0 0 18px; padding:0;">
`;
const alloc = s.allocation || {};
Object.keys(alloc).forEach(ch => {
html += `<li>${ch}: $${Math.round(alloc[ch])}</li>`;
});
html += `
</ul>
</div>
</div>
`;
});
document.getElementById('scenario-result').innerHTML = html;
}
function showError(err) {
console.error(err);
alert("오류가 발생했습니다: " + err);
const regReportEl = document.getElementById('reg-report-body');
const scenarioResultEl = document.getElementById('scenario-result');
if (regReportEl) regReportEl.innerText = "회귀분석 중 오류가 발생했습니다.";
if (scenarioResultEl) scenarioResultEl.innerText = "시나리오 생성 중 오류가 발생했습니다.";
}
</script>
</body>
</html>아래 v3 코드를 읽고,
- 어떤 기능을 하는지
- 데이터가 어떻게 흐르는지
- 어떤 화면(UI)과 분석이 구현되어 있는지
를 비전공자도 이해할 수 있게 간단히 설명해 주세요.
(이 단계에서는 수정 없이 “설명”만 합니다.)
(여기에 v3 Code.gs)
(여기에 v3 Dashboard.html)
혹은 첨부v3 팝업 대시보드를 아래 기능을 포함한 v4로 확장하기 위한
“전체 시스템 설계도”를 작성해 주세요.
필요한 기능:
- 회귀분석(요약문 + 계수표 + 채널별 산점도+회귀선)
- 예산 시나리오 3종 생성(전략 설명 + 예상 판매량)
- AI 인사이트 확장
- 기존 EDA 탭 유지, 새로운 탭 추가
설계도에는 다음을 포함해 주세요:
- 어떤 탭이 존재하며 각각 어떤 UI 요소가 필요한지
- 백엔드(GAS)와 프론트엔드(HTML/JS)가 어떻게 연결되는지
- 어떤 계산이 어디에서 수행되는지
- 자동 생성 시트(model_summary, optimizer_ui)의 역할과 데이터 흐름v3 Code.gs를 기반으로 아래 기능을 모두 포함하는
“완성된 v4 Code.gs 전체 파일”을 작성해 주세요.
반드시 포함해야 하는 기능:
1) 회귀분석 계산 및 결과 반환
2) 총예산 입력 → 3가지 예산 전략 생성 및 예상 판매량 계산
3) AI 인사이트 생성
4) 기존 getFullData() 및 v3 기능 유지
5) **model_summary 시트 자동 생성**
6) **optimizer_ui 시트 자동 생성**
시트 자동 생성 요건:
- 시트가 없으면 insertSheet()로 생성
- 필요한 헤더/기본 구조 자동 세팅
- 회귀분석 또는 시나리오 계산 시 활용 가능하도록 구성
요청:
- 비전공자도 이해하기 쉬운 주석 포함
- 그대로 붙여넣어 바로 실행 가능한 완성본이어야 함
출력 형식:
[BEGIN FINAL CODE.GS]
(여기에 완성된 v4 Code.gs 전체)
[END FINAL CODE.GS]v3 Dashboard.html을 기반으로, v4 기능을 모두 반영한
“완성된 Dashboard.html 전체 파일”을 작성해 주세요.
필수 구성:
- 기존 EDA 탭 유지
- 회귀분석 탭 추가
- 분석 실행 버튼
- 요약문 표시
- 계수표 표시
- 채널별 산점도+회귀선(Chart.js)
- 예산 시나리오 탭 추가
- 총 예산 입력
- 최소·최대 비중 입력
- 전략 3종 생성 버튼
- 전략 카드(전략명·설명·예상 판매량·배분표)
- marked.js, Chart.js 포함
- 백엔드 호출 기능 정상 연결
요청:
- 비전공자용 주석 포함
- 붙여넣으면 바로 동작하는 완성형 HTML/JS 파일
출력 형식:
[BEGIN FINAL DASHBOARD.HTML]
(여기에 완성된 v4 Dashboard.html 전체)
[END FINAL DASHBOARD.HTML]위에서 생성한 v4 Code.gs / Dashboard.html이
- 팝업에서 정상적으로 뜨는지
- 탭 전환이 모두 동작하는지
- 회귀/시나리오/EDA 모든 기능이 충돌 없이 실행되는지
- 자동 생성 시트(model_summary / optimizer_ui)가 정확히 동작하는지
- 백엔드와 프론트엔드 함수 연결이 일치하는지
를 점검해 주세요.
필요하면 코드 일부를 자동으로 수정하여
“서로 완벽히 호환되는 최종 버전”으로 정리해 주세요.
출력 형식:
[BEGIN FINAL CODE.GS]
(최종 정리된 Code.gs)
[END FINAL CODE.GS]
[BEGIN FINAL DASHBOARD.HTML]
(최종 정리된 Dashboard.html)
[END FINAL DASHBOARD.HTML]
비전공자용 “간단 사용 가이드”를 작성해 주세요.
필수 포함:
- Code.gs / Dashboard.html을 어디에 붙여넣는지
- 구글 시트에서 팝업 대시보드를 띄우는 방법
- 원본 데이터 시트 구성(광고비 + Product_Sold)
- 회귀분석 탭 사용법
- 예산 시나리오 탭 사용법
- **model_summary / optimizer_ui 시트는 자동 생성됨** 안내
길게 쓸 필요는 없고, “무엇을 어디서 누르면 되는지”를 간단히 정리해 주세요.당신은 Google Apps Script(GAS) + HTML/JS로 “구글 시트 팝업(modal) 기반 대시보드”를 설계·구현하는 시니어 엔지니어입니다.
저는 개발 비전공자인 광고/마케팅 실무자이고, 지금 가진 것은 v3 버전의 Code.gs / Dashboard.html 코드뿐입니다.
v4라는 코드는 아직 존재하지 않는다고 가정해 주세요.
======================================================================
🎯 목표
======================================================================
v3 팝업 대시보드를 기반으로, 아래 기능을 모두 포함하는 “v4 팝업 대시보드”를 **설계 + 구현 + 통합 + 간단 사용법 안내**까지 한 번에 완성해 주세요.
- v3와 동일하게 **웹앱이 아니라 구글 시트 메뉴에서 띄우는 팝업(modal) UI** 형태로 동작해야 합니다.
- 제가 할 일은 최종적으로 “Code.gs 전체”, “Dashboard.html 전체” 두 파일을 복사해서 붙여넣는 것뿐이어야 합니다.
======================================================================
📌 전제: v4에서 필요한 핵심 기능 (1~5단계 역할)
======================================================================
[1] v3 코드 구조 이해(간단 설명)
- v3 Code.gs / Dashboard.html이 현재 어떤 기능을 하는지
- 데이터가 어디서 어떻게 읽혀서 팝업에 전달되는지
- 팝업 화면(탭, 차트 등)이 어떤 구조로 그려지는지
이 세 가지를, **비전공자인 제가 이해할 수 있을 정도의 수준으로 짧게 요약해 주세요.
(이 단계에서는 코드를 수정하지 말고 “설명만” 해 주세요.)**
[2] v4 기능 요구사항에 맞는 전체 설계도
v3를 확장해서 v4를 만들기 위해, 아래 기능이 **어떻게 추가·연결**되어야 하는지 설계해 주세요.
2-1. 회귀분석 탭
- 광고비 → 판매량(Product_Sold) 관계를 분석하는 회귀분석 기능
- 분석 결과를 자연어 문장으로 요약
- 광고 채널별 산점도 + 추세선(회귀선)
- 변수(채널)별 중요도/계수 요약 표
- 이 모든 것을 볼 수 있는 **전용 탭(뷰)**
2-2. 예산 시나리오(버짓 제안) 탭
- “총 광고 예산”을 입력하면
- 예: 안정형 / 균형형 / 공격형 같은 **3가지 예산 전략** 자동 생성
- 각 전략마다 예상 판매량(Product_Sold 예측값)을 계산
- 각 전략별 채널 예산 배분 결과를 카드 형태/리스트 형태로 보여줌
- 이것을 위한 **전용 탭(뷰)**
2-3. AI 인사이트 확장
- 기존 v3의 “기초 통계 요약”뿐 아니라
- 회귀분석 결과에 대한 설명
- 예산 전략 3가지에 대한 설명
도 함께 자연어로 요약해 주는 인사이트 영역이 포함되어야 합니다.
2-4. UI 구조
- 기존 “기초 통계(EDA)” 탭은 유지
- 여기에 “회귀분석(버짓제안)” 탭 + “분석 가이드(Help)” 탭이 존재
- 전부 **구글 시트 팝업** 안에서 탭 전환으로 사용
- v3 스타일(Inter 폰트, 카드, 그리드, 색감)을 유지하면서 기능만 확장
이 단계에서는 **어떤 탭에 어떤 요소(버튼, 표, 차트, 텍스트)가 있어야 하는지**,
**백엔드와 프론트엔드가 어떤 식으로 데이터를 주고받을지**,
**어떤 계산이 어디에서 수행되는지**를 구조적으로 설명해 주세요.
[3] v4 백엔드 Code.gs 전체 구현
이제 v3 Code.gs를 기반으로, v4 기능을 수행하는 **완성된 Code.gs 전체 파일**을 작성해 주세요.
필수 조건:
- 기존 v3 기능(데이터 로딩, 기본 EDA 호출 등)은 유지
- 추가로 아래 기능을 포함해야 합니다.
3-1. 회귀분석 관련
- 스프레드시트에 있는 원본 데이터(광고비 + Product_Sold)를 이용해 회귀분석 수행
- 회귀 계수, 절편, 요약 통계 등을 계산해 반환
- 프론트엔드에서 사용할 수 있도록 적절한 JSON 구조로 반환
- Gemini(또는 유사 모델)를 사용해 “회귀 결과 설명” 텍스트 생성
3-2. 예산 시나리오 관련
- 입력받은 총 예산, 채널 목록, 채널별 최소/최대 비중 정보를 사용
- 3가지 서로 다른 전략(예: 안정형 / 균형형 / 공격형)의 예산 배분안을 계산
- 각 전략별 예산 배분 + 예상 판매량을 계산해 JSON 구조로 반환
- 필요하다면 AI를 사용해 각 전략에 대한 설명 문장을 생성
3-3. 자동 시트 생성 (중요)
아래 두 시트는 **사용자가 수동 생성할 필요 없이, Code.gs가 자동으로 생성**해야 합니다.
- model_summary 시트
- 회귀분석 결과(계수, 요약 텍스트 등)를 저장/갱신하는 시트
- 존재 여부를 확인하고, 없으면 생성
- 기본 헤더/구조를 초기화하는 로직 포함
- optimizer_ui 시트
- 예산 시나리오용 설정/중간 값을 저장하는 시트
- 존재 여부를 확인하고, 없으면 생성
- 기본 헤더/구조를 초기화하는 로직 포함
즉, Code.gs에는 다음 패턴이 반드시 포함되어야 합니다:
- 시트 존재 여부 확인 → 없으면 insertSheet로 생성 → 헤더/기본 구조 작성
3-4. AI 인사이트 함수
- 프론트엔드에서 호출하는 하나의 함수(예: askGeminiInsight 등)를 통해
- 모드/컨텍스트에 따라 다른 유형의 요약 문장을 생성
- JSON 또는 문자열 형태로 반환
3-5. 팝업 오픈 및 메뉴 설정
- 스프레드시트에서 메뉴를 추가해 팝업을 띄우는 onOpen / showSidebar or showModalDialog 관련 로직
- v3에서 사용하던 팝업 호출 방식을 유지·확장
요청:
- 최종 결과는 제가 그대로 붙여넣어 사용할 수 있도록 **전체 Code.gs 파일**로 제공해 주세요.
- 비전공자를 위해 각 기능 블록에 한글 주석을 달아 주세요.
- 아래 형식으로 출력해 주세요:
[BEGIN FINAL CODE.GS]
(여기에 완성된 v4 Code.gs 전체 코드)
[END FINAL CODE.GS]
[4] v4 프론트엔드 Dashboard.html 전체 구현
이번에는 v3 Dashboard.html을 기반으로, v4 기능을 모두 반영한 **완성된 HTML/JS 파일**을 작성해 주세요.
필수 조건:
- v3와 동일하게 팝업(modal)에 렌더링되는 HTML이어야 합니다.
- 기존 기초 통계(EDA) 탭은 그대로 유지합니다.
- 추가 탭:
- 💰 회귀분석(버짓제안) 탭
- 회귀분석 실행 버튼
- 회귀 리포트 영역(마크다운 → HTML 렌더)
- 회귀 계수 테이블
- 채널별 산점도 + 회귀선 Chart.js 시각화
- 📘 분석 가이드(Help) 탭
- 그래프/통계 해석 도움말
- 예산 시나리오 UI:
- 총 예산 입력 필드
- 6개 채널(TV, Billboards, Google_Ads, Social_Media, Influencer_Marketing, Affiliate_Marketing)의 최소/최대 비중 입력 필드
- “3가지 전략 생성” 버튼
- 결과 카드(전략명, 설명, 예상 판매량, 채널별 예산 배분 리스트)
- 백엔드 호출:
- 회귀분석 실행 버튼 → 백엔드 회귀 분석 함수 호출 → 결과를 화면에 표시
- 3가지 전략 생성 버튼 → 백엔드 시나리오 생성 함수 호출 → 결과를 화면에 표시
- 기초 통계 EDA 영역은 기존 v3 동작(로드 시 or 버튼 클릭 시 getFullData + 차트 렌더) 유지
- Chart.js, boxplot 플러그인, marked.js를 계속 사용
요청:
- 최종 결과는 제가 그대로 붙여넣어 사용할 수 있도록 **전체 Dashboard.html 파일**로 제공해 주세요.
- 비전공자를 위해 주요 영역과 스크립트에 한글 주석을 달아 주세요.
- 아래 형식으로 출력해 주세요:
[BEGIN FINAL DASHBOARD.HTML]
(여기에 완성된 v4 Dashboard.html 전체 코드)
[END FINAL DASHBOARD.HTML]
[5] 최종 통합·호환성 점검
위에서 생성한 Code.gs / Dashboard.html이 서로 잘 맞물려 실제로 동작하도록, 다음을 점검해 주세요.
- 팝업 UI가 정상적으로 뜨는지
- 탭 전환이 정상적으로 동작하는지
- v3의 EDA 탭과 새로 추가된 회귀/시나리오 탭이 함께 문제 없이 작동할 수 있는 구조인지
- 백엔드 함수명, 인자 구조, 반환 JSON 구조가 프론트엔드 호출과 일치하는지
- model_summary / optimizer_ui 자동 생성 로직이 실제로 호출되도록 연결되어 있는지
필요하다면 Code.gs와 Dashboard.html 일부를 수정해도 좋으니,
반드시 “서로 호환되는 최종 버전”으로 정리한 뒤 위의 형식으로 제공해 주세요.
======================================================================
📌 6단계: 최종 사용 가이드(간단 버전만)
======================================================================
기능 설명은 위에서 충분히 했으므로, 최종 사용 가이드는 **아주 간단히**만 작성해 주세요.
- Code.gs / Dashboard.html 파일을 어디에 붙여넣는지 (어떤 프로젝트/파일에 어떤 식으로 교체하면 되는지)
- 구글 시트에서 메뉴를 눌러 팝업 대시보드를 띄우는 방법
- 원본 데이터 시트(광고비 + Product_Sold)의 기본 구조만 간단히 설명
- 회귀분석 탭 / 예산 시나리오 탭을 어떻게 사용하는지 한두 문장씩
- model_summary / optimizer_ui 시트는 사용자가 만들 필요 없고, 자동 생성된다는 점만 명확히 안내
이 6단계 사용 가이드는 길게 작성할 필요 없고,
실무자가 “어디서 뭐 누르면 된다” 정도만 알 수 있게 간단히 작성해 주세요.
======================================================================
📌 입력(v3 원본 코드)
======================================================================
첨부의 v3 코드를 분석해서, 위 요구사항을 만족하는 v4 전체를 만들어 주세요.