← back to browse
goat command

heatmap

Full

0
views
0
likes
0
installs

Aliases: activity, groupactivity, wrapped

raw source
const { createCanvas, loadImage } = require('canvas');
const fs = require('fs-extra');
const path = require('path');

const dataPath = path.join(__dirname, 'cache', 'activity_data.json');

const C = {
  bg: '#02060d',
  grid: '#0a1830',
  panel: '#050f20',
  panelAlt: 'rgba(255,255,255,0.03)',
  border: '#1c6fe0',
  glow: 'rgba(45,150,255,0.6)',
  cyan: '#38ecff',
  green: '#39ffb8',
  blue: '#4a90ff',
  purple: '#c07bff',
  gold: '#ffd166',
  red: '#ff5c7a',
  dim: '#82aee0',
  white: '#f5faff',
  cellEmpty: '#0c1c33',
};

const STOPWORDS = new Set([
  // English
  'the','a','an','and','or','but','is','are','was','were','be','been','being',
  'to','of','in','on','at','for','with','as','by','this','that','it','its',
  'you','your','yours','i','im','me','my','we','our','they','their','he','she',
  'his','her','them','not','no','yes','so','if','then','than','just','have',
  'has','had','do','does','did','will','would','can','could','should','what',
  'when','where','why','how','who','which','there','here','from','up','out',
  'about','into','over','after','before','again','all','any','both','each',
  'more','most','some','such','only','own','same','too','very','ok','okay',
  'lol','yeah','yep','nah','haha','hahaha','hmm','oh','ah','well',
  // Bengali (common)
  'এবং','আমি','তুমি','আপনি','সে','তারা','আমরা','তোমরা','এই','ওই','সেই',
  'হয়','হয়েছে','হবে','ছিল','করে','করেছে','করবে','না','নাই','কি','কেন',
  'কোথায়','কখন','কীভাবে','যে','যদি','তাহলে','কিন্তু','আর','এটা','ওটা',
  'একটা','একটি','তো','তাই','তবে','ভাই','আপু','ভাইয়া','হ্যাঁ','নাহ',
]);

const EMOJI_REGEX = /(\p{Emoji_Presentation}|\p{Extended_Pictographic})/gu;
const WORD_REGEX = /[\p{L}\p{N}']{2,}/gu;

function readData() {
  try {
    return fs.readJsonSync(dataPath);
  } catch (e) {
    return {};
  }
}

function writeData(data) {
  try {
    fs.ensureDirSync(path.dirname(dataPath));
    fs.writeJsonSync(dataPath, data);
  } catch (e) {}
}

function dateKey(d) {
  return d.toISOString().slice(0, 10);
}

function pruneFreqMap(map, keep = 150) {
  const keys = Object.keys(map);
  if (keys.length <= keep * 2) return map;
  const sorted = keys.sort((a, b) => map[b] - map[a]).slice(0, keep);
  const pruned = {};
  for (const k of sorted) pruned[k] = map[k];
  return pruned;
}

function emptyUser() {
  return {
    name: null, count: 0, mediaCount: 0, textCount: 0,
    wordCount: 0, emojiCount: 0, nightCount: 0, starterCount: 0,
    responseGapSum: 0, responseGapCount: 0
  };
}

module.exports = {
  config: {
    name: "heatmap",
    aliases: ["activity", "groupactivity", "wrapped"],
    version: "3.0",
    author: "JABED",
    countDown: 10,
    role: 0,
    description: "Full 'Group Wrapped' dashboard — activity heatmap, group photo, streaks, trends, top words, top emojis, response-time awards and superlatives",
    category: "group utility"
  },

  onChat: async function ({ api, event }) {
    try {
      const { threadID, senderID, attachments, body: rawBody } = event;
      if (!threadID || !senderID) return;

      const body = rawBody || '';
      const now = new Date();
      const dKey = dateKey(now);
      const hour = now.getHours();
      const weekday = now.getDay();
      const hasAttachment = Array.isArray(attachments) && attachments.length > 0;

      const data = readData();
      if (!data[threadID]) {
        data[threadID] = {
          daily: {}, hourly: new Array(24).fill(0), weekday: new Array(7).fill(0),
          users: {}, total: 0, textCount: 0, mediaCount: 0,
          wordFreq: {}, emojiFreq: {},
          lastSender: null, lastTimestamp: null
        };
      }
      const t = data[threadID];
      if (t.wordFreq === undefined) t.wordFreq = {};
      if (t.emojiFreq === undefined) t.emojiFreq = {};
      if (t.textCount === undefined) t.textCount = 0;
      if (t.mediaCount === undefined) t.mediaCount = 0;

      t.daily[dKey] = (t.daily[dKey] || 0) + 1;
      t.hourly[hour] += 1;
      t.weekday[weekday] += 1;
      t.total += 1;
      if (hasAttachment) t.mediaCount += 1; else t.textCount += 1;

      if (!t.users[senderID]) t.users[senderID] = emptyUser();
      const u = t.users[senderID];
      u.count += 1;
      if (hasAttachment) u.mediaCount += 1; else u.textCount += 1;
      if (hour < 5) u.nightCount += 1;

      if (!u.name) {
        try {
          const info = await api.getUserInfo(senderID);
          const uname = info && info[senderID] && info[senderID].name;
          u.name = uname || `User ${senderID.slice(-4)}`;
        } catch (e) {
          u.name = `User ${senderID.slice(-4)}`;
        }
      }

      // ---- word + emoji extraction (skipped for pure-media messages) ----
      if (body) {
        const cleanBody = body.replace(/https?:\/\/\S+/g, ' ').toLowerCase();
        const words = cleanBody.match(WORD_REGEX) || [];
        u.wordCount += words.length;
        for (const w of words) {
          if (w.length < 2 || STOPWORDS.has(w) || !isNaN(w)) continue;
          t.wordFreq[w] = (t.wordFreq[w] || 0) + 1;
        }
        const emojis = body.match(EMOJI_REGEX) || [];
        if (emojis.length) {
          u.emojiCount += emojis.length;
          for (const e of emojis) t.emojiFreq[e] = (t.emojiFreq[e] || 0) + 1;
        }
        t.wordFreq = pruneFreqMap(t.wordFreq);
        t.emojiFreq = pruneFreqMap(t.emojiFreq, 60);
      }

      // ---- conversation-starter + response-time tracking ----
      if (t.lastTimestamp) {
        const gapMs = now - new Date(t.lastTimestamp);
        const gapMin = gapMs / 60000;
        if (gapMin > 120) {
          u.starterCount += 1;
        } else if (t.lastSender && t.lastSender !== senderID) {
          u.responseGapSum += gapMs / 1000;
          u.responseGapCount += 1;
        }
      } else {
        u.starterCount += 1;
      }
      t.lastTimestamp = now.toISOString();
      t.lastSender = senderID;

      writeData(data);
    } catch (e) {
      // silent fail, never break the chat pipeline over logging
    }
  },

  onStart: async function ({ api, event }) {
    const { threadID, messageID } = event;
    const data = readData();
    const t = data[threadID];

    if (!t || t.total === 0) {
      return api.sendMessage(
        "এখনো এই গ্রুপে কোনো অ্যাক্টিভিটি ডেটা জমা হয়নি। কিছুক্ষণ চ্যাট চলুক, তারপর আবার চেষ্টা করুন।",
        threadID, messageID
      );
    }

    let groupName = "This Group";
    let groupImageUrl = null;
    try {
      const info = await api.getThreadInfo(threadID);
      if (info) {
        if (info.threadName) groupName = info.threadName;
        if (info.imageSrc) groupImageUrl = info.imageSrc;
      }
    } catch (e) {}

    try {
      const cachePath = path.join(__dirname, 'cache', `heatmap_${threadID}.png`);
      fs.ensureDirSync(path.join(__dirname, 'cache'));

      const buffer = await generateDashboard(t, groupName, groupImageUrl);
      fs.writeFileSync(cachePath, buffer);

      const topUser = Object.values(t.users).sort((a, b) => b.count - a.count)[0];
      const { current: curStreak } = computeStreaks(t.daily);
      const trend = computeTrend(t.daily);

      const caption =
`✧━━━━━━━━━━━━━━━━━━━━✧
   𝗚𝗥𝗢𝗨𝗣 𝗪𝗥𝗔𝗣𝗣𝗘𝗗 𝗗𝗔𝗦𝗛𝗕𝗢𝗔𝗥𝗗
✧━━━━━━━━━━━━━━━━━━━━✧
📊 Total tracked : ${t.total} messages
👑 Most active   : ${topUser ? topUser.name : 'N/A'}
🔥 Current streak: ${curStreak} day(s)
📈 7-day trend   : ${trend.pct >= 0 ? '+' : ''}${trend.pct}%
✧━━━━━━━━━━━━━━━━━━━━✧
⚡ Powered by JABED`;

      return api.sendMessage(
        { body: caption, attachment: fs.createReadStream(cachePath) },
        threadID, () => fs.unlinkSync(cachePath), messageID
      );
    } catch (e) {
      api.sendMessage(`Error: ${e.message}`, threadID, messageID);
    }
  }
};

function computeStreaks(daily) {
  const dates = Object.keys(daily).filter(k => daily[k] > 0).sort();
  if (dates.length === 0) return { current: 0, longest: 0 };
  const dateSet = new Set(dates);
  let longest = 0, run = 0, prev = null;
  for (const dstr of dates) {
    const d = new Date(dstr);
    if (prev && (d - prev) / 86400000 === 1) run += 1; else run = 1;
    longest = Math.max(longest, run);
    prev = d;
  }
  let current = 0;
  let cursor = new Date();
  while (dateSet.has(dateKey(cursor))) {
    current += 1;
    cursor.setDate(cursor.getDate() - 1);
  }
  return { current, longest };
}

function computeTrend(daily) {
  const now = new Date();
  let last7 = 0, prev7 = 0;
  for (let i = 0; i < 7; i++) { const d = new Date(now); d.setDate(d.getDate() - i); last7 += daily[dateKey(d)] || 0; }
  for (let i = 7; i < 14; i++) { const d = new Date(now); d.setDate(d.getDate() - i); prev7 += daily[dateKey(d)] || 0; }
  const pct = prev7 === 0 ? (last7 > 0 ? 100 : 0) : Math.round(((last7 - prev7) / prev7) * 100);
  return { last7, prev7, pct };
}

function computeAwards(t) {
  const users = Object.entries(t.users).map(([id, u]) => ({ id, ...u }));
  const active = users.filter(u => u.count >= 5);

  const pick = (arr, keyFn, minVal = -Infinity) => {
    let best = null, bestVal = minVal;
    for (const u of arr) {
      const v = keyFn(u);
      if (v !== null && v > bestVal) { best = u; bestVal = v; }
    }
    return best ? { user: best, value: bestVal } : null;
  };
  const pickMin = (arr, keyFn, maxVal = Infinity) => {
    let best = null, bestVal = maxVal;
    for (const u of arr) {
      const v = keyFn(u);
      if (v !== null && v < bestVal) { best = u; bestVal = v; }
    }
    return best ? { user: best, value: bestVal } : null;
  };

  return {
    chatterbox: pick(users, u => u.count),
    nightOwl: pick(users, u => u.nightCount > 0 ? u.nightCount : null),
    essayWriter: pick(active, u => u.count > 0 ? u.wordCount / u.count : null),
    oneLiner: pickMin(active, u => u.count > 0 ? u.wordCount / u.count : null),
    emojiOverlord: pick(users, u => u.emojiCount > 0 ? u.emojiCount : null),
    mediaMaven: pick(users, u => u.mediaCount > 0 ? u.mediaCount : null),
    starter: pick(users, u => u.starterCount > 0 ? u.starterCount : null),
    quickDraw: pickMin(users.filter(u => u.responseGapCount >= 3), u => u.responseGapSum / u.responseGapCount)
  };
}

async function generateDashboard(t, groupName, groupImageUrl) {
  const W = 3000, H = 2650;
  const canvas = createCanvas(W, H);
  const ctx = canvas.getContext('2d');

  const bgGrad = ctx.createRadialGradient(W / 2, H / 2, 120, W / 2, H / 2, W * 0.75);
  bgGrad.addColorStop(0, '#071228');
  bgGrad.addColorStop(0.55, '#03080f');
  bgGrad.addColorStop(1, '#000103');
  ctx.fillStyle = bgGrad;
  ctx.fillRect(0, 0, W, H);

  ctx.strokeStyle = C.grid;
  ctx.lineWidth = 1;
  for (let gx = 0; gx < W; gx += 60) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke(); }
  for (let gy = 0; gy < H; gy += 60) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke(); }

  drawPanel(ctx, 30, 30, W - 60, H - 60, 34);

  // ================= HEADER =================
  const avatarSize = 130, avatarX = 100, avatarY = 65;
  await drawAvatar(ctx, groupImageUrl, avatarX, avatarY, avatarSize, groupName);

  ctx.fillStyle = C.cyan;
  ctx.font = 'bold 54px Sans';
  ctx.textAlign = 'left';
  ctx.fillText(truncate(groupName, 26), avatarX + avatarSize + 40, avatarY + 50);
  ctx.font = '600 28px Sans';
  ctx.fillStyle = C.dim;
  ctx.fillText('● GROUP WRAPPED — ADVANCED DASHBOARD', avatarX + avatarSize + 40, avatarY + 90);

  const now = new Date();
  ctx.textAlign = 'right';
  ctx.fillStyle = C.white;
  ctx.font = 'bold 34px Sans';
  ctx.fillText(now.toDateString(), W - 100, 100);
  ctx.textAlign = 'left';

  // ================= SECTION A: calendar + hourly/weekday =================
  const secAY = 260;
  const gridX = 100, gridW = 1450;
  const cell = 46, cellGap = 11, days = 91;

  const dailyVals = [];
  for (let i = days - 1; i >= 0; i--) {
    const d = new Date(now); d.setDate(d.getDate() - i);
    dailyVals.push(t.daily[dateKey(d)] || 0);
  }
  const maxDaily = Math.max(...dailyVals, 1);
  const peakIdx = dailyVals.indexOf(maxDaily);
  const peakDate = new Date(now); peakDate.setDate(peakDate.getDate() - (days - 1 - peakIdx));

  ctx.fillStyle = C.cyan;
  ctx.font = 'bold 32px Sans';
  ctx.fillText('☰  LAST 91 DAYS', gridX, secAY - 20);

  for (let i = 0; i < days; i++) {
    const col = Math.floor(i / 7), row = i % 7;
    const val = dailyVals[i];
    const intensity = val === 0 ? 0 : Math.min(1, val / maxDaily);
    const x = gridX + col * (cell + cellGap), y = secAY + row * (cell + cellGap);
    ctx.fillStyle = intensity === 0 ? C.cellEmpty : mixColor(C.green, intensity);
    roundRect(ctx, x, y, cell, cell, 8); ctx.fill();
    if (intensity > 0.5) { ctx.save(); ctx.shadowColor = C.green; ctx.shadowBlur = 10; ctx.fill(); ctx.restore(); }
    if (i === peakIdx && val > 0) { ctx.save(); ctx.strokeStyle = C.white; ctx.lineWidth = 3; roundRect(ctx, x, y, cell, cell, 8); ctx.stroke(); ctx.restore(); }
  }

  const legendY = secAY + 7 * (cell + cellGap) + 26;
  ctx.font = '24px monospace'; ctx.fillStyle = C.dim;
  ctx.fillText('Less', gridX, legendY + 18);
  for (let l = 0; l < 5; l++) {
    ctx.fillStyle = l === 0 ? C.cellEmpty : mixColor(C.green, l / 4);
    roundRect(ctx, gridX + 85 + l * 42, legendY, 34, 34, 6); ctx.fill();
  }
  ctx.fillStyle = C.dim;
  ctx.fillText('More', gridX + 85 + 5 * 42 + 12, legendY + 18);
  ctx.fillStyle = C.white; ctx.font = '22px monospace';
  ctx.fillText(`Peak: ${peakDate.toDateString()} (${maxDaily} msgs)`, gridX + 420, legendY + 18);

  const { current: curStreak, longest: longStreak } = computeStreaks(t.daily);
  const trend = computeTrend(t.daily);
  const stripY = legendY + 60;
  drawPanel(ctx, gridX, stripY, gridW, 120);
  const chips = [
    { label: 'CURRENT STREAK', value: `${curStreak}d`, color: C.green },
    { label: 'LONGEST STREAK', value: `${longStreak}d`, color: C.cyan },
    { label: '7-DAY TREND', value: `${trend.pct >= 0 ? '+' : ''}${trend.pct}%`, color: trend.pct >= 0 ? C.green : C.red },
  ];
  const chipW = gridW / 3;
  chips.forEach((c, i) => {
    const cx = gridX + i * chipW + chipW / 2;
    ctx.textAlign = 'center';
    ctx.fillStyle = c.color; ctx.font = 'bold 42px monospace'; ctx.fillText(c.value, cx, stripY + 65);
    ctx.fillStyle = C.dim; ctx.font = 'bold 20px monospace'; ctx.fillText(c.label, cx, stripY + 98);
    ctx.textAlign = 'left';
    if (i > 0) { ctx.strokeStyle = '#123057'; ctx.beginPath(); ctx.moveTo(gridX + i * chipW, stripY + 18); ctx.lineTo(gridX + i * chipW, stripY + 100); ctx.stroke(); }
  });

  // ---- hourly + weekday (right column of Section A) ----
  const hourX = 1650, hourW = 1250;
  const hourY = secAY, hourH = 430;
  drawPanel(ctx, hourX, hourY, hourW, hourH);
  ctx.fillStyle = C.cyan; ctx.font = 'bold 32px Sans';
  ctx.fillText('☰  ACTIVITY BY HOUR (0-23h)', hourX + 40, hourY + 55);
  const maxHour = Math.max(...t.hourly, 1);
  const bAX = hourX + 50, bAY = hourY + 100, bAW = hourW - 100, bAH = hourH - 170;
  for (let h = 0; h < 24; h++) {
    const val = t.hourly[h];
    const barH = (val / maxHour) * bAH;
    const x = bAX + h * (bAW / 24), y = bAY + bAH - barH, barW = bAW / 24 - 6;
    const grad = ctx.createLinearGradient(0, y, 0, bAY + bAH);
    grad.addColorStop(0, C.cyan); grad.addColorStop(1, C.blue);
    ctx.fillStyle = grad; roundRect(ctx, x, y, barW, Math.max(barH, 3), 4); ctx.fill();
    if (h % 3 === 0) { ctx.fillStyle = C.dim; ctx.font = '20px monospace'; ctx.textAlign = 'center'; ctx.fillText(String(h), x + barW / 2, bAY + bAH + 32); ctx.textAlign = 'left'; }
  }

  const wdY = hourY + hourH + 40, wdH = 430;
  drawPanel(ctx, hourX, wdY, hourW, wdH);
  ctx.fillStyle = C.cyan; ctx.font = 'bold 32px Sans';
  ctx.fillText('☰  ACTIVITY BY WEEKDAY', hourX + 40, wdY + 55);
  const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  const maxWd = Math.max(...t.weekday, 1);
  const wAX = hourX + 50, wAY = wdY + 100, wAW = hourW - 100, wAH = wdH - 170;
  for (let d = 0; d < 7; d++) {
    const val = t.weekday[d];
    const barH = (val / maxWd) * wAH;
    const x = wAX + d * (wAW / 7) + 10, y = wAY + wAH - barH, barW2 = wAW / 7 - 20;
    ctx.fillStyle = C.purple; ctx.shadowColor = C.purple; ctx.shadowBlur = 8;
    roundRect(ctx, x, y, barW2, Math.max(barH, 3), 6); ctx.fill(); ctx.shadowBlur = 0;
    ctx.fillStyle = C.dim; ctx.font = '18px monospace'; ctx.textAlign = 'center'; ctx.fillText(String(val), x + barW2 / 2, y - 8);
    ctx.fillStyle = C.white; ctx.font = 'bold 20px monospace'; ctx.fillText(dayLabels[d], x + barW2 / 2, wAY + wAH + 32);
    ctx.textAlign = 'left';
  }

  // ================= SECTION B: AWARDS =================
  const secBY = stripY + 140 > wdY + wdH ? stripY + 140 : wdY + wdH + 40;
  const awardsY = secBY + 20, awardsH = 380;
  drawPanel(ctx, 100, awardsY, 2800, awardsH);
  ctx.fillStyle = C.gold; ctx.font = 'bold 36px Sans';
  ctx.fillText('🏆  AWARDS & SUPERLATIVES', 140, awardsY + 55);

  const awards = computeAwards(t);
  const awardDefs = [
    { key: 'chatterbox', icon: '👑', title: 'CHATTERBOX', fmt: a => `${a.value} msgs` },
    { key: 'nightOwl', icon: '🦉', title: 'NIGHT OWL', fmt: a => `${a.value} late-night msgs` },
    { key: 'essayWriter', icon: '📝', title: 'ESSAY WRITER', fmt: a => `${a.value.toFixed(1)} words/msg` },
    { key: 'oneLiner', icon: '✂️', title: 'ONE-LINER', fmt: a => `${a.value.toFixed(1)} words/msg` },
    { key: 'emojiOverlord', icon: '😂', title: 'EMOJI OVERLORD', fmt: a => `${a.value} emojis` },
    { key: 'mediaMaven', icon: '📸', title: 'MEDIA MAVEN', fmt: a => `${a.value} shared` },
    { key: 'starter', icon: '🚀', title: 'CONVO STARTER', fmt: a => `${a.value} threads started` },
    { key: 'quickDraw', icon: '⚡', title: 'QUICK DRAW', fmt: a => `${formatSecs(a.value)} avg reply` },
  ];

  const cardW = 2720 / 4, cardH = 165;
  awardDefs.forEach((def, i) => {
    const col = i % 4, row = Math.floor(i / 4);
    const cx = 140 + col * cardW, cy = awardsY + 90 + row * (cardH + 15);
    drawPanel(ctx, cx, cy, cardW - 25, cardH, 16);

    ctx.font = '52px Sans';
    ctx.fillText(def.icon, cx + 20, cy + 65);

    ctx.fillStyle = C.gold; ctx.font = 'bold 22px monospace';
    ctx.fillText(def.title, cx + 95, cy + 40);

    const a = awards[def.key];
    ctx.fillStyle = C.white; ctx.font = 'bold 26px monospace';
    ctx.fillText(truncate(a ? a.user.name : 'N/A', 16), cx + 95, cy + 78);

    ctx.fillStyle = C.dim; ctx.font = '20px monospace';
    ctx.fillText(a ? def.fmt(a) : '—', cx + 95, cy + 108);
  });

  // ================= SECTION C: leaderboard / words / emojis =================
  const secCY = awardsY + awardsH + 40;
  const colGap = 30;
  const col1W = 940, col2W = 900, col3W = 900;
  const col1X = 100, col2X = col1X + col1W + colGap, col3X = col2X + col2W + colGap;
  const secCH = 560;

  // Top chatters
  drawPanel(ctx, col1X, secCY, col1W, secCH);
  ctx.fillStyle = C.cyan; ctx.font = 'bold 32px Sans';
  ctx.fillText('★  TOP CHATTERS', col1X + 40, secCY + 55);
  const topUsers = Object.values(t.users).sort((a, b) => b.count - a.count).slice(0, 6);
  const maxUserCount = topUsers.length ? topUsers[0].count : 1;
  const ldColors = [C.green, C.cyan, C.blue, C.purple, C.dim, C.white];
  let uy = secCY + 115;
  topUsers.forEach((u, i) => {
    ctx.fillStyle = ldColors[i % ldColors.length]; ctx.font = 'bold 28px monospace';
    ctx.fillText(`#${i + 1}`, col1X + 40, uy);
    ctx.fillStyle = C.white; ctx.font = 'bold 28px monospace';
    ctx.fillText(truncate(u.name || 'Unknown', 18), col1X + 130, uy);
    drawProgressBar(ctx, col1X + 40, uy + 18, col1W - 160, 18, (u.count / maxUserCount) * 100, ldColors[i % ldColors.length]);
    ctx.fillStyle = C.dim; ctx.font = '22px monospace'; ctx.textAlign = 'right';
    ctx.fillText(`${u.count}`, col1X + col1W - 40, uy);
    ctx.textAlign = 'left';
    uy += 78;
  });

  // Top words
  drawPanel(ctx, col2X, secCY, col2W, secCH);
  ctx.fillStyle = C.cyan; ctx.font = 'bold 32px Sans';
  ctx.fillText('💬  TOP WORDS', col2X + 40, secCY + 55);
  const topWords = Object.entries(t.wordFreq || {}).sort((a, b) => b[1] - a[1]).slice(0, 6);
  const maxWordCount = topWords.length ? topWords[0][1] : 1;
  let wy = secCY + 115;
  if (topWords.length === 0) {
    ctx.fillStyle = C.dim; ctx.font = '26px monospace'; ctx.fillText('Not enough text data yet.', col2X + 40, wy);
  }
  topWords.forEach(([word, count], i) => {
    ctx.fillStyle = ldColors[i % ldColors.length]; ctx.font = 'bold 28px monospace';
    ctx.fillText(truncate(word, 16), col2X + 40, wy);
    drawProgressBar(ctx, col2X + 40, wy + 18, col2W - 160, 18, (count / maxWordCount) * 100, ldColors[i % ldColors.length]);
    ctx.fillStyle = C.dim; ctx.font = '22px monospace'; ctx.textAlign = 'right';
    ctx.fillText(`${count}x`, col2X + col2W - 40, wy);
    ctx.textAlign = 'left';
    wy += 78;
  });

  // Top emojis
  drawPanel(ctx, col3X, secCY, col3W, secCH);
  ctx.fillStyle = C.cyan; ctx.font = 'bold 32px Sans';
  ctx.fillText('😂  TOP EMOJIS', col3X + 40, secCY + 55);
  const topEmojis = Object.entries(t.emojiFreq || {}).sort((a, b) => b[1] - a[1]).slice(0, 6);
  const maxEmojiCount = topEmojis.length ? topEmojis[0][1] : 1;
  let ey = secCY + 130;
  if (topEmojis.length === 0) {
    ctx.fillStyle = C.dim; ctx.font = '26px monospace'; ctx.fillText('No emoji data yet.', col3X + 40, ey);
  }
  topEmojis.forEach(([emoji, count], i) => {
    ctx.font = '46px Sans';
    ctx.fillText(emoji, col3X + 40, ey);
    ctx.fillStyle = C.white; ctx.font = 'bold 24px monospace';
    drawProgressBar(ctx, col3X + 110, ey - 22, col3W - 260, 18, (count / maxEmojiCount) * 100, ldColors[i % ldColors.length]);
    ctx.fillStyle = C.dim; ctx.font = '22px monospace'; ctx.textAlign = 'right';
    ctx.fillText(`${count}x`, col3X + col3W - 40, ey);
    ctx.textAlign = 'left';
    ey += 78;
  });

  // ================= SECTION D: message types + summary =================
  const secDY = secCY + secCH + 40, secDH = 300;
  const dLeftW = 1360, dRightW = 1410, dRightX = 100 + dLeftW + 40;

  drawPanel(ctx, 100, secDY, dLeftW, secDH);
  ctx.fillStyle = C.cyan; ctx.font = 'bold 32px Sans';
  ctx.fillText('☰  MESSAGE TYPES', 140, secDY + 55);
  const textC = t.textCount || 0, mediaC = t.mediaCount || 0;
  const totalTM = Math.max(textC + mediaC, 1);
  const textPct = (textC / totalTM) * 100;
  const donutCx = 280, donutCy = secDY + 175, donutR = 95;
  drawDonutSegment(ctx, donutCx, donutCy, donutR, 0, textPct, C.cyan);
  drawDonutSegment(ctx, donutCx, donutCy, donutR, textPct, 100, C.purple);
  ctx.fillStyle = C.white; ctx.font = 'bold 28px monospace'; ctx.textAlign = 'center';
  ctx.fillText(`${textPct.toFixed(0)}%`, donutCx, donutCy + 10);
  ctx.textAlign = 'left';
  ctx.fillStyle = C.cyan; ctx.font = 'bold 26px monospace';
  ctx.fillText(`● Text: ${textC}`, 460, secDY + 130);
  ctx.fillStyle = C.purple;
  ctx.fillText(`● Media/Sticker: ${mediaC}`, 460, secDY + 175);
  ctx.fillStyle = C.dim; ctx.font = '22px monospace';
  ctx.fillText(`Total classified: ${totalTM}`, 460, secDY + 220);

  drawPanel(ctx, dRightX, secDY, dRightW, secDH);
  ctx.fillStyle = C.cyan; ctx.font = 'bold 32px Sans';
  ctx.fillText('☰  SUMMARY', dRightX + 40, secDY + 55);
  const busiestHour = t.hourly.indexOf(Math.max(...t.hourly));
  const busiestDay = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][t.weekday.indexOf(Math.max(...t.weekday))];
  const activeDaysCount = Object.values(t.daily).filter(v => v > 0).length;
  const uniqueWords = Object.keys(t.wordFreq || {}).length;
  const summaryRows = [
    ['Total Messages', String(t.total)],
    ['Unique Members', String(Object.keys(t.users).length)],
    ['Active Days', String(activeDaysCount)],
    ['Busiest Hour', `${busiestHour}:00`],
    ['Busiest Day', busiestDay],
    ['Unique Words Tracked', String(uniqueWords)],
  ];
  let sy = secDY + 110;
  summaryRows.forEach(([label, value], i) => {
    if (i % 2 === 0) { ctx.fillStyle = C.panelAlt; ctx.fillRect(dRightX + 30, sy - 30, dRightW - 60, 56); }
    ctx.fillStyle = C.green; ctx.font = 'bold 24px monospace'; ctx.fillText(label, dRightX + 50, sy);
    ctx.fillStyle = C.white; ctx.font = 'bold 24px monospace'; ctx.textAlign = 'right';
    ctx.fillText(value, dRightX + dRightW - 50, sy); ctx.textAlign = 'left';
    sy += 62;
  });

  return canvas.toBuffer('image/png');
}

function formatSecs(sec) {
  if (sec < 60) return `${Math.round(sec)}s`;
  if (sec < 3600) return `${Math.round(sec / 60)}m`;
  return `${(sec / 3600).toFixed(1)}h`;
}

async function drawAvatar(ctx, imageUrl, x, y, size, fallbackName) {
  ctx.save();
  ctx.beginPath();
  ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2);
  ctx.closePath();
  ctx.clip();

  let drew = false;
  if (imageUrl) {
    try {
      const img = await loadImage(imageUrl);
      ctx.drawImage(img, x, y, size, size);
      drew = true;
    } catch (e) { drew = false; }
  }
  if (!drew) {
    const grad = ctx.createLinearGradient(x, y, x + size, y + size);
    grad.addColorStop(0, C.blue); grad.addColorStop(1, C.purple);
    ctx.fillStyle = grad; ctx.fillRect(x, y, size, size);
    ctx.fillStyle = C.white; ctx.font = `bold ${Math.floor(size * 0.45)}px Sans`;
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    const initial = (fallbackName || '?').trim().charAt(0).toUpperCase();
    ctx.fillText(initial, x + size / 2, y + size / 2 + 4);
    ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
  }
  ctx.restore();

  ctx.save();
  ctx.beginPath();
  ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2);
  ctx.lineWidth = 4; ctx.strokeStyle = C.cyan;
  ctx.shadowColor = C.glow; ctx.shadowBlur = 14;
  ctx.stroke();
  ctx.restore();
}

function drawDonutSegment(ctx, cx, cy, r, fromPct, toPct, color) {
  const start = -Math.PI / 2 + (fromPct / 100) * Math.PI * 2;
  const end = -Math.PI / 2 + (toPct / 100) * Math.PI * 2;
  ctx.beginPath();
  ctx.arc(cx, cy, r, start, end);
  ctx.strokeStyle = color; ctx.lineWidth = 30;
  ctx.shadowColor = color; ctx.shadowBlur = 10;
  ctx.stroke(); ctx.shadowBlur = 0;
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

function drawPanel(ctx, x, y, w, h, r = 20) {
  ctx.save();
  ctx.shadowColor = C.glow; ctx.shadowBlur = 16;
  roundRect(ctx, x, y, w, h, r);
  ctx.fillStyle = C.panel; ctx.fill();
  ctx.lineWidth = 2; ctx.strokeStyle = C.border; ctx.stroke();
  ctx.restore();
}

function drawProgressBar(ctx, x, y, w, h, pct, color) {
  roundRect(ctx, x, y, w, h, h / 2);
  ctx.fillStyle = '#0b1b33'; ctx.fill();
  const fillW = Math.max(h, (w * Math.min(pct, 100)) / 100);
  roundRect(ctx, x, y, fillW, h, h / 2);
  ctx.fillStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 8;
  ctx.fill(); ctx.shadowBlur = 0;
}

function truncate(str, n) {
  if (!str) return '';
  return str.length > n ? str.slice(0, n - 1) + '…' : str;
}

function mixColor(hex, intensity) {
  const c = hex.replace('#', '');
  const r = parseInt(c.substring(0, 2), 16), g = parseInt(c.substring(2, 4), 16), b = parseInt(c.substring(4, 6), 16);
  const baseR = 10, baseG = 30, baseB = 50;
  const nr = Math.round(baseR + (r - baseR) * intensity);
  const ng = Math.round(baseG + (g - baseG) * intensity);
  const nb = Math.round(baseB + (b - baseB) * intensity);
  return `rgb(${nr},${ng},${nb})`;
}

View raw file