Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a plot to the website #12

Merged
merged 2 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,4 @@ cython_debug/
#.idea/

.env
.DS_Store
14 changes: 5 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,14 @@ build: ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
@poetry build

.PHONY: docs-test
docs-test: ## Test if documentation can be built without warnings or errors
@poetry run mkdocs build -s

.PHONY: docs
docs: ## Build and serve the documentation
@poetry run mkdocs serve

.PHONY: serve
serve: ## Serve API with uvicorn
serve: ## Serve API with uvicorn in development mode
@poetry run uvicorn pypi_scout.api.main:app --reload

.PHONY: frontend
frontend: ## Serve frontend in development mode
@cd frontend; npm run dev

.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/components/InfoBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const InfoBox: React.FC<InfoBoxProps> = ({ 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.
</p>
</div>
);
Expand Down
278 changes: 278 additions & 0 deletions frontend/app/components/ScatterPlot.tsx
Original file line number Diff line number Diff line change
@@ -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<ScatterPlotProps> = ({ 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 (
<div className="overflow-auto w-full flex flex-col items-center">
<h2 className="text-center text-white mb-4">
Click a package to go to PyPI
</h2>
<hr className="border-gray-500 mb-4 w-full" />
<div className="w-full h-[600px]">
<Scatter data={data} options={options} plugins={plugins} />
</div>
</div>
);
};

export default ScatterPlot;
42 changes: 42 additions & 0 deletions frontend/app/components/ToggleSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from "react";

interface ToggleSwitchProps {
option1: string;
option2: string;
selectedOption: string;
onToggle: (option: string) => void;
}

const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
option1,
option2,
selectedOption,
onToggle,
}) => {
return (
<div className="flex space-x-4 bg-sky-800 p-2 rounded-lg shadow-md">
<button
className={`px-4 py-2 rounded ${
selectedOption === option1
? "bg-white text-sky-900"
: " bg-sky-950 text-white"
}`}
onClick={() => onToggle(option1)}
>
{option1}
</button>
<button
className={`px-4 py-2 rounded ${
selectedOption === option2
? "bg-white text-sky-900"
: " bg-sky-950 text-white"
}`}
onClick={() => onToggle(option2)}
>
{option2}
</button>
</div>
);
};

export default ToggleSwitch;
Loading
Loading