diff --git a/.gitignore b/.gitignore
index bf23a33..6888734 100644
--- a/.gitignore
+++ b/.gitignore
@@ -167,3 +167,4 @@ cython_debug/
#.idea/
.env
+.DS_Store
diff --git a/frontend/app/components/InfoBox.tsx b/frontend/app/components/InfoBox.tsx
index 88c5cd4..60b9b08 100644
--- a/frontend/app/components/InfoBox.tsx
+++ b/frontend/app/components/InfoBox.tsx
@@ -24,7 +24,7 @@ const InfoBox: React.FC = ({ infoBoxVisible }) => {
packages on PyPI, which includes all packages with at least ~100
downloads per week. The results are then scored based on their
similarity to the query and their number of weekly downloads, and the
- best results are displayed in the table below.
+ best results are displayed in the plot and table above.
);
diff --git a/frontend/app/components/ScatterPlot.tsx b/frontend/app/components/ScatterPlot.tsx
new file mode 100644
index 0000000..2e1e923
--- /dev/null
+++ b/frontend/app/components/ScatterPlot.tsx
@@ -0,0 +1,278 @@
+import React from "react";
+import { Scatter } from "react-chartjs-2";
+import {
+ Chart,
+ Tooltip,
+ Legend,
+ PointElement,
+ LinearScale,
+ Title,
+ LogarithmicScale,
+ CategoryScale,
+} from "chart.js";
+
+Chart.register(
+ Tooltip,
+ Legend,
+ PointElement,
+ LinearScale,
+ Title,
+ LogarithmicScale,
+ CategoryScale,
+);
+
+interface Match {
+ name: string;
+ similarity: number;
+ weekly_downloads: number;
+ summary: string;
+}
+
+interface ScatterPlotProps {
+ results: Match[];
+}
+
+const getColor = (
+ similarity: number,
+ downloads: number,
+ minSim: number,
+ maxSim: number,
+ minLogDownloads: number,
+ maxLogDownloads: number,
+) => {
+ const baseColor = [54, 162, 235]; // Blue
+ const highlightColor = [255, 99, 132]; // Red
+
+ const normalizedSimilarity = (similarity - minSim) / (maxSim - minSim);
+ const normalizedDownloads =
+ (Math.log10(downloads) - minLogDownloads) /
+ (maxLogDownloads - minLogDownloads);
+
+ const weight = Math.min(
+ ((normalizedSimilarity + normalizedDownloads) / 2) * 1.5,
+ 1,
+ );
+
+ const color = baseColor.map((base, index) =>
+ Math.round(base + weight * (highlightColor[index] - base)),
+ );
+
+ return `rgba(${color.join(",")}, 0.8)`;
+};
+
+const getPointSize = (
+ similarity: number,
+ downloads: number,
+ minSim: number,
+ maxSim: number,
+ minLogDownloads: number,
+ maxLogDownloads: number,
+) => {
+ const normalizedSimilarity = (similarity - minSim) / (maxSim - minSim);
+ const normalizedDownloads =
+ (Math.log10(downloads) - minLogDownloads) /
+ (maxLogDownloads - minLogDownloads);
+
+ const minSize = 2;
+ const size = Math.min(
+ (normalizedSimilarity + normalizedDownloads) * 10 + minSize,
+ 25,
+ );
+ return size;
+};
+
+const ScatterPlot: React.FC = ({ results }) => {
+ const similarities = results.map((result) => result.similarity);
+ const downloads = results.map((result) => result.weekly_downloads);
+ const logDownloads = downloads.map((download) => Math.log10(download));
+
+ const minSim = Math.min(...similarities);
+ const maxSim = Math.max(...similarities);
+ const minLogDownloads = Math.min(...logDownloads);
+ const maxLogDownloads = Math.max(...logDownloads);
+
+ const data = {
+ datasets: [
+ {
+ label: "Packages",
+ data: results.map((result) => ({
+ x: result.similarity,
+ y: result.weekly_downloads,
+ name: result.name,
+ summary: result.summary,
+ link: `https://pypi.org/project/${result.name}/`,
+ })),
+ backgroundColor: results.map((result) =>
+ getColor(
+ result.similarity,
+ result.weekly_downloads,
+ minSim,
+ maxSim,
+ minLogDownloads,
+ maxLogDownloads,
+ ),
+ ),
+ borderColor: results.map((result) =>
+ getColor(
+ result.similarity,
+ result.weekly_downloads,
+ minSim,
+ maxSim,
+ minLogDownloads,
+ maxLogDownloads,
+ ),
+ ),
+ pointRadius: results.map((result) =>
+ getPointSize(
+ result.similarity,
+ result.weekly_downloads,
+ minSim,
+ maxSim,
+ minLogDownloads,
+ maxLogDownloads,
+ ),
+ ),
+ hoverBackgroundColor: results.map((result) =>
+ getColor(
+ result.similarity,
+ result.weekly_downloads,
+ minSim,
+ maxSim,
+ minLogDownloads,
+ maxLogDownloads,
+ ),
+ ),
+ hoverBorderColor: results.map((result) =>
+ getColor(
+ result.similarity,
+ result.weekly_downloads,
+ minSim,
+ maxSim,
+ minLogDownloads,
+ maxLogDownloads,
+ ),
+ ),
+ pointHoverRadius: 15,
+ },
+ ],
+ };
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ tooltip: {
+ callbacks: {
+ title: (context: any) => {
+ const dataPoint = context[0].raw;
+ return dataPoint.name;
+ },
+ beforeLabel: (context: any) => {
+ const dataPoint = context.raw;
+ return dataPoint.summary;
+ },
+ label: () => "",
+ afterLabel: (context: any) => {
+ const dataPoint = context.raw;
+ return `\nWeekly downloads: ${dataPoint.y.toLocaleString()}`;
+ },
+ },
+ titleFont: { size: 16, weight: "bold" },
+ bodyFont: { size: 14 },
+ footerFont: { size: 12 },
+ displayColors: false,
+ backgroundColor: "rgba(0, 0, 0, 0.8)",
+ padding: 10,
+ bodySpacing: 4,
+ titleAlign: "left",
+ bodyAlign: "left",
+ footerAlign: "left",
+ },
+ legend: {
+ display: false,
+ },
+ },
+ scales: {
+ x: {
+ title: {
+ display: true,
+ text: "Similarity",
+ color: "#FFFFFF",
+ font: {
+ size: 24,
+ },
+ },
+ ticks: {
+ color: "#FFFFFF",
+ },
+ },
+ y: {
+ title: {
+ display: true,
+ text: "Weekly Downloads",
+ color: "#FFFFFF",
+ font: {
+ size: 24,
+ },
+ },
+ ticks: {
+ callback: function (value: any) {
+ return value.toLocaleString();
+ },
+ color: "#FFFFFF",
+ maxTicksLimit: 5,
+ },
+ type: "logarithmic",
+ },
+ },
+ onClick: (event: any, elements: any) => {
+ if (elements.length > 0) {
+ const elementIndex = elements[0].index;
+ const datasetIndex = elements[0].datasetIndex;
+ const link = data.datasets[datasetIndex].data[elementIndex].link;
+ window.open(link, "_blank");
+ }
+ },
+ onHover: (event: any, elements: any) => {
+ event.native.target.style.cursor = elements[0] ? "pointer" : "default";
+ },
+ elements: {
+ point: {
+ hoverRadius: 15,
+ },
+ },
+ };
+
+ const plugins = [
+ {
+ id: "customLabels",
+ afterDatasetsDraw: (chart: any) => {
+ const ctx = chart.ctx;
+ chart.data.datasets.forEach((dataset: any) => {
+ dataset.data.forEach((dataPoint: any, index: number) => {
+ const { x, y } = chart
+ .getDatasetMeta(0)
+ .data[index].tooltipPosition();
+ ctx.fillStyle = "white";
+ ctx.textAlign = "center";
+ ctx.fillText(dataPoint.name, x, y - 10);
+ });
+ });
+ },
+ },
+ ];
+
+ return (
+
+
+ Click a package to go to PyPI
+
+
+
+
+
+
+ );
+};
+
+export default ScatterPlot;
diff --git a/frontend/app/components/ToggleSwitch.tsx b/frontend/app/components/ToggleSwitch.tsx
new file mode 100644
index 0000000..3bec932
--- /dev/null
+++ b/frontend/app/components/ToggleSwitch.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+
+interface ToggleSwitchProps {
+ option1: string;
+ option2: string;
+ selectedOption: string;
+ onToggle: (option: string) => void;
+}
+
+const ToggleSwitch: React.FC = ({
+ option1,
+ option2,
+ selectedOption,
+ onToggle,
+}) => {
+ return (
+
+ onToggle(option1)}
+ >
+ {option1}
+
+ onToggle(option2)}
+ >
+ {option2}
+
+
+ );
+};
+
+export default ToggleSwitch;
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 1a21814..4132ae6 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,9 +1,11 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect, useRef } from "react";
import { handleSearch, sortResults } from "./utils/search";
import SearchResultsTable from "./components/SearchResultsTable";
import InfoBox from "./components/InfoBox";
+import ScatterPlot from "./components/ScatterPlot";
+import ToggleSwitch from "./components/ToggleSwitch";
import { ClipLoader } from "react-spinners";
import Header from "./components/Header";
@@ -22,6 +24,22 @@ export default function Home() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [infoBoxVisible, setInfoBoxVisible] = useState(false);
+ const [view, setView] = useState("Plot");
+
+ const resultsRef = useRef(null);
+
+ // If user is on small screen, we probably
+ useEffect(() => {
+ if (window.innerWidth < 768) {
+ setView("Table");
+ }
+ }, []);
+
+ useEffect(() => {
+ if (results.length > 0) {
+ resultsRef.current?.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [results]);
const handleSort = (field: string) => {
const direction =
@@ -31,17 +49,21 @@ export default function Home() {
setResults(sortResults(results, field, direction));
};
+ const handleSearchAction = () => {
+ handleSearch(
+ text,
+ sortField,
+ sortDirection,
+ setResults,
+ setLoading,
+ setError,
+ );
+ };
+
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
- handleSearch(
- text,
- sortField,
- sortDirection,
- setResults,
- setLoading,
- setError,
- );
+ handleSearchAction();
}
};
@@ -74,16 +96,7 @@ export default function Home() {
>
- handleSearch(
- text,
- sortField,
- sortDirection,
- setResults,
- setLoading,
- setError,
- )
- }
+ onClick={handleSearchAction}
>
Search
@@ -93,6 +106,41 @@ export default function Home() {
{error && {error}
}
+ {results.length > 0 && (
+
+
+
+ )}
+
+
+ {" "}
+ {/* Reference to this div */}
+ {results.length > 0 && view === "Plot" && (
+
+ )}
+ {results.length > 0 && view === "Table" && (
+
+ )}
+
+
-
- {results.length > 0 && (
-
- )}
);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8e8f3ef..26eb8c1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,8 +9,10 @@
"version": "0.1.0",
"dependencies": {
"axios": "^1.7.2",
+ "chart.js": "^4.4.3",
"next": "14.2.4",
"react": "^18",
+ "react-chartjs-2": "^5.2.0",
"react-dom": "^18",
"react-icons": "^5.2.1",
"react-spinners": "^0.13.8"
@@ -233,6 +235,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
+ "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
+ },
"node_modules/@next/env": {
"version": "14.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz",
@@ -1069,6 +1076,17 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chart.js": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.3.tgz",
+ "integrity": "sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -3805,6 +3823,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-chartjs-2": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
+ "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 0e5834f..9287417 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,8 +10,10 @@
},
"dependencies": {
"axios": "^1.7.2",
+ "chart.js": "^4.4.3",
"next": "14.2.4",
"react": "^18",
+ "react-chartjs-2": "^5.2.0",
"react-dom": "^18",
"react-icons": "^5.2.1",
"react-spinners": "^0.13.8"
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..13a2b7b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,68 @@
+{
+ "name": "pypi-llm",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "chart.js": "^4.4.3",
+ "react-chartjs-2": "^5.2.0"
+ }
+ },
+ "node_modules/@kurkle/color": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
+ "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
+ },
+ "node_modules/chart.js": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.3.tgz",
+ "integrity": "sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "peer": true
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "peer": true,
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-chartjs-2": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
+ "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5dac333
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+ "dependencies": {
+ "chart.js": "^4.4.3",
+ "react-chartjs-2": "^5.2.0"
+ }
+}
diff --git a/pypi_scout/api/main.py b/pypi_scout/api/main.py
index f54c585..fe7ba75 100644
--- a/pypi_scout/api/main.py
+++ b/pypi_scout/api/main.py
@@ -43,7 +43,7 @@
@app.post("/api/search", response_model=SearchResponse)
-@limiter.limit("4/minute")
+@limiter.limit("6/minute")
async def search(query: QueryModel, request: Request):
"""
Search for the packages whose summary and description have the highest similarity to the query.