{"id":40,"date":"2026-06-05T19:48:24","date_gmt":"2026-06-05T19:48:24","guid":{"rendered":"https:\/\/felippechaves.com\/?page_id=40"},"modified":"2026-06-05T19:59:47","modified_gmt":"2026-06-05T19:59:47","slug":"meta-ads-performance-interactive-bubble-chart","status":"publish","type":"page","link":"https:\/\/felippechaves.com\/en\/dashboards\/meta-ads-performance-interactive-bubble-chart\/","title":{"rendered":"Meta Ads Performance &#8211; Interactive Bubble Chart"},"content":{"rendered":"<div data-elementor-type=\"wp-page\" data-elementor-id=\"40\" class=\"elementor elementor-40\">\n\t\t\t\t<div class=\"elementor-element elementor-element-69e9db0 e-flex e-con-boxed e-con e-parent\" data-id=\"69e9db0\" data-element_type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-f46f9d1 elementor-widget elementor-widget-html\" data-id=\"f46f9d1\" data-element_type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Meta Ads Performance \u2014 Bubble Chart<\/title>\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/PapaParse\/5.4.1\/papaparse.min.js\"><\/script>\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/xlsx\/0.18.5\/xlsx.full.min.js\"><\/script>\n<style>\n  @import url('https:\/\/fonts.googleapis.com\/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap');\n  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n  :root {\n    --bg: #f4f5f7; --surface: #ffffff; --surface2: #f0f2f5;\n    --border: rgba(0,0,0,0.08); --text: #111827; --muted: #6b7280;\n    --efficiency: #16a34a; --underscaled: #d97706; --overspender: #dc2626; --neutral: #9ca3af;\n    --blue: #2563eb; --blue-light: #eff6ff; --blue-border: #bfdbfe;\n  }\n  body { background: var(--bg); color: var(--text); font-family: 'DM Sans', sans-serif; min-height: 100vh; padding: 32px; }\n  .dashboard { max-width: 1340px; margin: 0 auto; }\n\n  \/* \u2500\u2500 Upload Zone \u2500\u2500 *\/\n  .upload-section {\n    margin-bottom: 28px;\n  }\n  .upload-zone {\n    background: var(--surface);\n    border: 2px dashed var(--border);\n    border-radius: 14px;\n    padding: 36px 28px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 24px;\n    cursor: pointer;\n    transition: border-color 0.2s, background 0.2s;\n    flex-wrap: wrap;\n  }\n  .upload-zone:hover, .upload-zone.drag-over {\n    border-color: var(--blue);\n    background: var(--blue-light);\n  }\n  .upload-icon {\n    width: 48px; height: 48px;\n    background: var(--blue-light);\n    border: 1px solid var(--blue-border);\n    border-radius: 12px;\n    display: flex; align-items: center; justify-content: center;\n    flex-shrink: 0;\n    font-size: 22px;\n  }\n  .upload-text h2 { font-size: 15px; font-weight: 600; color: var(--text); }\n  .upload-text p  { font-size: 12px; color: var(--muted); margin-top: 3px; }\n  .upload-btn {\n    display: inline-flex; align-items: center; gap: 7px;\n    background: var(--blue); color: #fff;\n    border: none; border-radius: 9px;\n    padding: 10px 20px; font-size: 13px; font-weight: 500;\n    font-family: 'DM Sans', sans-serif;\n    cursor: pointer; transition: background 0.15s;\n    white-space: nowrap;\n  }\n  .upload-btn:hover { background: #1d4ed8; }\n  .upload-status {\n    display: flex; align-items: center; gap: 10px;\n    font-size: 12px; color: var(--muted);\n    margin-top: 10px; padding: 10px 14px;\n    background: var(--surface); border: 1px solid var(--border);\n    border-radius: 9px;\n  }\n  .upload-status .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--neutral); flex-shrink: 0; }\n  .upload-status .status-dot.loaded { background: var(--efficiency); }\n  .upload-status .status-dot.error  { background: var(--overspender); }\n\n  .col-map-section {\n    background: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 14px;\n    padding: 22px 24px;\n    margin-top: 16px;\n    display: none;\n  }\n  .col-map-section.visible { display: block; }\n  .col-map-title { font-size: 13px; font-weight: 600; margin-bottom: 14px; }\n  .col-map-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));\n    gap: 12px;\n    margin-bottom: 16px;\n  }\n  .col-map-item label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); display: block; margin-bottom: 5px; }\n  .col-map-item select {\n    width: 100%;\n    appearance: none;\n    background: var(--surface2);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    padding: 7px 30px 7px 10px;\n    font-size: 12px;\n    font-family: 'DM Sans', sans-serif;\n    color: var(--text);\n    cursor: pointer;\n    background-image: url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'\/%3E%3C\/svg%3E\");\n    background-repeat: no-repeat;\n    background-position: right 8px center;\n  }\n  .col-map-item select:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,0.12); }\n  .col-map-note { font-size: 11px; color: var(--muted); margin-bottom: 14px; }\n  .threshold-row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }\n  .threshold-item label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); display: block; margin-bottom: 5px; }\n  .threshold-item input {\n    background: var(--surface2);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    padding: 7px 10px;\n    font-size: 12px;\n    font-family: 'DM Mono', monospace;\n    color: var(--text);\n    width: 100px;\n  }\n  .threshold-item input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,0.12); }\n  .apply-btn {\n    display: inline-flex; align-items: center; gap: 7px;\n    background: var(--blue); color: #fff;\n    border: none; border-radius: 9px;\n    padding: 9px 18px; font-size: 13px; font-weight: 500;\n    font-family: 'DM Sans', sans-serif;\n    cursor: pointer; transition: background 0.15s;\n  }\n  .apply-btn:hover { background: #1d4ed8; }\n\n  \/* \u2500\u2500 Main dashboard \u2500\u2500 *\/\n  #mainDashboard { display: none; }\n  #mainDashboard.visible { display: block; }\n\n  header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 16px; }\n  .header-left { display: flex; align-items: flex-start; gap: 14px; }\n  .header-left h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.3px; }\n  .header-left p  { font-size: 13px; color: var(--muted); margin-top: 4px; font-weight: 300; }\n  .reload-btn {\n    display: inline-flex; align-items: center; gap: 6px;\n    background: var(--surface2); color: var(--muted);\n    border: 1px solid var(--border); border-radius: 8px;\n    padding: 7px 12px; font-size: 12px; font-weight: 500;\n    font-family: 'DM Sans', sans-serif;\n    cursor: pointer; transition: all 0.15s;\n    white-space: nowrap; flex-shrink: 0; margin-top: 2px;\n  }\n  .reload-btn:hover { background: var(--surface); color: var(--text); border-color: rgba(0,0,0,0.18); }\n\n  .kpi-strip { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n  .kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 18px; text-align: right; min-width: 110px; }\n  .kpi-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.6px; }\n  .kpi-value { font-size: 20px; font-weight: 600; font-family: 'DM Mono', monospace; margin-top: 2px; }\n  .kpi-value.green  { color: var(--efficiency); }\n  .kpi-value.yellow { color: var(--underscaled); }\n  .kpi-value.red    { color: var(--overspender); }\n\n  .chart-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 28px 28px 20px; }\n  .chart-title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }\n  .chart-title    { font-size: 14px; font-weight: 500; }\n  .chart-subtitle { font-size: 12px; color: var(--muted); margin-top: 2px; }\n  .controls-legend { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }\n  .size-control { display: flex; align-items: center; gap: 8px; }\n  .size-control label { font-size: 12px; color: var(--muted); white-space: nowrap; }\n  .size-select {\n    appearance: none;\n    background: var(--surface2);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    padding: 6px 32px 6px 12px;\n    font-size: 13px;\n    font-family: 'DM Sans', sans-serif;\n    font-weight: 500;\n    color: var(--text);\n    cursor: pointer;\n    background-image: url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'\/%3E%3C\/svg%3E\");\n    background-repeat: no-repeat;\n    background-position: right 10px center;\n    transition: border-color 0.15s, box-shadow 0.15s;\n  }\n  .size-select:hover  { border-color: rgba(0,0,0,0.2); }\n  .size-select:focus  { outline: none; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); border-color: #6366f1; }\n  .legend-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }\n  .legend-item { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); }\n  .legend-dot  { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }\n  .legend-bubble-wrap { display: flex; align-items: center; gap: 5px; }\n  .lb { border-radius: 50%; background: rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0; }\n  .size-pill {\n    display: inline-flex; align-items: center; gap: 5px;\n    background: #eff6ff; border: 1px solid #bfdbfe;\n    color: #2563eb; border-radius: 20px;\n    padding: 3px 10px; font-size: 11px; font-weight: 500;\n    transition: all 0.2s;\n  }\n  .size-pill-dot { width: 6px; height: 6px; border-radius: 50%; background: #2563eb; }\n  svg#chart { display: block; width: 100%; overflow: visible; }\n  .band-legend { display: flex; gap: 6px; margin-top: 14px; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); font-size: 11px; }\n  .band-seg { flex: 1; padding: 8px 12px; display: flex; flex-direction: column; gap: 2px; }\n  .band-seg .band-range { font-family: 'DM Mono', monospace; font-weight: 500; font-size: 12px; }\n  .band-seg .band-label { color: rgba(0,0,0,0.45); font-size: 11px; }\n  .tooltip { position: fixed; background: #fff; border: 1px solid rgba(0,0,0,0.12); border-radius: 10px; padding: 14px 16px; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity 0.15s; z-index: 999; min-width: 220px; box-shadow: 0 8px 32px rgba(0,0,0,0.12); }\n  .tooltip.visible { opacity: 1; }\n  .tt-name { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 10px; line-height: 1.4; }\n  .tt-row  { display: flex; justify-content: space-between; gap: 16px; margin-top: 5px; }\n  .tt-key  { color: var(--muted); }\n  .tt-val  { font-family: 'DM Mono', monospace; font-weight: 500; color: var(--text); }\n  .tt-highlight { background: #eff6ff; border-radius: 4px; padding: 1px 4px; }\n  .tt-badge { display: inline-block; margin-top: 10px; padding: 3px 9px; border-radius: 20px; font-size: 11px; font-weight: 500; }\n  .tt-badge.efficiency    { background: rgba(22,163,74,0.1);  color: var(--efficiency);  border: 1px solid rgba(22,163,74,0.25); }\n  .tt-badge.underscaled   { background: rgba(217,119,6,0.1);  color: var(--underscaled); border: 1px solid rgba(217,119,6,0.25); }\n  .tt-badge.overspender   { background: rgba(220,38,38,0.1);  color: var(--overspender); border: 1px solid rgba(220,38,38,0.25); }\n  .tt-badge.underperformer{ background: rgba(156,163,175,0.12);color: #4b5563;            border: 1px solid rgba(156,163,175,0.3); }\n  .bottom-row { display: flex; gap: 14px; margin-top: 16px; flex-wrap: wrap; }\n  .insight-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; flex: 1; min-width: 200px; }\n  .insight-card h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 8px; }\n  .insight-card h3.green  { color: var(--efficiency); }\n  .insight-card h3.yellow { color: var(--underscaled); }\n  .insight-card h3.red    { color: var(--overspender); }\n  .insight-card h3.grey   { color: var(--muted); }\n  .insight-ad { font-size: 12px; color: var(--muted); padding: 5px 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 8px; }\n  .insight-ad:last-child { border-bottom: none; }\n  .insight-ad-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 210px; }\n  .insight-ad-stat { font-family: 'DM Mono', monospace; font-size: 11px; white-space: nowrap; }\n  .s-green  { color: var(--efficiency); }\n  .s-yellow { color: var(--underscaled); }\n  .s-red    { color: var(--overspender); }\n  .s-grey   { color: var(--muted); }\n  circle.bubble { transition: r 0.35s cubic-bezier(0.34,1.56,0.64,1), opacity 0.15s; }\n\n  \/* Error banner *\/\n  .error-banner {\n    background: rgba(220,38,38,0.08);\n    border: 1px solid rgba(220,38,38,0.2);\n    color: var(--overspender);\n    border-radius: 9px;\n    padding: 10px 14px;\n    font-size: 12px;\n    margin-top: 10px;\n    display: none;\n  }\n  .error-banner.visible { display: block; }\n<\/style>\n<\/head>\n<body>\n<div class=\"dashboard\">\n\n  <!-- Upload Section -->\n  <div class=\"upload-section\">\n    <div class=\"upload-zone\" id=\"uploadZone\">\n      <div class=\"upload-icon\">\ud83d\udcca<\/div>\n      <div class=\"upload-text\">\n        <h2>Upload your Meta Ads data<\/h2>\n        <p>Drag & drop a CSV or Excel (.xlsx) file here, or click to browse<\/p>\n      <\/div>\n      <button class=\"upload-btn\" onclick=\"document.getElementById('fileInput').click()\">\n        \u2191 Choose File\n      <\/button>\n      <input type=\"file\" id=\"fileInput\" accept=\".csv,.xlsx,.xls\" style=\"display:none\">\n    <\/div>\n\n    <div class=\"upload-status\" id=\"uploadStatus\">\n      <span class=\"status-dot\" id=\"statusDot\"><\/span>\n      <span id=\"statusText\">No file loaded \u2014 upload a CSV or Excel file to begin<\/span>\n    <\/div>\n\n    <div class=\"error-banner\" id=\"errorBanner\"><\/div>\n\n    <!-- Column mapping -->\n    <div class=\"col-map-section\" id=\"colMapSection\">\n      <div class=\"col-map-title\">Map your columns<\/div>\n      <p class=\"col-map-note\">Match each field to the corresponding column in your file. Only Name, ROAS, and Spend are required.<\/p>\n      <div class=\"col-map-grid\" id=\"colMapGrid\"><\/div>\n      <div class=\"threshold-row\">\n        <div class=\"threshold-item\">\n          <label>ROAS Target (efficiency floor)<\/label>\n          <input type=\"number\" id=\"roasTarget\" value=\"1.90\" step=\"0.05\" min=\"0\">\n        <\/div>\n        <div class=\"threshold-item\">\n          <label>ROAS Ceiling (underscaled floor)<\/label>\n          <input type=\"number\" id=\"roasCeiling\" value=\"2.20\" step=\"0.05\" min=\"0\">\n        <\/div>\n      <\/div>\n      <button class=\"apply-btn\" id=\"applyBtn\">Generate Chart \u2192<\/button>\n    <\/div>\n  <\/div>\n\n  <!-- Main Dashboard (hidden until data loaded) -->\n  <div id=\"mainDashboard\">\n    <header>\n      <div class=\"header-left\">\n        <div>\n          <h1>Meta Ads Performance \u2014 Interactive Bubble Chart<\/h1>\n          <p id=\"headerSub\">Color = ROAS tier \u00b7 Bubble size is configurable below<\/p>\n        <\/div>\n        <button class=\"reload-btn\" onclick=\"resetUpload()\">\u21ba Upload New File<\/button>\n      <\/div>\n      <div class=\"kpi-strip\" id=\"kpiStrip\"><\/div>\n    <\/header>\n\n    <div class=\"chart-wrap\">\n      <div class=\"chart-title-row\">\n        <div>\n          <div class=\"chart-title\">ROAS vs. Spend<\/div>\n          <div class=\"chart-subtitle\">Each bubble = one ad creative &nbsp;\u00b7&nbsp; Larger bubble = higher selected metric<\/div>\n        <\/div>\n        <div class=\"controls-legend\">\n          <div class=\"size-control\">\n            <label for=\"sizeSelect\">Bubble size:<\/label>\n            <select class=\"size-select\" id=\"sizeSelect\"><\/select>\n          <\/div>\n          <div class=\"legend-row\">\n            <div class=\"legend-item\"><span class=\"legend-dot\" style=\"background:#d97706\"><\/span>Underscaled<\/div>\n            <div class=\"legend-item\"><span class=\"legend-dot\" style=\"background:#16a34a\"><\/span>High Efficiency<\/div>\n            <div class=\"legend-item\"><span class=\"legend-dot\" style=\"background:#dc2626\"><\/span>Overspender<\/div>\n            <div class=\"legend-item\"><span class=\"legend-dot\" style=\"background:#9ca3af\"><\/span>Underperformer<\/div>\n            <div class=\"legend-item\">\n              <div class=\"legend-bubble-wrap\">\n                <span class=\"lb\" style=\"width:7px;height:7px\"><\/span>\n                <span class=\"lb\" style=\"width:13px;height:13px\"><\/span>\n                <span class=\"lb\" style=\"width:20px;height:20px\"><\/span>\n              <\/div>\n              <span id=\"sizePill\" class=\"size-pill\"><span class=\"size-pill-dot\"><\/span><span id=\"sizePillText\">\u2014<\/span><\/span>\n            <\/div>\n          <\/div>\n        <\/div>\n      <\/div>\n\n      <svg id=\"chart\"><\/svg>\n\n      <div class=\"band-legend\" id=\"bandLegend\"><\/div>\n    <\/div>\n\n    <div class=\"bottom-row\">\n      <div class=\"insight-card\" id=\"card-underscaled\"><h3 class=\"yellow\">\u26a1 Underscaled \u2014 ROAS &gt; threshold<\/h3><\/div>\n      <div class=\"insight-card\" id=\"card-efficiency\"><h3 class=\"green\">\u2713 High Efficiency \u2014 on target<\/h3><\/div>\n      <div class=\"insight-card\" id=\"card-overspender\"><h3 class=\"red\">\u2198 Overspenders \u2014 Low ROAS, High Spend<\/h3><\/div>\n      <div class=\"insight-card\" id=\"card-under\"><h3 class=\"grey\">\u2199 Underperformers \u2014 Low ROAS, Low Spend<\/h3><\/div>\n    <\/div>\n  <\/div>\n\n<\/div>\n<div class=\"tooltip\" id=\"tooltip\"><\/div>\n\n<script>\n\/\/ \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet rawData = [];\nlet parsedColumns = [];\nlet fieldMap = {};\nlet ROAS_TARGET  = 1.90;\nlet ROAS_CEILING = 2.20;\nlet currentMetric = null;\nlet METRICS = {};\n\n\/\/ Required & optional fields\nconst FIELD_DEFS = [\n  { key: 'name',      label: 'Ad Name',      required: true,  hint: 'ad name \/ creative name' },\n  { key: 'roas',      label: 'ROAS',         required: true,  hint: 'return on ad spend' },\n  { key: 'spend',     label: 'Spend ($)',     required: true,  hint: 'amount spent' },\n  { key: 'revenue',   label: 'Revenue ($)',   required: false, hint: 'purchase value \/ revenue' },\n  { key: 'purchases', label: 'Purchases',     required: false, hint: 'number of purchases \/ conversions' },\n  { key: 'ctr',       label: 'CTR (%)',       required: false, hint: 'click-through rate' },\n  { key: 'aov',       label: 'AOV ($)',       required: false, hint: 'average order value' },\n  { key: 'cpm',       label: 'CPM ($)',       required: false, hint: 'cost per 1000 impressions' },\n  { key: 'hook',      label: 'Hook Score',    required: false, hint: 'hook score (optional)' },\n];\n\n\/\/ \u2500\u2500 File Upload Handling \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst fileInput   = document.getElementById('fileInput');\nconst uploadZone  = document.getElementById('uploadZone');\nconst statusDot   = document.getElementById('statusDot');\nconst statusText  = document.getElementById('statusText');\nconst errorBanner = document.getElementById('errorBanner');\nconst colMapSec   = document.getElementById('colMapSection');\n\nuploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('drag-over'); });\nuploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));\nuploadZone.addEventListener('drop', e => {\n  e.preventDefault();\n  uploadZone.classList.remove('drag-over');\n  const file = e.dataTransfer.files[0];\n  if (file) handleFile(file);\n});\nfileInput.addEventListener('change', () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); });\n\nfunction setStatus(msg, type='idle') {\n  statusText.textContent = msg;\n  statusDot.className = 'status-dot' + (type === 'loaded' ? ' loaded' : type === 'error' ? ' error' : '');\n}\nfunction showError(msg) {\n  errorBanner.textContent = msg;\n  errorBanner.classList.add('visible');\n}\nfunction clearError() { errorBanner.classList.remove('visible'); }\n\nfunction handleFile(file) {\n  clearError();\n  const ext = file.name.split('.').pop().toLowerCase();\n  if (ext === 'csv') {\n    Papa.parse(file, {\n      header: true, skipEmptyLines: true,\n      complete: res => processData(res.data, file.name),\n      error: err => { setStatus('Parse error', 'error'); showError('CSV parse error: ' + err.message); }\n    });\n  } else if (ext === 'xlsx' || ext === 'xls') {\n    const reader = new FileReader();\n    reader.onload = e => {\n      try {\n        const wb   = XLSX.read(e.target.result, { type: 'array' });\n        const ws   = wb.Sheets[wb.SheetNames[0]];\n        const data = XLSX.utils.sheet_to_json(ws, { defval: '' });\n        processData(data, file.name);\n      } catch (err) {\n        setStatus('Parse error', 'error');\n        showError('Excel parse error: ' + err.message);\n      }\n    };\n    reader.readAsArrayBuffer(file);\n  } else {\n    showError('Unsupported file type. Please upload a .csv or .xlsx file.');\n    setStatus('Unsupported file', 'error');\n  }\n}\n\nfunction processData(rows, filename) {\n  if (!rows || rows.length === 0) {\n    showError('File appears to be empty.');\n    setStatus('Empty file', 'error');\n    return;\n  }\n  rawData = rows;\n  parsedColumns = Object.keys(rows[0]);\n  setStatus(`Loaded \"${filename}\" \u2014 ${rows.length} rows, ${parsedColumns.length} columns. Map your columns below.`, 'loaded');\n  buildColumnMapper();\n}\n\n\/\/ \u2500\u2500 Column Mapper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction buildColumnMapper() {\n  const grid = document.getElementById('colMapGrid');\n  grid.innerHTML = '';\n\n  \/\/ Auto-detect best column matches\n  const autoMap = autoDetectColumns(parsedColumns);\n\n  FIELD_DEFS.forEach(fd => {\n    const div = document.createElement('div');\n    div.className = 'col-map-item';\n    const label = document.createElement('label');\n    label.textContent = fd.label + (fd.required ? ' *' : '');\n    const sel = document.createElement('select');\n    sel.id = 'map_' + fd.key;\n\n    const blankOpt = document.createElement('option');\n    blankOpt.value = '';\n    blankOpt.textContent = fd.required ? '\u2014 select column \u2014' : '\u2014 not mapped \u2014';\n    sel.appendChild(blankOpt);\n\n    parsedColumns.forEach(col => {\n      const opt = document.createElement('option');\n      opt.value = col;\n      opt.textContent = col.length > 30 ? col.slice(0, 28) + '\u2026' : col;\n      if (autoMap[fd.key] === col) opt.selected = true;\n      sel.appendChild(opt);\n    });\n\n    div.appendChild(label);\n    div.appendChild(sel);\n    grid.appendChild(div);\n  });\n\n  colMapSec.classList.add('visible');\n  document.getElementById('applyBtn').onclick = applyMapping;\n}\n\nfunction autoDetectColumns(cols) {\n  const map = {};\n  const lower = cols.map(c => c.toLowerCase().replace(\/[\\s_\\-\\.]+\/g, ''));\n\n  const patterns = {\n    name:      [\/adname\/,\/creative\/,\/name\/,\/ad\/],\n    roas:      [\/^roas$\/,\/roas\/],\n    spend:     [\/^spend$\/,\/amountspent\/,\/spend\/,\/cost\/],\n    revenue:   [\/^revenue$\/,\/purchasevalue\/,\/revenue\/,\/conversionvalue\/,\/value\/],\n    purchases: [\/^purchases$\/,\/purchase\/,\/conversion\/,\/orders\/,\/results\/],\n    ctr:       [\/^ctr\/,\/clickthroughrate\/,\/ctr\/],\n    aov:       [\/aov\/,\/avgorder\/,\/averageorder\/],\n    cpm:       [\/^cpm$\/,\/cpm\/,\/costperthousand\/],\n    hook:      [\/hook\/,\/hookscore\/,\/hookrate\/],\n  };\n\n  for (const [field, pats] of Object.entries(patterns)) {\n    for (const pat of pats) {\n      const idx = lower.findIndex(c => pat.test(c));\n      if (idx !== -1 && !Object.values(map).includes(cols[idx])) {\n        map[field] = cols[idx];\n        break;\n      }\n    }\n  }\n  return map;\n}\n\nfunction applyMapping() {\n  clearError();\n  fieldMap = {};\n  let missing = [];\n\n  FIELD_DEFS.forEach(fd => {\n    const val = document.getElementById('map_' + fd.key).value;\n    if (val) fieldMap[fd.key] = val;\n    else if (fd.required) missing.push(fd.label);\n  });\n\n  if (missing.length) {\n    showError('Required columns not mapped: ' + missing.join(', '));\n    return;\n  }\n\n  ROAS_TARGET  = parseFloat(document.getElementById('roasTarget').value)  || 1.90;\n  ROAS_CEILING = parseFloat(document.getElementById('roasCeiling').value) || 2.20;\n\n  buildAndRender();\n}\n\n\/\/ \u2500\u2500 Data Builder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction parseNum(val) {\n  if (val === null || val === undefined || val === '') return null;\n  const s = String(val).replace(\/[$,%\\s]\/g, '');\n  const n = parseFloat(s);\n  return isNaN(n) ? null : n;\n}\n\nfunction buildChartData() {\n  const data = [];\n  rawData.forEach(row => {\n    const name  = row[fieldMap.name]  ? String(row[fieldMap.name]).trim() : '(unnamed)';\n    const roas  = parseNum(row[fieldMap.roas]);\n    const spend = parseNum(row[fieldMap.spend]);\n    if (roas === null || spend === null) return; \/\/ skip rows with missing required fields\n\n    const d = { name, roas, spend };\n    d.revenue   = fieldMap.revenue   ? parseNum(row[fieldMap.revenue])   : null;\n    d.purchases = fieldMap.purchases ? parseNum(row[fieldMap.purchases]) : null;\n    d.ctr       = fieldMap.ctr       ? parseNum(row[fieldMap.ctr])       : null;\n    d.aov       = fieldMap.aov       ? parseNum(row[fieldMap.aov])       : null;\n    d.cpm       = fieldMap.cpm       ? parseNum(row[fieldMap.cpm])       : null;\n    d.hook      = fieldMap.hook      ? parseNum(row[fieldMap.hook])      : null;\n    data.push(d);\n  });\n  return data;\n}\n\n\/\/ \u2500\u2500 Chart Render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction buildAndRender() {\n  const data = buildChartData();\n  if (data.length === 0) {\n    showError('No valid rows found after parsing. Check that ROAS and Spend columns contain numeric values.');\n    return;\n  }\n\n  \/\/ Build METRICS based on what was mapped\n  METRICS = {};\n  const allFields = [\n    { key:'revenue',   label:'Revenue',    fmt: v=>`$${(v\/1000).toFixed(1)}K` },\n    { key:'spend',     label:'Spend',      fmt: v=>`$${(v\/1000).toFixed(1)}K` },\n    { key:'cpm',       label:'CPM',        fmt: v=>`$${v.toFixed(2)}` },\n    { key:'ctr',       label:'CTR',        fmt: v=>`${v.toFixed(2)}%` },\n    { key:'aov',       label:'AOV',        fmt: v=>`$${v.toFixed(2)}` },\n    { key:'hook',      label:'Hook Score', fmt: v=>`${v}` },\n    { key:'purchases', label:'Purchases',  fmt: v=>`${Math.round(v)}` },\n  ];\n  allFields.forEach(f => {\n    \/\/ include if mapped OR if it's \"spend\" (always available)\n    if (f.key === 'spend' || fieldMap[f.key]) {\n      const hasData = data.some(d => d[f.key] !== null && d[f.key] !== undefined);\n      if (hasData) METRICS[f.key] = { label: f.label, fmt: f.fmt, field: f.key };\n    }\n  });\n\n  \/\/ Pick default metric\n  currentMetric = METRICS.revenue ? 'revenue' : Object.keys(METRICS)[0];\n\n  \/\/ Populate size select\n  const sel = document.getElementById('sizeSelect');\n  sel.innerHTML = '';\n  Object.entries(METRICS).forEach(([key, m]) => {\n    const opt = document.createElement('option');\n    opt.value = key; opt.textContent = m.label;\n    if (key === currentMetric) opt.selected = true;\n    sel.appendChild(opt);\n  });\n  document.getElementById('sizePillText').textContent = METRICS[currentMetric].label;\n\n  \/\/ Header sub\n  document.getElementById('headerSub').textContent =\n    `Color = ROAS tier \u00b7 ${data.length} ads \u00b7 Bubble size is configurable below`;\n\n  \/\/ KPIs\n  renderKPIs(data);\n\n  \/\/ Band legend\n  renderBandLegend();\n\n  \/\/ Insight cards\n  renderInsightCards(data);\n\n  \/\/ Show dashboard\n  document.getElementById('mainDashboard').classList.add('visible');\n  uploadZone.style.display = 'none';\n\n  \/\/ Draw chart\n  drawChart(data);\n\n  \/\/ Dropdown\n  sel.onchange = () => {\n    currentMetric = sel.value;\n    document.getElementById('sizePillText').textContent = METRICS[currentMetric].label;\n    renderBubbles(data, currentMetric);\n  };\n}\n\n\/\/ \u2500\u2500 KPIs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction classify(d) {\n  const avgSpend = rawData.reduce((s,_,i,a) => s, 0); \/\/ use global avg\n  if (d.roas > ROAS_CEILING)                            return 'underscaled';\n  if (d.roas >= ROAS_TARGET && d.roas <= ROAS_CEILING)  return 'efficiency';\n  if (d.roas < ROAS_TARGET  && d.spend >= window._avgSpend) return 'overspender';\n  return 'underperformer';\n}\nconst colorMap = { underscaled:'#d97706', efficiency:'#16a34a', overspender:'#dc2626', underperformer:'#9ca3af' };\n\nfunction renderKPIs(data) {\n  window._avgSpend = data.reduce((s,d)=>s+d.spend,0)\/data.length;\n  const groups = {\n    underscaled:    data.filter(d=>classify(d)==='underscaled'),\n    efficiency:     data.filter(d=>classify(d)==='efficiency'),\n    overspender:    data.filter(d=>classify(d)==='overspender'),\n    underperformer: data.filter(d=>classify(d)==='underperformer'),\n  };\n  window._groups = groups;\n\n  const totalRevenue = data.reduce((s,d)=>s+(d.revenue||0),0);\n  const totalSpend   = data.reduce((s,d)=>s+d.spend,0);\n  const overallROAS  = totalSpend > 0 ? totalRevenue\/totalSpend : null;\n\n  let kpiHtml = '';\n  if (totalRevenue > 0) kpiHtml += `<div class=\"kpi\"><div class=\"kpi-label\">Total Revenue<\/div><div class=\"kpi-value green\">$${(totalRevenue\/1000).toFixed(0)}K<\/div><\/div>`;\n  kpiHtml += `<div class=\"kpi\"><div class=\"kpi-label\">Total Spend<\/div><div class=\"kpi-value\">$${(totalSpend\/1000).toFixed(0)}K<\/div><\/div>`;\n  if (overallROAS) kpiHtml += `<div class=\"kpi\"><div class=\"kpi-label\">Overall ROAS<\/div><div class=\"kpi-value\">${overallROAS.toFixed(2)}x<\/div><\/div>`;\n  kpiHtml += `<div class=\"kpi\"><div class=\"kpi-label\">Underscaled<\/div><div class=\"kpi-value yellow\">${groups.underscaled.length} Ads<\/div><\/div>`;\n  kpiHtml += `<div class=\"kpi\"><div class=\"kpi-label\">Overspenders<\/div><div class=\"kpi-value red\">${groups.overspender.length} Ads<\/div><\/div>`;\n  document.getElementById('kpiStrip').innerHTML = kpiHtml;\n}\n\nfunction renderBandLegend() {\n  document.getElementById('bandLegend').innerHTML = `\n    <div class=\"band-seg\" style=\"background:rgba(156,163,175,0.06)\">\n      <span class=\"band-range\" style=\"color:#6b7280\">ROAS &lt; ${ROAS_TARGET.toFixed(2)}<\/span>\n      <span class=\"band-label\">Underperformer \/ Overspender<\/span>\n    <\/div>\n    <div class=\"band-seg\" style=\"background:rgba(22,163,74,0.06)\">\n      <span class=\"band-range\" style=\"color:var(--efficiency)\">${ROAS_TARGET.toFixed(2)} \u2264 ROAS \u2264 ${ROAS_CEILING.toFixed(2)}<\/span>\n      <span class=\"band-label\">High Efficiency \u2014 on target<\/span>\n    <\/div>\n    <div class=\"band-seg\" style=\"background:rgba(217,119,6,0.06)\">\n      <span class=\"band-range\" style=\"color:var(--underscaled)\">ROAS &gt; ${ROAS_CEILING.toFixed(2)}<\/span>\n      <span class=\"band-label\">Underscaled \u2014 budget opportunity<\/span>\n    <\/div>\n  `;\n}\n\nfunction renderInsightCards(data) {\n  const groups = window._groups;\n  const avgSpend = window._avgSpend;\n\n  document.getElementById('card-underscaled').innerHTML =\n    `<h3 class=\"yellow\">\u26a1 Underscaled \u2014 ROAS > ${ROAS_CEILING.toFixed(2)}<\/h3>`;\n  document.getElementById('card-efficiency').innerHTML =\n    `<h3 class=\"green\">\u2713 High Efficiency \u2014 ROAS ${ROAS_TARGET.toFixed(2)}\u2013${ROAS_CEILING.toFixed(2)}<\/h3>`;\n  document.getElementById('card-overspender').innerHTML =\n    `<h3 class=\"red\">\u2198 Overspenders \u2014 Low ROAS, High Spend<\/h3>`;\n  document.getElementById('card-under').innerHTML =\n    `<h3 class=\"grey\">\u2199 Underperformers \u2014 Low ROAS, Low Spend<\/h3>`;\n\n  function appendRows(id, ads, sortKey, statFn, cls) {\n    const el = document.getElementById(id);\n    const h3 = el.querySelector('h3').outerHTML;\n    let html = h3;\n    [...ads].sort((a,b)=>(b[sortKey]||0)-(a[sortKey]||0)).slice(0,5).forEach(d=>{\n      html += `<div class=\"insight-ad\"><span class=\"insight-ad-name\">${d.name}<\/span><span class=\"insight-ad-stat ${cls}\">${statFn(d)}<\/span><\/div>`;\n    });\n    el.innerHTML = html;\n  }\n\n  const sortKey = METRICS.revenue ? 'revenue' : 'spend';\n  appendRows('card-underscaled', groups.underscaled,    'roas',    d=>`${d.roas.toFixed(2)}x`, 's-yellow');\n  appendRows('card-efficiency',  groups.efficiency,     sortKey,   d=>d.revenue!=null?`$${(d.revenue\/1000).toFixed(1)}K`:`${d.roas.toFixed(2)}x`, 's-green');\n  appendRows('card-overspender', groups.overspender,    'spend',   d=>`$${(d.spend\/1000).toFixed(1)}K spend`, 's-red');\n  appendRows('card-under',       groups.underperformer, 'spend',   d=>`${d.roas.toFixed(2)}x`, 's-grey');\n}\n\n\/\/ \u2500\u2500 SVG Chart \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet svgChartData = [];\n\nfunction drawChart(data) {\n  svgChartData = data;\n  const svg = document.getElementById('chart');\n  svg.innerHTML = '';\n\n  const W = svg.parentElement.clientWidth - 56;\n  const H = Math.min(560, W * 0.52);\n  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);\n  svg.setAttribute('height', H);\n\n  const pad = { top:44, right:44, bottom:58, left:76 };\n  const cw  = W - pad.left - pad.right;\n  const ch  = H - pad.top  - pad.bottom;\n\n  const maxSpendVal = Math.max(...data.map(d=>d.spend));\n  const sqrtMax     = Math.sqrt(maxSpendVal * 1.06);\n  const sx = v => pad.left + (Math.sqrt(v)\/sqrtMax)*cw;\n\n  const minRoas = Math.max(0, Math.min(...data.map(d=>d.roas)) - 0.2);\n  const maxRoas = Math.max(...data.map(d=>d.roas)) + 0.3;\n  const sy = v => pad.top + ch - ((v-minRoas)\/(maxRoas-minRoas))*ch;\n\n  const ns = 'http:\/\/www.w3.org\/2000\/svg';\n  function svgEl(tag, attrs={}, parent=svg) {\n    const e = document.createElementNS(ns, tag);\n    Object.entries(attrs).forEach(([k,v])=>e.setAttribute(k,v));\n    parent.appendChild(e);\n    return e;\n  }\n\n  \/\/ Band fills\n  const y_target  = sy(ROAS_TARGET);\n  const y_ceiling = sy(ROAS_CEILING);\n  svgEl('rect',{x:pad.left,y:y_target,  width:cw,height:pad.top+ch-y_target,  fill:'rgba(220,38,38,0.03)'});\n  svgEl('rect',{x:pad.left,y:y_ceiling, width:cw,height:y_target-y_ceiling,   fill:'rgba(22,163,74,0.05)'});\n  svgEl('rect',{x:pad.left,y:pad.top,   width:cw,height:y_ceiling-pad.top,    fill:'rgba(217,119,6,0.05)'});\n\n  \/\/ Avg spend divider\n  const qx = sx(window._avgSpend);\n  svgEl('line',{x1:qx,y1:pad.top,x2:qx,y2:pad.top+ch,stroke:'rgba(0,0,0,0.18)',strokeWidth:1.5,'stroke-dasharray':'5 4'});\n  const xt = svgEl('text',{x:qx+4,y:pad.top+13,fill:'rgba(0,0,0,0.4)','font-size':'10','font-family':'DM Sans, sans-serif'});\n  xt.textContent=`Avg Spend $${(window._avgSpend\/1000).toFixed(0)}K`;\n\n  \/\/ Threshold lines\n  svgEl('line',{x1:pad.left,y1:y_target, x2:pad.left+cw,y2:y_target, stroke:'rgba(22,163,74,0.5)',  strokeWidth:1.2,'stroke-dasharray':'4 3'});\n  svgEl('line',{x1:pad.left,y1:y_ceiling,x2:pad.left+cw,y2:y_ceiling,stroke:'rgba(217,119,6,0.5)', strokeWidth:1.2,'stroke-dasharray':'4 3'});\n  const lt1=svgEl('text',{x:pad.left+4,y:y_target-4, fill:'rgba(22,163,74,0.8)','font-size':'10','font-family':'DM Sans, sans-serif'}); lt1.textContent=`Target ${ROAS_TARGET.toFixed(2)}x`;\n  const lt2=svgEl('text',{x:pad.left+4,y:y_ceiling-4,fill:'rgba(217,119,6,0.8)','font-size':'10','font-family':'DM Sans, sans-serif'}); lt2.textContent=`Ceiling ${ROAS_CEILING.toFixed(2)}x`;\n\n  \/\/ Grid\n  const spendMag = Math.pow(10, Math.floor(Math.log10(maxSpendVal)));\n  const rawTicks = [0, 0.25, 0.5, 1, 2, 4, 8, 16, 32].map(v => v * spendMag).filter(v => v <= maxSpendVal * 1.01);\n  const roasTicks = [];\n  for (let v = Math.ceil(minRoas*4)\/4; v <= maxRoas+0.05; v += 0.25) roasTicks.push(Math.round(v*100)\/100);\n\n  rawTicks.forEach(v=>{\n    svgEl('line',{x1:sx(v),y1:pad.top,x2:sx(v),y2:pad.top+ch,stroke:'rgba(0,0,0,0.06)',strokeWidth:1});\n    const t=svgEl('text',{x:sx(v),y:pad.top+ch+18,fill:'#6b7280','font-size':'10','text-anchor':'middle','font-family':'DM Mono, monospace'});\n    t.textContent=v===0?'$0':v>=1000?`$${(v\/1000).toFixed(0)}K`:`$${v}`;\n  });\n  roasTicks.forEach(v=>{\n    svgEl('line',{x1:pad.left,y1:sy(v),x2:pad.left+cw,y2:sy(v),stroke:'rgba(0,0,0,0.06)',strokeWidth:1});\n    const t=svgEl('text',{x:pad.left-8,y:sy(v)+4,fill:'#6b7280','font-size':'10','text-anchor':'end','font-family':'DM Mono, monospace'});\n    t.textContent=v.toFixed(2)+'x';\n  });\n\n  \/\/ Axis labels\n  const xa=svgEl('text',{x:pad.left+cw\/2,y:H-6,fill:'#6b7280','font-size':'12','text-anchor':'middle','font-family':'DM Sans, sans-serif','font-weight':'500'});\n  xa.textContent='Spend ($) \u2014 sqrt scale';\n  const ya=svgEl('text',{x:14,y:pad.top+ch\/2,fill:'#6b7280','font-size':'12','text-anchor':'middle','font-family':'DM Sans, sans-serif','font-weight':'500',transform:`rotate(-90,14,${pad.top+ch\/2})`});\n  ya.textContent='ROAS';\n\n  \/\/ Chart border\n  svgEl('rect',{x:pad.left,y:pad.top,width:cw,height:ch,fill:'none',stroke:'rgba(0,0,0,0.08)',strokeWidth:1,rx:2});\n\n  \/\/ Bubble layer\n  const bubbleLayer = svgEl('g',{id:'bubbleLayer'});\n\n  \/\/ Store helpers in window for re-render\n  window._svgHelpers = { sx, sy, svgEl, bubbleLayer, ns };\n\n  renderBubbles(data, currentMetric);\n}\n\nfunction getMetricValues(data, metric) {\n  const field = METRICS[metric].field;\n  const vals = data.map(d=>d[field]).filter(v=>v!==null&&v!==undefined);\n  return { min: Math.min(...vals), max: Math.max(...vals) };\n}\n\nfunction bRadius(d, data, metric) {\n  const field = METRICS[metric].field;\n  const val = d[field];\n  if (val===null||val===undefined) return 4;\n  const {min,max} = getMetricValues(data, metric);\n  return 4 + ((val-min)\/(max-min||1))*18;\n}\n\nfunction renderBubbles(data, metric) {\n  const { sx, sy, svgEl, bubbleLayer, ns } = window._svgHelpers;\n  while (bubbleLayer.firstChild) bubbleLayer.removeChild(bubbleLayer.firstChild);\n\n  const tooltip = document.getElementById('tooltip');\n  const badgeLabel = { underscaled:'Underscaled', efficiency:'High Efficiency', overspender:'Overspender', underperformer:'Underperformer' };\n\n  const sorted = [...data].sort((a,b)=>bRadius(b,data,metric)-bRadius(a,data,metric));\n\n  sorted.forEach(d=>{\n    const cx=sx(d.spend), cy=sy(d.roas), r=bRadius(d,data,metric);\n    const cls=classify(d), col=colorMap[cls];\n\n    const glow=document.createElementNS(ns,'circle');\n    glow.setAttribute('cx',cx); glow.setAttribute('cy',cy);\n    glow.setAttribute('r',r+4); glow.setAttribute('fill','none');\n    glow.setAttribute('stroke',col); glow.setAttribute('stroke-width',7);\n    glow.setAttribute('opacity',0.1);\n    bubbleLayer.appendChild(glow);\n\n    const circ=document.createElementNS(ns,'circle');\n    circ.setAttribute('cx',cx); circ.setAttribute('cy',cy); circ.setAttribute('r',r);\n    circ.setAttribute('fill',col); circ.setAttribute('opacity',0.85);\n    circ.setAttribute('stroke','rgba(255,255,255,0.7)'); circ.setAttribute('stroke-width',1.5);\n    circ.setAttribute('class','bubble');\n    circ.style.cursor='pointer';\n    bubbleLayer.appendChild(circ);\n\n    const metricField = METRICS[metric].field;\n    const metricVal = d[metricField];\n    const metricFmt = METRICS[metric].fmt;\n    const metricLabel = METRICS[metric].label;\n    const metricStr = (metricVal!==null&&metricVal!==undefined) ? metricFmt(metricVal) : 'N\/A';\n\n    const fmtCur = (v, prefix='$') => v!=null ? `${prefix}${v.toLocaleString('en-US',{maximumFractionDigits:0})}` : 'N\/A';\n    const fmtDec = (v, suffix='', dec=2) => v!=null ? `${v.toFixed(dec)}${suffix}` : 'N\/A';\n\n    let ttRows = `\n      <div class=\"tt-row\"><span class=\"tt-key\">Spend<\/span><span class=\"tt-val\">${fmtCur(d.spend)}<\/span><\/div>\n      ${d.revenue!=null?`<div class=\"tt-row\"><span class=\"tt-key\">Revenue<\/span><span class=\"tt-val\">${fmtCur(d.revenue)}<\/span><\/div>`:''}\n      <div class=\"tt-row\"><span class=\"tt-key\">ROAS<\/span><span class=\"tt-val\">${d.roas.toFixed(2)}x<\/span><\/div>\n      ${d.aov!=null?`<div class=\"tt-row\"><span class=\"tt-key\">AOV<\/span><span class=\"tt-val\">${fmtDec(d.aov,'',2)} $<\/span><\/div>`:''}\n      ${d.ctr!=null?`<div class=\"tt-row\"><span class=\"tt-key\">CTR<\/span><span class=\"tt-val\">${fmtDec(d.ctr,'%')}<\/span><\/div>`:''}\n      ${d.cpm!=null?`<div class=\"tt-row\"><span class=\"tt-key\">CPM<\/span><span class=\"tt-val\">${fmtDec(d.cpm,'',2)} $<\/span><\/div>`:''}\n      ${d.purchases!=null?`<div class=\"tt-row\"><span class=\"tt-key\">Purchases<\/span><span class=\"tt-val\">${Math.round(d.purchases).toLocaleString()}<\/span><\/div>`:''}\n      ${d.hook!=null?`<div class=\"tt-row\"><span class=\"tt-key\">Hook Score<\/span><span class=\"tt-val\">${d.hook}<\/span><\/div>`:''}\n    `;\n\n    circ.addEventListener('mouseenter',()=>{\n      circ.setAttribute('opacity','1');\n      glow.setAttribute('opacity','0.25');\n      tooltip.innerHTML=`\n        <div class=\"tt-name\">${d.name}<\/div>\n        ${ttRows}\n        <div class=\"tt-row\" style=\"margin-top:8px;padding-top:8px;border-top:1px solid #f0f0f0\">\n          <span class=\"tt-key\">\u2194 Bubble size (<b>${metricLabel}<\/b>)<\/span>\n          <span class=\"tt-val tt-highlight\">${metricStr}<\/span>\n        <\/div>\n        <div><span class=\"tt-badge ${cls}\">${badgeLabel[cls]}<\/span><\/div>\n      `;\n      tooltip.classList.add('visible');\n    });\n    circ.addEventListener('mousemove',e=>{\n      tooltip.style.left=`${Math.min(e.clientX+16,window.innerWidth-240)}px`;\n      tooltip.style.top=`${e.clientY-20}px`;\n    });\n    circ.addEventListener('mouseleave',()=>{\n      circ.setAttribute('opacity','0.85');\n      glow.setAttribute('opacity','0.1');\n      tooltip.classList.remove('visible');\n    });\n  });\n}\n\n\/\/ \u2500\u2500 Reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction resetUpload() {\n  document.getElementById('mainDashboard').classList.remove('visible');\n  uploadZone.style.display = '';\n  colMapSec.classList.remove('visible');\n  clearError();\n  setStatus('No file loaded \u2014 upload a CSV or Excel file to begin');\n  document.getElementById('statusDot').className = 'status-dot';\n  fileInput.value = '';\n  rawData = []; parsedColumns = []; fieldMap = {};\n}\n<\/script>\n<\/body>\n<\/html>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>","protected":false},"excerpt":{"rendered":"<p>Meta Ads Performance \u2014 Bubble Chart \ud83d\udcca Upload your Meta Ads data Drag &#038; drop a CSV or Excel (.xlsx) file here, or click to browse \u2191 Choose File No file loaded \u2014 upload a CSV or Excel file to begin Map your columns Match each field to the corresponding column in your file. Only [&hellip;]<\/p>","protected":false},"author":1,"featured_media":0,"parent":36,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_canvas","meta":{"footnotes":""},"class_list":["post-40","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages\/40","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/comments?post=40"}],"version-history":[{"count":5,"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages\/40\/revisions"}],"predecessor-version":[{"id":45,"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages\/40\/revisions\/45"}],"up":[{"embeddable":true,"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages\/36"}],"wp:attachment":[{"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/media?parent=40"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}