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 ( +
+ + +
+ ); +}; + +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() { > @@ -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" && ( +
+
+ +
+
+ )} +
+
); 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.