harshapitla/github-actions-analytics
0
1import React, { useMemo, useState } from "react";2import {3 ResponsiveContainer,4 BarChart,5 Bar,6 XAxis,7 YAxis,8 Tooltip,9 CartesianGrid,10 LabelList,11 Cell,12} from "recharts";13import { formatNumberPrecise } from "../components/utils/uiUtils";14import formatSI from "../components/utils/formatSI";15 16const MIN_COUNT = 5;17const SCALE_OPTIONS = [18 { id: "absolute", label: "Avg downloads" },19 { id: "relative", label: "% of leader" },20];21const SOURCE_OPTIONS = [22 { id: "all", label: "All features" },23 { id: "categories", label: "Categories" },24 { id: "tags", label: "Tags" },25];26 27function normalizeFeatureName(input) {28 if (!input) return "";29 let s = String(input);30 s = s.replace(/[_\-./]+/g, " ");31 s = s.replace(/([a-z])([A-Z])/g, "$1 $2");32 s = s.replace(/([A-Za-z])([0-9])/g, "$1 $2");33 s = s.replace(/\s+/g, " ").trim();34 return s35 .split(" ")36 .map((w) => w.charAt(0).toUpperCase() + w.slice(1))37 .join(" ");38}39 40function extractTokens(field) {41 if (!field) return [];42 return String(field)43 .replace(/[|/\\;]/g, ",")44 .split(",")45 .map((t) => t.trim())46 .filter(Boolean);47}48 49export default function FeatureInfluence({ rows }) {50 const [scale, setScale] = useState("absolute");51 const [source, setSource] = useState("all");52 const [activeFeature, setActiveFeature] = useState(null);53 54 const data = useMemo(() => {55 if (!rows?.length) return [];56 57 const map = {};58 rows.forEach((row) => {59 const downloads = Number(row.downloads_count) || 0;60 const categories = source !== "tags" ? extractTokens(row.categories) : [];61 const tags = source !== "categories" ? extractTokens(row.tags) : [];62 [...categories, ...tags].forEach((feat) => {63 const key = normalizeFeatureName(feat).toLowerCase();64 if (!key) return;65 map[key] = map[key] || { name: normalizeFeatureName(feat), sum: 0, count: 0 };66 map[key].sum += downloads;67 map[key].count += 1;68 });69 });70 71 return Object.values(map)72 .map((entry) => ({73 feature: entry.name,74 count: entry.count,75 avgDownloads: entry.count ? Math.round(entry.sum / entry.count) : 0,76 }))77 .filter((entry) => entry.count >= MIN_COUNT)78 .sort((a, b) => b.avgDownloads - a.avgDownloads)79 .slice(0, 14);80 }, [rows, source]);81 82 if (!data.length) {83 return (84 <div className="card" style={{ padding: 20 }}>85 <h3>Feature Influence</h3>86 <p className="small">Not enough feature data to generate insights.</p>87 </div>88 );89 }90 91 const maxDownload = Math.max(...data.map((item) => item.avgDownloads));92 const display = data.map((item, index) => ({93 ...item,94 featureShort: item.feature,95 relativeValue: maxDownload ? (item.avgDownloads / maxDownload) * 100 : 0,96 rank: index + 1,97 }));98 99 return (100 <>101 <div className="ams-simple-header">102 <div>103 <h3>Feature Influence</h3>104 <p>Which features from tags or categories correlate with higher downloads. Toggle source or scale to reframe the story.</p>105 </div>106 <div className="feature-controls">107 <div className="filter-group">108 <span className="filter-label">From:</span>109 {SOURCE_OPTIONS.map((option) => (110 <button111 key={option.id}112 className={`pill-button ${source === option.id ? "is-active" : ""}`}113 onClick={() => setSource(option.id)}114 >115 {option.label}116 </button>117 ))}118 </div>119 <div className="filter-group">120 <span className="filter-label">Scale:</span>121 {SCALE_OPTIONS.map((option) => (122 <button123 key={option.id}124 className={`pill-button ${scale === option.id ? "is-active" : ""}`}125 onClick={() => setScale(option.id)}126 >127 {option.label}128 </button>129 ))}130 </div>131 </div>132 </div>133 134 <div className="chart-area" style={{ height: 460 }}>135 <ResponsiveContainer>136 <BarChart137 data={display}138 layout="vertical"139 margin={{ top: 12, right: 40, left: 200, bottom: 12 }}140 barCategoryGap={14}141 >142 <CartesianGrid strokeDasharray="3 3" />143 <XAxis144 type="number"145 tickFormatter={(value) => (scale === "absolute" ? formatSI(value) : `${value.toFixed(0)}%`)}146 />147 <YAxis148 type="category"149 dataKey="featureShort"150 width={230}151 tick={<FeatureTick />}152 />153 <Tooltip154 formatter={(value, key, payload) => {155 if (key === "avgDownloads") return [formatNumberPrecise(value), "Avg downloads"];156 if (key === "count") return [`${value} extensions`, "Extensions"];157 if (key === "relativeValue") return [`${value.toFixed(1)}% of leader`, "Relative"];158 return value;159 }}160 labelFormatter={(label) => {161 const entry = display.find((item) => item.featureShort === label);162 return entry ? `${entry.rank}. ${entry.feature}` : label;163 }}164 contentStyle={{ borderRadius: 8, border: "1px solid #dbe3f4", fontSize: 13 }}165 />166 <Bar167 dataKey={scale === "absolute" ? "avgDownloads" : "relativeValue"}168 radius={[10, 10, 10, 10]}169 barSize={20}170 isAnimationActive={false}171 onMouseMove={(_, index) => setActiveFeature(display[index]?.feature)}172 onMouseLeave={() => setActiveFeature(null)}173 >174 {display.map((entry) => (175 <Cell176 key={entry.feature}177 fill={entry.feature === activeFeature ? "#7c3aed" : "rgba(124,58,237,0.75)"}178 stroke="#7c3aed"179 strokeWidth={entry.feature === activeFeature ? 2 : 0.6}180 />181 ))}182 <LabelList183 dataKey={scale === "absolute" ? "avgDownloads" : "relativeValue"}184 formatter={(value, entry) =>185 scale === "absolute" ? formatSI(value) : `${value.toFixed(0)}%`186 }187 position="right"188 fontSize={11}189 fill="#0f172a"190 />191 </Bar>192 </BarChart>193 </ResponsiveContainer>194 </div>195 196 <p className="small" style={{ marginTop: 10, color: "#475569" }}>197 Features linked to popular workflows (containers, notebooks, packaging) consistently deliver higher downloads. Source toggle isolates whether the signal comes from category metadata or explicit tags.198 </p>199 </>200 );201}202 203function FeatureTick({ x, y, payload }) {204 const raw = payload.value || "";205 const lines = raw.length > 28 ? raw.match(/.{1,28}(\\s|$)/g) : [raw];206 const offsetY = -((lines.length - 1) * 6);207 return (208 <g transform={`translate(${x},${y + offsetY})`}>209 {lines.map((line, idx) => (210 <text211 key={idx}212 x={-4}213 y={idx * 12}214 textAnchor="end"215 fontSize={12}216 fill="#1f2933"217 >218 {line.trim()}219 </text>220 ))}221 </g>222 );223}224 