{"id":60,"date":"2026-06-09T16:47:12","date_gmt":"2026-06-09T16:47:12","guid":{"rendered":"https:\/\/felippechaves.com\/?page_id=60"},"modified":"2026-06-09T16:50:28","modified_gmt":"2026-06-09T16:50:28","slug":"meta-ads-performance-spider-chart","status":"publish","type":"page","link":"https:\/\/felippechaves.com\/en\/dashboards\/meta-ads-performance-spider-chart\/","title":{"rendered":"Meta Ads Performance \u2013 Spider Chart"},"content":{"rendered":"<div data-elementor-type=\"wp-page\" data-elementor-id=\"60\" class=\"elementor elementor-60\">\n\t\t\t\t<div class=\"elementor-element elementor-element-c2a83cf e-flex e-con-boxed e-con e-parent\" data-id=\"c2a83cf\" data-element_type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-543f430e e-flex e-con-boxed e-con e-parent\" data-id=\"543f430e\" 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-56ffdd10 elementor-widget elementor-widget-html\" data-id=\"56ffdd10\" 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 \u2014 Creative Spider Chart<\/title>\n<style>\n  @import url('https:\/\/fonts.googleapis.com\/css2?family=Syne:wght@400;500;600;700;800&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap');\n\n  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n  :root {\n    --bg: #f4f5f7;\n    --surface: #ffffff;\n    --surface2: #f0f2f5;\n    --border: rgba(0,0,0,0.08);\n    --text: #111827;\n    --muted: #6b7280;\n    --accent: #2563eb;\n    --accent-soft: #eff6ff;\n    --efficiency: #16a34a;\n    --underscaled: #d97706;\n    --overspender: #dc2626;\n    --neutral: #9ca3af;\n  }\n\n  body {\n    background: var(--bg);\n    color: var(--text);\n    font-family: 'DM Sans', sans-serif;\n    min-height: 100vh;\n    padding: 32px;\n  }\n\n  .dashboard { max-width: 1200px; margin: 0 auto; }\n\n  \/* \u2500\u2500 Header \u2500\u2500 *\/\n  header {\n    display: flex;\n    justify-content: space-between;\n    align-items: flex-end;\n    margin-bottom: 28px;\n    padding-bottom: 22px;\n    border-bottom: 1px solid var(--border);\n    flex-wrap: wrap;\n    gap: 16px;\n  }\n  .header-left h1 {\n    font-family: 'Syne', sans-serif;\n    font-size: 24px;\n    font-weight: 700;\n    letter-spacing: -0.4px;\n    color: var(--text);\n  }\n  .header-left p { font-size: 13px; color: var(--muted); margin-top: 4px; }\n\n  \/* \u2500\u2500 Upload Zone \u2500\u2500 *\/\n  .upload-zone {\n    border: 2px dashed rgba(37,99,235,0.3);\n    border-radius: 14px;\n    background: var(--accent-soft);\n    padding: 28px 32px;\n    text-align: center;\n    cursor: pointer;\n    transition: border-color 0.2s, background 0.2s;\n    margin-bottom: 24px;\n    position: relative;\n  }\n  .upload-zone:hover, .upload-zone.drag-over {\n    border-color: var(--accent);\n    background: #dbeafe;\n  }\n  .upload-zone input[type=\"file\"] {\n    position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;\n  }\n  .upload-icon {\n    font-size: 28px; margin-bottom: 8px;\n  }\n  .upload-zone h3 {\n    font-family: 'Syne', sans-serif;\n    font-size: 15px;\n    font-weight: 700;\n    color: var(--accent);\n    margin-bottom: 4px;\n  }\n  .upload-zone p {\n    font-size: 12px;\n    color: var(--muted);\n    line-height: 1.5;\n  }\n  .upload-zone .file-loaded {\n    font-size: 12px;\n    color: var(--efficiency);\n    font-weight: 500;\n    margin-top: 6px;\n  }\n\n  \/* \u2500\u2500 Column Mapper \u2500\u2500 *\/\n  .mapper-panel {\n    background: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 14px;\n    padding: 20px 24px;\n    margin-bottom: 24px;\n    box-shadow: 0 1px 4px rgba(0,0,0,0.05);\n    display: none;\n  }\n  .mapper-panel.visible { display: block; }\n  .mapper-title {\n    font-family: 'Syne', sans-serif;\n    font-size: 13px;\n    font-weight: 700;\n    text-transform: uppercase;\n    letter-spacing: 0.8px;\n    color: var(--muted);\n    margin-bottom: 14px;\n  }\n  .mapper-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));\n    gap: 12px;\n    margin-bottom: 16px;\n  }\n  .mapper-field label {\n    display: block;\n    font-size: 11px;\n    text-transform: uppercase;\n    letter-spacing: 0.6px;\n    color: var(--muted);\n    font-weight: 500;\n    margin-bottom: 5px;\n  }\n  .mapper-field select {\n    width: 100%;\n    appearance: none;\n    background: var(--surface2) url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'\/%3E%3C\/svg%3E\") no-repeat right 10px center;\n    border: 1.5px solid var(--border);\n    border-radius: 8px;\n    padding: 8px 30px 8px 10px;\n    font-size: 12px;\n    font-family: 'DM Sans', sans-serif;\n    color: var(--text);\n    cursor: pointer;\n  }\n  .mapper-field select:focus { outline: none; border-color: var(--accent); }\n  .apply-btn {\n    display: inline-flex;\n    align-items: center;\n    gap: 8px;\n    padding: 10px 22px;\n    background: var(--accent);\n    color: #fff;\n    border: none;\n    border-radius: 9px;\n    font-family: 'Syne', sans-serif;\n    font-size: 13px;\n    font-weight: 700;\n    cursor: pointer;\n    letter-spacing: 0.3px;\n    transition: opacity 0.15s;\n  }\n  .apply-btn:hover { opacity: 0.88; }\n\n  \/* \u2500\u2500 Dropdown \u2500\u2500 *\/\n  .select-wrap {\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n    min-width: 320px;\n    max-width: 460px;\n    flex: 1;\n  }\n  .select-label {\n    font-size: 11px;\n    text-transform: uppercase;\n    letter-spacing: 0.7px;\n    color: var(--muted);\n    font-weight: 500;\n  }\n  .custom-select-wrapper { position: relative; }\n  .custom-select-wrapper::after {\n    content: '';\n    position: absolute;\n    right: 14px; top: 50%;\n    transform: translateY(-50%);\n    width: 0; height: 0;\n    border-left: 5px solid transparent;\n    border-right: 5px solid transparent;\n    border-top: 6px solid var(--muted);\n    pointer-events: none;\n  }\n  select#creativeSelect {\n    width: 100%;\n    appearance: none;\n    background: var(--surface);\n    border: 1.5px solid var(--border);\n    border-radius: 10px;\n    padding: 11px 40px 11px 14px;\n    font-size: 13px;\n    font-family: 'DM Sans', sans-serif;\n    font-weight: 500;\n    color: var(--text);\n    cursor: pointer;\n    transition: border-color 0.15s, box-shadow 0.15s;\n    box-shadow: 0 1px 3px rgba(0,0,0,0.06);\n  }\n  select#creativeSelect:hover { border-color: rgba(0,0,0,0.18); }\n  select#creativeSelect:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,0.12); }\n\n  \/* \u2500\u2500 Empty state \u2500\u2500 *\/\n  .empty-state {\n    background: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 16px;\n    padding: 64px 32px;\n    text-align: center;\n    box-shadow: 0 1px 4px rgba(0,0,0,0.05);\n  }\n  .empty-state h2 {\n    font-family: 'Syne', sans-serif;\n    font-size: 18px;\n    font-weight: 700;\n    color: var(--text);\n    margin-bottom: 8px;\n  }\n  .empty-state p { font-size: 13px; color: var(--muted); }\n\n  \/* \u2500\u2500 Main layout \u2500\u2500 *\/\n  .main-grid {\n    display: grid;\n    grid-template-columns: 1fr 340px;\n    gap: 20px;\n    align-items: start;\n  }\n\n  \/* \u2500\u2500 Chart card \u2500\u2500 *\/\n  .chart-card {\n    background: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 16px;\n    padding: 32px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    box-shadow: 0 1px 4px rgba(0,0,0,0.05);\n  }\n  .chart-card-title {\n    font-family: 'Syne', sans-serif;\n    font-size: 13px;\n    font-weight: 600;\n    text-transform: uppercase;\n    letter-spacing: 0.8px;\n    color: var(--muted);\n    margin-bottom: 6px;\n    align-self: flex-start;\n  }\n  .creative-name {\n    font-family: 'Syne', sans-serif;\n    font-size: 18px;\n    font-weight: 700;\n    color: var(--text);\n    align-self: flex-start;\n    margin-bottom: 24px;\n    line-height: 1.3;\n  }\n  svg#spider { display: block; overflow: visible; }\n\n  \/* \u2500\u2500 Side panel \u2500\u2500 *\/\n  .side-panel { display: flex; flex-direction: column; gap: 14px; }\n\n  \/* Classification badge *\/\n  .class-badge {\n    padding: 16px;\n    border-radius: 12px;\n    border: 1.5px solid;\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n  }\n  .class-badge-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; font-weight: 500; }\n  .class-badge-value { font-family: 'Syne', sans-serif; font-size: 20px; font-weight: 700; }\n  .class-badge.underscaled   { background: rgba(217,119,6,0.06);  border-color: rgba(217,119,6,0.2);  }\n  .class-badge.efficiency    { background: rgba(22,163,74,0.06);  border-color: rgba(22,163,74,0.2);  }\n  .class-badge.overspender   { background: rgba(220,38,38,0.06);  border-color: rgba(220,38,38,0.2);  }\n  .class-badge.underperformer{ background: rgba(156,163,175,0.08);border-color: rgba(156,163,175,0.25);}\n\n  \/* Metrics table *\/\n  .metrics-card {\n    background: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 12px;\n    overflow: hidden;\n    box-shadow: 0 1px 4px rgba(0,0,0,0.05);\n  }\n  .metrics-card-header {\n    padding: 12px 16px;\n    border-bottom: 1px solid var(--border);\n    font-family: 'Syne', sans-serif;\n    font-size: 11px;\n    font-weight: 700;\n    text-transform: uppercase;\n    letter-spacing: 0.8px;\n    color: var(--muted);\n    background: var(--surface2);\n  }\n  .metric-row {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding: 10px 16px;\n    border-bottom: 1px solid var(--border);\n    gap: 12px;\n  }\n  .metric-row:last-child { border-bottom: none; }\n  .metric-row-left { display: flex; align-items: center; gap: 10px; }\n  .metric-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }\n  .metric-name { font-size: 12px; color: var(--muted); }\n  .metric-value { font-family: 'DM Mono', monospace; font-size: 13px; font-weight: 500; color: var(--text); }\n  .metric-bar-wrap { width: 60px; height: 4px; background: rgba(0,0,0,0.07); border-radius: 2px; overflow: hidden; }\n  .metric-bar { height: 100%; border-radius: 2px; transition: width 0.5s cubic-bezier(0.34,1.2,0.64,1); }\n\n  \/* Percentile note *\/\n  .percentile-note {\n    background: var(--surface2);\n    border: 1px solid var(--border);\n    border-radius: 10px;\n    padding: 12px 14px;\n    font-size: 11px;\n    color: var(--muted);\n    line-height: 1.6;\n  }\n  .percentile-note strong { color: var(--text); }\n\n  \/* Spider animation *\/\n  #spiderPath, #spiderFill {\n    transition: d 0.45s cubic-bezier(0.34,1.1,0.64,1);\n  }\n\n  \/* stat badge *\/\n  .stat-badge {\n    background: var(--surface2);\n    border: 1px solid var(--border);\n    border-radius: 10px;\n    padding: 10px 14px;\n    font-size: 12px;\n    color: var(--muted);\n    display: flex;\n    gap: 6px;\n    align-items: center;\n  }\n  .stat-badge strong { color: var(--text); font-family: 'DM Mono', monospace; font-size: 13px; }\n<\/style>\n<\/head>\n<body>\n<div class=\"dashboard\">\n\n  <header>\n    <div class=\"header-left\">\n      <h1>Creative Performance Radar<\/h1>\n      <p id=\"subheader\">Upload your data table to get started<\/p>\n    <\/div>\n    <div class=\"select-wrap\" id=\"selectWrap\" style=\"display:none\">\n      <span class=\"select-label\">Select Creative<\/span>\n      <div class=\"custom-select-wrapper\">\n        <select id=\"creativeSelect\"><\/select>\n      <\/div>\n    <\/div>\n  <\/header>\n\n  <!-- Upload zone -->\n  <div class=\"upload-zone\" id=\"uploadZone\">\n    <input type=\"file\" id=\"fileInput\" accept=\".csv,.tsv,.txt,text\/csv,text\/plain\">\n    <div class=\"upload-icon\">\ud83d\udcca<\/div>\n    <h3>Drop your CSV here, or click to browse<\/h3>\n    <p>Supports comma- or tab-separated files exported from Meta Ads Manager or any spreadsheet.<br>Required columns: a name\/creative column + numeric metrics (ROAS, Spend, Revenue, CTR, AOV, CPM, etc.)<\/p>\n    <div class=\"file-loaded\" id=\"fileLoaded\" style=\"display:none\"><\/div>\n  <\/div>\n\n  <!-- Column mapper -->\n  <div class=\"mapper-panel\" id=\"mapperPanel\">\n    <div class=\"mapper-title\">Map your columns<\/div>\n    <div class=\"mapper-grid\" id=\"mapperGrid\"><\/div>\n    <button class=\"apply-btn\" id=\"applyBtn\">Apply mapping &amp; render chart \u2192<\/button>\n  <\/div>\n\n  <!-- Empty state -->\n  <div class=\"empty-state\" id=\"emptyState\">\n    <div style=\"font-size:40px;margin-bottom:12px\">\ud83d\udd78\ufe0f<\/div>\n    <h2>No data loaded yet<\/h2>\n    <p>Upload a CSV above to render the spider chart for each creative.<\/p>\n  <\/div>\n\n  <!-- Chart (hidden until data loaded) -->\n  <div id=\"chartSection\" style=\"display:none\">\n    <div class=\"main-grid\">\n      <div class=\"chart-card\">\n        <div class=\"chart-card-title\">Performance Radar<\/div>\n        <div class=\"creative-name\" id=\"creativeName\">\u2014<\/div>\n        <svg id=\"spider\" width=\"480\" height=\"480\" viewbox=\"0 0 480 480\"><\/svg>\n      <\/div>\n\n      <div class=\"side-panel\">\n        <div class=\"stat-badge\" id=\"datasetBadge\"><\/div>\n\n        <div class=\"class-badge\" id=\"classBadge\">\n          <span class=\"class-badge-label\">ROAS Classification<\/span>\n          <span class=\"class-badge-value\" id=\"classValue\">\u2014<\/span>\n        <\/div>\n\n        <div class=\"metrics-card\">\n          <div class=\"metrics-card-header\">Raw Metrics<\/div>\n          <div id=\"metricsTable\"><\/div>\n        <\/div>\n\n        <div class=\"percentile-note\">\n          <strong>How to read this chart:<\/strong> Each axis shows a metric's <em>percentile rank<\/em> among all ads in the dataset. A score of 100 = best. <br><br>\n          Axes where <em>lower is better<\/em> (CPM) are <strong>inverted<\/strong> \u2014 a large area still means better performance.\n        <\/div>\n      <\/div>\n    <\/div>\n  <\/div>\n\n<\/div>\n\n<script>\n\/\/ \u2500\u2500 Axes config \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\nconst AXES_TEMPLATE = [\n  { key:'roas',     label:'ROAS',       higherIsBetter:true,  fmt:v=>`${parseFloat(v).toFixed(2)}x`,          color:'#2563eb' },\n  { key:'revenue',  label:'Revenue',    higherIsBetter:true,  fmt:v=>`$${(parseFloat(v)\/1000).toFixed(1)}K`,   color:'#16a34a' },\n  { key:'ctr',      label:'CTR',        higherIsBetter:true,  fmt:v=>`${parseFloat(v).toFixed(2)}%`,           color:'#7c3aed' },\n  { key:'aov',      label:'AOV',        higherIsBetter:true,  fmt:v=>`$${parseFloat(v).toFixed(2)}`,           color:'#0891b2' },\n  { key:'hook',     label:'Hook Score', higherIsBetter:true,  fmt:v=>v!==null&&v!==''?`${v}`:'N\/A',            color:'#ea580c' },\n  { key:'cpm',      label:'CPM',        higherIsBetter:false, fmt:v=>`$${parseFloat(v).toFixed(2)}`,           color:'#be185d' },\n  { key:'spend',    label:'Spend',      higherIsBetter:true,  fmt:v=>`$${(parseFloat(v)\/1000).toFixed(1)}K`,   color:'#ca8a04' },\n];\n\n\/\/ Field display names for the mapper UI\nconst FIELD_LABELS = {\n  name:    'Creative Name (text)',\n  roas:    'ROAS',\n  spend:   'Spend ($)',\n  revenue: 'Revenue ($)',\n  purchases:'Purchases',\n  ctr:     'CTR (%)',\n  aov:     'AOV ($)',\n  hook:    'Hook Score',\n  cpm:     'CPM ($)',\n};\nconst REQUIRED_FIELDS = ['name','roas','spend','revenue','ctr','aov','cpm'];\nconst OPTIONAL_FIELDS = ['purchases','hook'];\nconst ALL_FIELDS = [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS];\n\nlet raw = [];\nlet AXES = [];\nlet csvHeaders = [];\nlet columnMapping = {}; \/\/ fieldKey -> csvHeader\n\n\/\/ \u2500\u2500 CSV Parser \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 parseCSV(text) {\n  const lines = text.trim().split(\/\\r?\\n\/);\n  if (lines.length < 2) return { headers: [], rows: [] };\n  \n  \/\/ Detect delimiter\n  const firstLine = lines[0];\n  const tabCount = (firstLine.match(\/\\t\/g)||[]).length;\n  const commaCount = (firstLine.match(\/,\/g)||[]).length;\n  const delim = tabCount > commaCount ? '\\t' : ',';\n\n  function splitLine(line) {\n    const result = [];\n    let cur = '', inQ = false;\n    for (let i = 0; i < line.length; i++) {\n      const c = line[i];\n      if (c === '\"') { inQ = !inQ; continue; }\n      if (c === delim && !inQ) { result.push(cur.trim()); cur = ''; continue; }\n      cur += c;\n    }\n    result.push(cur.trim());\n    return result;\n  }\n\n  const headers = splitLine(lines[0]);\n  const rows = lines.slice(1).filter(l => l.trim()).map(l => {\n    const vals = splitLine(l);\n    const obj = {};\n    headers.forEach((h, i) => { obj[h] = vals[i] !== undefined ? vals[i] : ''; });\n    return obj;\n  });\n  return { headers, rows };\n}\n\n\/\/ \u2500\u2500 Auto-detect column mapping \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 autoMap(headers) {\n  const lh = headers.map(h => h.toLowerCase().replace(\/[^a-z0-9]\/g,''));\n  const mapping = {};\n  const patterns = {\n    name:     [\/name\/,\/creative\/,\/ad\/,\/title\/],\n    roas:     [\/roas\/,\/returnonad\/],\n    spend:    [\/spend\/,\/cost\/,\/amountspent\/],\n    revenue:  [\/revenue\/,\/purchasevalue\/,\/conversionvalue\/,\/value\/],\n    purchases:[\/purchases\/,\/conversions\/,\/results\/,\/orders\/],\n    ctr:      [\/ctr\/,\/clickthroughrate\/,\/clickrate\/],\n    aov:      [\/aov\/,\/averageorder\/,\/avgorder\/],\n    hook:     [\/hook\/,\/thumbstop\/,\/3sec\/,\/thumbstopratio\/],\n    cpm:      [\/cpm\/,\/costpermille\/,\/cost1000\/],\n  };\n  Object.entries(patterns).forEach(([field, pats]) => {\n    for (let i = 0; i < lh.length; i++) {\n      if (pats.some(p => p.test(lh[i]))) {\n        mapping[field] = headers[i];\n        break;\n      }\n    }\n  });\n  return mapping;\n}\n\n\/\/ \u2500\u2500 Render mapper UI \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 renderMapper(headers) {\n  const grid = document.getElementById('mapperGrid');\n  grid.innerHTML = '';\n  ALL_FIELDS.forEach(field => {\n    const isOpt = OPTIONAL_FIELDS.includes(field);\n    const div = document.createElement('div');\n    div.className = 'mapper-field';\n    div.innerHTML = `<label>${FIELD_LABELS[field]}${isOpt?' <em style=\"opacity:0.6;font-style:normal\">(optional)<\/em>':''}<\/label>\n      <select data-field=\"${field}\">\n        <option value=\"\">\u2014 skip \u2014<\/option>\n        ${headers.map(h=>`<option value=\"${h}\"${columnMapping[field]===h?' selected':''}>${h}<\/option>`).join('')}\n      <\/select>`;\n    grid.appendChild(div);\n  });\n}\n\n\/\/ \u2500\u2500 Build raw data from mapping \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 buildRaw(rows, mapping) {\n  return rows.map(row => {\n    const obj = {};\n    ALL_FIELDS.forEach(f => {\n      const col = mapping[f];\n      if (!col) { obj[f] = null; return; }\n      const val = row[col];\n      if (f === 'name') { obj[f] = val || '(unnamed)'; return; }\n      const num = parseFloat((val||'').replace(\/[$,%]\/g,'').replace(\/,\/g,''));\n      obj[f] = isNaN(num) ? null : num;\n    });\n    return obj;\n  }).filter(r => r.name);\n}\n\n\/\/ \u2500\u2500 Percentile rank computation \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 computePercentiles(data, axes) {\n  const pcts = {};\n  axes.forEach(ax => {\n    const vals = data.map(d => d[ax.key]).filter(v => v !== null);\n    const sorted = [...vals].sort((a,b)=>a-b);\n    pcts[ax.key] = data.map(d => {\n      const v = d[ax.key];\n      if (v === null) return 50;\n      const rank = sorted.filter(x => x < v).length;\n      let pct = sorted.length > 1 ? rank \/ (sorted.length - 1) : 0.5;\n      if (!ax.higherIsBetter) pct = 1 - pct;\n      return Math.round(pct * 100);\n    });\n  });\n  return pcts;\n}\n\n\/\/ \u2500\u2500 Classification \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\nconst ROAS_TARGET=1.90, ROAS_CEILING=2.20;\nfunction classify(d, avgSpend) {\n  if (!d.roas) return 'underperformer';\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 >= avgSpend)    return 'overspender';\n  return 'underperformer';\n}\nconst classLabels = { underscaled:'\u26a1 Underscaled', efficiency:'\u2713 High Efficiency', overspender:'\u2198 Overspender', underperformer:'\u2199 Underperformer' };\nconst classColors = { underscaled:'#d97706', efficiency:'#16a34a', overspender:'#dc2626', underperformer:'#9ca3af' };\nconst fillColors  = { underscaled:'rgba(217,119,6,0.1)', efficiency:'rgba(22,163,74,0.1)', overspender:'rgba(220,38,38,0.1)', underperformer:'rgba(156,163,175,0.1)' };\n\n\/\/ \u2500\u2500 SVG Spider 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\u2500\nlet spiderBuilt = false;\nlet pathEl, fillEl, dots;\nconst CX=240, CY=240, R=170;\nconst ns='http:\/\/www.w3.org\/2000\/svg';\nfunction svgEl(tag,attrs={},parent){\n  const svg=document.getElementById('spider');\n  const e=document.createElementNS(ns,tag);\n  Object.entries(attrs).forEach(([k,v])=>e.setAttribute(k,v));\n  (parent||svg).appendChild(e); return e;\n}\nfunction polarPt(angle, radius) {\n  return [CX + radius * Math.sin(angle), CY - radius * Math.cos(angle)];\n}\n\nfunction buildSpider(axes) {\n  const svg = document.getElementById('spider');\n  svg.innerHTML = '';\n  const N = axes.length;\n\n  \/\/ Rings\n  [20,40,60,80,100].forEach(pct => {\n    const r = R * pct\/100;\n    const pts = Array.from({length:N},(_,i)=>{\n      const [x,y]=polarPt(2*Math.PI*i\/N, r); return `${x},${y}`;\n    }).join(' ');\n    svgEl('polygon',{points:pts, fill:'none', stroke:'rgba(0,0,0,0.07)', 'stroke-width':1});\n    svgEl('text',{x:CX+3, y:CY-r-3, fill:'#9ca3af', 'font-size':'9', 'font-family':'DM Mono, monospace', 'text-anchor':'start'}).textContent=`${pct}`;\n  });\n\n  \/\/ Axes + labels\n  axes.forEach((ax,i) => {\n    const angle = 2*Math.PI*i\/N;\n    const [x1,y1] = polarPt(angle, R);\n    svgEl('line',{x1:CX,y1:CY,x2:x1,y2:y1,stroke:'rgba(0,0,0,0.1)','stroke-width':1});\n    const labelR = R + 28;\n    const [lx,ly] = polarPt(angle, labelR);\n    const anchor = Math.abs(lx-CX)<10 ? 'middle' : lx>CX ? 'start' : 'end';\n    const dotR = R + 14;\n    const [dx,dy] = polarPt(angle, dotR);\n    svgEl('circle',{cx:dx,cy:dy,r:4,fill:ax.color,opacity:0.85});\n    const lbl = svgEl('text',{x:lx,y:ly+4,fill:'#374151','font-size':'11','font-family':'DM Sans, sans-serif','font-weight':'600','text-anchor':anchor});\n    lbl.textContent = ax.label;\n  });\n\n  fillEl = svgEl('polygon',{id:'spiderFill', points:'', fill:'rgba(37,99,235,0.12)', stroke:'none'});\n  pathEl = svgEl('polygon',{id:'spiderPath', points:'', fill:'none', stroke:'#2563eb', 'stroke-width':2.5, 'stroke-linejoin':'round'});\n  dots = axes.map(()=>svgEl('circle',{r:4, fill:'#2563eb', stroke:'#fff', 'stroke-width':2, opacity:0}));\n  spiderBuilt = true;\n}\n\nlet percentiles = {};\nlet avgSpend = 0;\n\nfunction update(idx) {\n  const d = raw[idx];\n  const N = AXES.length;\n  document.getElementById('creativeName').textContent = d.name;\n\n  const cls = classify(d, avgSpend);\n  const badge = document.getElementById('classBadge');\n  badge.className = `class-badge ${cls}`;\n  document.getElementById('classValue').textContent = classLabels[cls];\n  document.getElementById('classValue').style.color = classColors[cls];\n  badge.querySelector('.class-badge-label').style.color = classColors[cls];\n\n  const pts = AXES.map((ax,i) => {\n    const pct = percentiles[ax.key] ? percentiles[ax.key][idx] : 50;\n    const r = R * pct \/ 100;\n    return polarPt(2*Math.PI*i\/N, r);\n  });\n  const ptStr = pts.map(([x,y])=>`${x.toFixed(2)},${y.toFixed(2)}`).join(' ');\n  pathEl.setAttribute('points', ptStr);\n  fillEl.setAttribute('points', ptStr);\n  fillEl.setAttribute('fill', fillColors[cls]);\n  pathEl.setAttribute('stroke', classColors[cls]);\n\n  dots.forEach((dot,i)=>{\n    const [x,y] = pts[i];\n    dot.setAttribute('cx', x.toFixed(2));\n    dot.setAttribute('cy', y.toFixed(2));\n    dot.setAttribute('fill', AXES[i].color);\n    dot.setAttribute('opacity', 1);\n  });\n\n  const table = document.getElementById('metricsTable');\n  table.innerHTML = AXES.map((ax) => {\n    const val = d[ax.key];\n    const pct = percentiles[ax.key] ? percentiles[ax.key][idx] : 50;\n    const rawVal = (val !== null && val !== undefined) ? ax.fmt(val) : 'N\/A';\n    const barColor = pct >= 70 ? '#16a34a' : pct >= 40 ? '#d97706' : '#dc2626';\n    return `<div class=\"metric-row\">\n      <div class=\"metric-row-left\">\n        <span class=\"metric-dot\" style=\"background:${ax.color}\"><\/span>\n        <span class=\"metric-name\">${ax.label}<\/span>\n      <\/div>\n      <div class=\"metric-bar-wrap\"><div class=\"metric-bar\" style=\"width:${pct}%;background:${barColor}\"><\/div><\/div>\n      <span class=\"metric-value\">${rawVal}<\/span>\n    <\/div>`;\n  }).join('');\n}\n\n\/\/ \u2500\u2500 Wire up everything \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 applyData() {\n  \/\/ Read mapping from selects\n  document.querySelectorAll('#mapperGrid select').forEach(sel => {\n    if (sel.value) columnMapping[sel.dataset.field] = sel.value;\n    else delete columnMapping[sel.dataset.field];\n  });\n\n  if (!columnMapping.name) { alert('Please map at least the Creative Name column.'); return; }\n\n  \/\/ Build raw\n  const mappedRows = window._csvRows;\n  raw = buildRaw(mappedRows, columnMapping);\n  if (!raw.length) { alert('No valid rows found after mapping.'); return; }\n\n  \/\/ Build axes (only include axes where data exists)\n  AXES = AXES_TEMPLATE.filter(ax => ax.key !== 'name' && raw.some(d => d[ax.key] !== null && columnMapping[ax.key]));\n\n  percentiles = computePercentiles(raw, AXES);\n  avgSpend = raw.reduce((s,d)=>s+(d.spend||0),0)\/raw.length;\n\n  \/\/ Populate dropdown\n  const sel = document.getElementById('creativeSelect');\n  sel.innerHTML = '';\n  raw.forEach((d,i) => {\n    const opt = document.createElement('option');\n    opt.value = i;\n    opt.textContent = d.name;\n    sel.appendChild(opt);\n  });\n\n  \/\/ Build spider\n  buildSpider(AXES);\n\n  \/\/ Dataset badge\n  document.getElementById('datasetBadge').innerHTML = `<strong>${raw.length}<\/strong> creatives loaded \u00b7 <strong>${AXES.length}<\/strong> metrics`;\n\n  \/\/ Show UI\n  document.getElementById('emptyState').style.display = 'none';\n  document.getElementById('chartSection').style.display = 'block';\n  document.getElementById('selectWrap').style.display = 'flex';\n  document.getElementById('subheader').textContent = `All metrics normalized to percentile rank across ${raw.length} ads \u00b7 Larger area = stronger overall performance`;\n  document.getElementById('mapperPanel').classList.remove('visible');\n\n  update(0);\n}\n\ndocument.getElementById('applyBtn').addEventListener('click', applyData);\n\ndocument.getElementById('creativeSelect').addEventListener('change', function() {\n  update(parseInt(this.value));\n});\n\n\/\/ \u2500\u2500 File 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction handleFile(file) {\n  if (!file) return;\n  const reader = new FileReader();\n  reader.onload = e => {\n    const text = e.target.result;\n    const { headers, rows } = parseCSV(text);\n    if (!headers.length) { alert('Could not parse file. Make sure it is a CSV or TSV.'); return; }\n    csvHeaders = headers;\n    window._csvRows = rows;\n    columnMapping = autoMap(headers);\n    renderMapper(headers);\n    document.getElementById('mapperPanel').classList.add('visible');\n    document.getElementById('fileLoaded').style.display = 'block';\n    document.getElementById('fileLoaded').textContent = `\u2713 ${file.name} loaded \u2014 ${rows.length} rows, ${headers.length} columns`;\n  };\n  reader.readAsText(file);\n}\n\nconst fileInput = document.getElementById('fileInput');\nfileInput.addEventListener('change', () => handleFile(fileInput.files[0]));\n\nconst zone = document.getElementById('uploadZone');\nzone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });\nzone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));\nzone.addEventListener('drop', e => {\n  e.preventDefault();\n  zone.classList.remove('drag-over');\n  handleFile(e.dataTransfer.files[0]);\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 \u2014 Creative Spider Chart Creative Performance Radar Upload your data table to get started Select Creative \ud83d\udcca Drop your CSV here, or click to browse Supports comma- or tab-separated files exported from Meta Ads Manager or any spreadsheet.Required columns: a name\/creative column + numeric metrics (ROAS, Spend, Revenue, CTR, AOV, CPM, etc.) Map [&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-60","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages\/60","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=60"}],"version-history":[{"count":6,"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages\/60\/revisions"}],"predecessor-version":[{"id":66,"href":"https:\/\/felippechaves.com\/en\/wp-json\/wp\/v2\/pages\/60\/revisions\/66"}],"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=60"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}