diff --git a/.gitignore b/.gitignore
index a6377d4..4d258ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,3 +78,11 @@ out
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+
+# Database files
+*.db
+*.sqlite
+*.sqlite3
+*.db-journal
+*.db-shm
+*.db-wal
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..b0d0698
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "makefile.configureOnOpen": true
+}
\ No newline at end of file
diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md
deleted file mode 100644
index 6c4f4c8..0000000
--- a/packages/client/CHANGELOG.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Change Log
-
-All notable changes to this project will be documented in this file.
-See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-
-# 0.1.0 (2025-02-12)
-
-**Note:** Version bump only for package @cosmology/db-client
diff --git a/packages/client/README.md b/packages/client/README.md
deleted file mode 100644
index 64b96df..0000000
--- a/packages/client/README.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# db-client
-
-
- 
- db-client
-
-
-## install
-
-```sh
-npm install db-client
-```
-## Table of contents
-
-- [db-client](#db-client)
- - [Install](#install)
- - [Table of contents](#table-of-contents)
-- [Developing](#developing)
-- [Credits](#credits)
-
-## Developing
-
-When first cloning the repo:
-
-```sh
-yarn
-# build the prod packages. When devs would like to navigate to the source code, this will only navigate from references to their definitions (.d.ts files) between packages.
-yarn build
-```
-
-Or if you want to make your dev process smoother, you can run:
-
-```sh
-yarn
-# build the dev packages with .map files, this enables navigation from references to their source code between packages.
-yarn build:dev
-```
-
-## Interchain JavaScript Stack
-
-A unified toolkit for building applications and smart contracts in the Interchain ecosystem ⚛️
-
-| Category | Tools | Description |
-|----------------------|------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|
-| **Chain Information** | [**Chain Registry**](https://github.com/hyperweb-io/chain-registry), [**Utils**](https://www.npmjs.com/package/@chain-registry/utils), [**Client**](https://www.npmjs.com/package/@chain-registry/client) | Everything from token symbols, logos, and IBC denominations for all assets you want to support in your application. |
-| **Wallet Connectors**| [**Interchain Kit**](https://github.com/hyperweb-io/interchain-kit)beta, [**Cosmos Kit**](https://github.com/hyperweb-io/cosmos-kit) | Experience the convenience of connecting with a variety of web3 wallets through a single, streamlined interface. |
-| **Signing Clients** | [**InterchainJS**](https://github.com/hyperweb-io/interchainjs)beta, [**CosmJS**](https://github.com/cosmos/cosmjs) | A single, universal signing interface for any network |
-| **SDK Clients** | [**Telescope**](https://github.com/hyperweb-io/telescope) | Your Frontend Companion for Building with TypeScript with Cosmos SDK Modules. |
-| **Starter Kits** | [**Create Interchain App**](https://github.com/hyperweb-io/create-interchain-app)beta, [**Create Cosmos App**](https://github.com/hyperweb-io/create-cosmos-app) | Set up a modern Interchain app by running one command. |
-| **UI Kits** | [**Interchain UI**](https://github.com/hyperweb-io/interchain-ui) | The Interchain Design System, empowering developers with a flexible, easy-to-use UI kit. |
-| **Testing Frameworks** | [**Starship**](https://github.com/hyperweb-io/starship) | Unified Testing and Development for the Interchain. |
-| **TypeScript Smart Contracts** | [**Create Hyperweb App**](https://github.com/hyperweb-io/create-hyperweb-app) | Build and deploy full-stack blockchain applications with TypeScript |
-| **CosmWasm Contracts** | [**CosmWasm TS Codegen**](https://github.com/CosmWasm/ts-codegen) | Convert your CosmWasm smart contracts into dev-friendly TypeScript classes. |
-
-## Credits
-
-🛠 Built by Hyperweb (formerly Cosmology) — if you like our tools, please checkout and contribute to [our github ⚛️](https://github.com/hyperweb-io)
-
-
-## Disclaimer
-
-AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED “AS IS”, AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
-
-No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
diff --git a/packages/client/__tests__/connection.test.ts b/packages/client/__tests__/connection.test.ts
deleted file mode 100644
index cf4bf91..0000000
--- a/packages/client/__tests__/connection.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-process.env.DATABASE_URL = 'postgres://postgres:password@localhost:5432/postgres';
-import { PoolClient } from 'pg';
-
-import { Database } from '../src';
-let client: Database;
-
-beforeAll(() => {
- client = new Database();
-})
-
-afterAll(() => {
- client.shutdown();
-});
-
-it('getClient', (done) => {
- client.withTransaction(async (client: PoolClient) => {
- try {
- const result = await client.query('SELECT 1');
- console.log(result.rows);
- done();
- } catch (error) {
- console.error('Error executing query:', error);
- done(error);
- }
- });
-});
diff --git a/packages/client/jest.config.js b/packages/client/jest.config.js
deleted file mode 100644
index 0aa3aaa..0000000
--- a/packages/client/jest.config.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/** @type {import('ts-jest').JestConfigWithTsJest} */
-module.exports = {
- preset: "ts-jest",
- testEnvironment: "node",
- transform: {
- "^.+\\.tsx?$": [
- "ts-jest",
- {
- babelConfig: false,
- tsconfig: "tsconfig.json",
- },
- ],
- },
- transformIgnorePatterns: [`/node_modules/*`],
- testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
- moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
- modulePathIgnorePatterns: ["dist/*"]
-};
diff --git a/packages/client/package.json b/packages/client/package.json
deleted file mode 100644
index 62761c9..0000000
--- a/packages/client/package.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "name": "@cosmology/db-client",
- "version": "0.1.0",
- "author": "Hyperweb ",
- "description": "db-client",
- "main": "index.js",
- "module": "esm/index.js",
- "types": "index.d.ts",
- "homepage": "https://github.com/cosmology/lib-count",
- "license": "SEE LICENSE IN LICENSE",
- "publishConfig": {
- "access": "public",
- "directory": "dist"
- },
- "repository": {
- "type": "git",
- "url": "https://github.com/cosmology/lib-count"
- },
- "bugs": {
- "url": "https://github.com/cosmology/lib-count/issues"
- },
- "scripts": {
- "copy": "copyfiles -f ../../LICENSE README.md package.json dist",
- "clean": "rimraf dist/**",
- "prepare": "npm run build",
- "build": "npm run clean; tsc; tsc -p tsconfig.esm.json; npm run copy",
- "build:dev": "npm run clean; tsc --declarationMap; tsc -p tsconfig.esm.json; npm run copy",
- "lint": "eslint . --fix",
- "test": "jest",
- "test:watch": "jest --watch"
- },
- "dependencies": {
- "@types/pg": "^8.11.10",
- "envalid": "^8.0.0",
- "pg": "^8.13.1"
- },
- "keywords": []
-}
\ No newline at end of file
diff --git a/packages/client/src/env.ts b/packages/client/src/env.ts
deleted file mode 100644
index 8fcf762..0000000
--- a/packages/client/src/env.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { cleanEnv, url } from 'envalid';
-
-const env = cleanEnv(
- process.env,
- {
- DATABASE_URL: url(),
- }
-);
-
-export default env;
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
deleted file mode 100644
index 69e4bb5..0000000
--- a/packages/client/src/index.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Pool, PoolClient } from 'pg';
-
-import env from './env';
-
-export class Database {
- private static instance: Database;
- private pool: Pool;
-
- constructor() {
- if (Database.instance) {
- return Database.instance;
- }
-
- const pgPoolConfig = {
- connectionString: env.DATABASE_URL,
- };
- this.pool = new Pool(pgPoolConfig);
-
- // Ensure the pool is closed on process termination
- process.on('SIGTERM', async () => {
- await this.shutdown();
- });
-
- Database.instance = this;
- return this;
- }
-
- /**
- * Executes a callback function within a database transaction.
- * @param fn - A callback function that receives a PoolClient to perform database operations.
- */
- async withTransaction(fn: (client: PoolClient) => Promise): Promise {
- const client = await this.pool.connect();
- try {
- await client.query('BEGIN');
- try {
- await fn(client);
- await client.query('COMMIT');
- } catch (e) {
- console.error('Error during transaction:', e);
- await client.query('ROLLBACK');
- throw e; // Re-throw the error to propagate it
- }
- } finally {
- client.release();
- }
- }
-
- /**
- * Shuts down the connection pool.
- */
- async shutdown(): Promise {
- await this.pool.end();
- }
-}
\ No newline at end of file
diff --git a/packages/client/tsconfig.esm.json b/packages/client/tsconfig.esm.json
deleted file mode 100644
index 800d750..0000000
--- a/packages/client/tsconfig.esm.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "outDir": "dist/esm",
- "module": "es2022",
- "rootDir": "src/",
- "declaration": false
- }
-}
diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json
deleted file mode 100644
index 1a9d569..0000000
--- a/packages/client/tsconfig.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src/"
- },
- "include": ["src/**/*.ts"],
- "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"]
-}
diff --git a/packages/data-vis/.gitignore b/packages/data-vis/.gitignore
new file mode 100644
index 0000000..5ef6a52
--- /dev/null
+++ b/packages/data-vis/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/packages/data-vis/README.md b/packages/data-vis/README.md
new file mode 100644
index 0000000..e215bc4
--- /dev/null
+++ b/packages/data-vis/README.md
@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
diff --git a/packages/data-vis/components.json b/packages/data-vis/components.json
new file mode 100644
index 0000000..4f36604
--- /dev/null
+++ b/packages/data-vis/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
\ No newline at end of file
diff --git a/packages/data-vis/eslint.config.mjs b/packages/data-vis/eslint.config.mjs
new file mode 100644
index 0000000..c85fb67
--- /dev/null
+++ b/packages/data-vis/eslint.config.mjs
@@ -0,0 +1,16 @@
+import { dirname } from "path";
+import { fileURLToPath } from "url";
+import { FlatCompat } from "@eslint/eslintrc";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+});
+
+const eslintConfig = [
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
+];
+
+export default eslintConfig;
diff --git a/packages/data-vis/next.config.ts b/packages/data-vis/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/packages/data-vis/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/packages/data-vis/package.json b/packages/data-vis/package.json
new file mode 100644
index 0000000..5203fe3
--- /dev/null
+++ b/packages/data-vis/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "data-vis",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.828.0",
+ "@observablehq/plot": "^0.6.14",
+ "@radix-ui/react-dialog": "^1.1.14",
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@tanstack/react-query": "^5.51.15",
+ "@tanstack/react-virtual": "^3.13.10",
+ "@visx/responsive": "^3.10.0",
+ "better-sqlite3": "^11.10.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "d3": "^7.9.0",
+ "date-fns": "^4.1.0",
+ "drizzle-orm": "^0.44.2",
+ "lucide-react": "^0.514.0",
+ "next": "15.3.3",
+ "next-themes": "^0.4.6",
+ "react": "^19.0.0",
+ "react-colorful": "^5.6.1",
+ "react-dom": "^19.0.0",
+ "react-icons": "^5.2.1",
+ "recharts": "^2.15.3",
+ "tailwind-merge": "^3.3.1",
+ "zod": "^3.25.62"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3",
+ "@tailwindcss/postcss": "^4",
+ "@types/better-sqlite3": "^7.6.13",
+ "@types/d3": "^7.4.3",
+ "@types/node": "^22",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "15.3.3",
+ "tailwindcss": "^4",
+ "tw-animate-css": "^1.3.4",
+ "typescript": "^5"
+ }
+}
diff --git a/packages/data-vis/postcss.config.mjs b/packages/data-vis/postcss.config.mjs
new file mode 100644
index 0000000..c7bcb4b
--- /dev/null
+++ b/packages/data-vis/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;
diff --git a/packages/data-vis/public/file.svg b/packages/data-vis/public/file.svg
new file mode 100644
index 0000000..004145c
--- /dev/null
+++ b/packages/data-vis/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/data-vis/public/globe.svg b/packages/data-vis/public/globe.svg
new file mode 100644
index 0000000..567f17b
--- /dev/null
+++ b/packages/data-vis/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/data-vis/public/next.svg b/packages/data-vis/public/next.svg
new file mode 100644
index 0000000..5174b28
--- /dev/null
+++ b/packages/data-vis/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/data-vis/public/vercel.svg b/packages/data-vis/public/vercel.svg
new file mode 100644
index 0000000..7705396
--- /dev/null
+++ b/packages/data-vis/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/data-vis/public/window.svg b/packages/data-vis/public/window.svg
new file mode 100644
index 0000000..b2b2a44
--- /dev/null
+++ b/packages/data-vis/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/data-vis/src/app/api/npm/categories/route.ts b/packages/data-vis/src/app/api/npm/categories/route.ts
new file mode 100644
index 0000000..d9f6ab8
--- /dev/null
+++ b/packages/data-vis/src/app/api/npm/categories/route.ts
@@ -0,0 +1,48 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { category, packageCategory, npmPackage } from "@stats-db/schema";
+import { eq, asc } from "drizzle-orm";
+import { PackageComparison } from "@/lib/types";
+
+export async function GET() {
+ try {
+ const categories = await db
+ .select({
+ id: category.id,
+ name: category.name,
+ })
+ .from(category)
+ .orderBy(asc(category.name));
+
+ const comparisons: PackageComparison[] = await Promise.all(
+ categories.map(async (cat) => {
+ const packages = await db
+ .select({
+ name: npmPackage.packageName,
+ })
+ .from(packageCategory)
+ .innerJoin(
+ npmPackage,
+ eq(packageCategory.packageId, npmPackage.packageName)
+ )
+ .where(eq(packageCategory.categoryId, cat.id));
+
+ return {
+ title: cat.name,
+ packageGroups: packages.map((p) => ({
+ packages: [{ name: p.name }],
+ color: null,
+ })),
+ };
+ })
+ );
+
+ return NextResponse.json(comparisons);
+ } catch (error) {
+ console.error("Failed to fetch categories:", error);
+ return NextResponse.json(
+ { error: "Internal Server Error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/data-vis/src/app/api/npm/categories/stats/route.ts b/packages/data-vis/src/app/api/npm/categories/stats/route.ts
new file mode 100644
index 0000000..c448800
--- /dev/null
+++ b/packages/data-vis/src/app/api/npm/categories/stats/route.ts
@@ -0,0 +1,139 @@
+import { NextResponse } from "next/server";
+import { getS3Database } from "@/lib/s3-db";
+import { packages as dataConfigPackages } from "@stats-db/tasks/npm/data-config";
+
+type HighLevelCategory = "web2" | "web3" | "utils";
+type MonthlyDownloads = Record>;
+
+export async function GET() {
+ try {
+ const db = await getS3Database();
+
+ // 1. Get all unique package names from the database
+ const allDbPackages = db
+ .prepare(
+ `
+ SELECT DISTINCT package_name as name FROM npm_package
+ `
+ )
+ .all() as { name: string }[];
+ const allDbPackageNames = new Set(allDbPackages.map((p) => p.name));
+
+ // 2. Get all packages from data-config.ts
+ const allConfigPackages = new Set();
+ Object.values(dataConfigPackages).forEach((pkgList) => {
+ pkgList.forEach((pkgName) => allConfigPackages.add(pkgName));
+ });
+
+ // 3. Combine all packages (from config and database)
+ const allPackages = new Set([...allDbPackageNames, ...allConfigPackages]);
+
+ // 4. Classify all packages into high-level categories
+ const packageToCategoryMap = new Map();
+ const utilsConfigPackages = new Set(dataConfigPackages.utils || []);
+ const launchqlConfigPackages = new Set(dataConfigPackages.launchql || []);
+
+ allPackages.forEach((name) => {
+ if (utilsConfigPackages.has(name)) {
+ packageToCategoryMap.set(name, "utils");
+ } else if (
+ launchqlConfigPackages.has(name) ||
+ name.startsWith("@launchql/")
+ ) {
+ packageToCategoryMap.set(name, "web2");
+ } else {
+ packageToCategoryMap.set(name, "web3");
+ }
+ });
+
+ // 5. Fetch all daily download records
+ const allDownloadsComplete = db
+ .prepare(
+ `
+ SELECT package_name as packageName, download_count as downloads, date
+ FROM daily_downloads
+ `
+ )
+ .all() as {
+ packageName: string;
+ downloads: number;
+ date: string | number;
+ }[];
+
+ // 6. Aggregate downloads in TypeScript
+ const monthlyDownloads: MonthlyDownloads = {};
+
+ for (const record of allDownloadsComplete) {
+ if (!record.date) continue;
+
+ // Handle both string and integer date formats
+ let date: Date;
+ if (typeof record.date === "number") {
+ // SQLite stores as Unix timestamp - could be seconds or milliseconds
+ // If the number is less than a reasonable timestamp in milliseconds (year 2000),
+ // assume it's in seconds and convert to milliseconds
+ const timestamp =
+ record.date < 946684800000 ? record.date * 1000 : record.date;
+ date = new Date(timestamp);
+ } else {
+ // String format
+ date = new Date(record.date);
+ }
+
+ // Validate the date
+ if (isNaN(date.getTime())) {
+ console.warn(
+ `Invalid date found: ${record.date} for package ${record.packageName}`
+ );
+ continue;
+ }
+
+ const year = date.getUTCFullYear();
+ const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
+ const monthKey = `${year}-${month}`;
+
+ const category = packageToCategoryMap.get(record.packageName);
+ if (!category) continue;
+
+ if (!monthlyDownloads[monthKey]) {
+ monthlyDownloads[monthKey] = { web2: 0, web3: 0, utils: 0 };
+ }
+
+ monthlyDownloads[monthKey][category] += record.downloads || 0;
+ }
+
+ // 7. Generate a complete monthly series from 2019 to now
+ const chartData: {
+ date: string;
+ web2: number;
+ web3: number;
+ utils: number;
+ }[] = [];
+ const startDate = new Date("2019-01-01");
+ const endDate = new Date();
+
+ for (let d = startDate; d <= endDate; d.setMonth(d.getMonth() + 1)) {
+ const year = d.getUTCFullYear();
+ const month = (d.getUTCMonth() + 1).toString().padStart(2, "0");
+ const monthKey = `${year}-${month}`;
+
+ chartData.push({
+ date: `${monthKey}-01`,
+ web2: monthlyDownloads[monthKey]?.web2 || 0,
+ web3: monthlyDownloads[monthKey]?.web3 || 0,
+ utils: monthlyDownloads[monthKey]?.utils || 0,
+ });
+ }
+
+ // Close the database connection
+ db.close();
+
+ return NextResponse.json(chartData);
+ } catch (error) {
+ console.error("Failed to fetch category stats from S3 database:", error);
+ return NextResponse.json(
+ { error: "Failed to fetch category stats" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/data-vis/src/app/api/npm/downloads/route.ts b/packages/data-vis/src/app/api/npm/downloads/route.ts
new file mode 100644
index 0000000..3d5ec0b
--- /dev/null
+++ b/packages/data-vis/src/app/api/npm/downloads/route.ts
@@ -0,0 +1,169 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { packageGroupSchema } from "@/lib/types";
+
+const searchParamsSchema = z.object({
+ packageGroups: z.string().transform((str) => {
+ const parsed = JSON.parse(str);
+ return z.array(packageGroupSchema).parse(parsed);
+ }),
+ range: z.string(),
+ binType: z.string(),
+});
+
+// Helper function to fetch NPM download data
+async function fetchNpmDownloads(
+ packageName: string,
+ startDate: string,
+ endDate: string
+) {
+ const url = `https://api.npmjs.org/downloads/range/${startDate}:${endDate}/${packageName}`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ return { downloads: [] };
+ }
+ throw new Error(
+ `Failed to fetch data for ${packageName}: ${response.statusText}`
+ );
+ }
+
+ return await response.json();
+}
+
+// Helper function to calculate date range based on range parameter
+function getDateRange(range: string): { startDate: string; endDate: string } {
+ const now = new Date();
+ const endDate = now.toISOString().split("T")[0];
+
+ let startDate: string;
+
+ switch (range) {
+ case "7-days":
+ startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ break;
+ case "30-days":
+ startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ break;
+ case "90-days":
+ startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ break;
+ case "180-days":
+ startDate = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ break;
+ case "365-days":
+ startDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ break;
+ case "730-days":
+ startDate = new Date(now.getTime() - 730 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ break;
+ case "1825-days":
+ startDate = new Date(now.getTime() - 1825 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ break;
+ case "all-time":
+ startDate = "2010-01-12"; // NPM was created around this time
+ break;
+ default:
+ startDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split("T")[0];
+ }
+
+ return { startDate, endDate };
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const searchParams = request.nextUrl.searchParams;
+ const { packageGroups, range } = searchParamsSchema.parse({
+ packageGroups: searchParams.get("packageGroups"),
+ range: searchParams.get("range"),
+ binType: searchParams.get("binType"),
+ });
+
+ const { startDate, endDate } = getDateRange(range);
+
+ // Fetch data for each package group
+ const results = await Promise.all(
+ packageGroups.map(async (group) => {
+ try {
+ const packages = await Promise.all(
+ group.packages.map(async (pkg) => {
+ try {
+ const downloadData = await fetchNpmDownloads(
+ pkg.name,
+ startDate,
+ endDate
+ );
+
+ return {
+ name: pkg.name,
+ hidden: pkg.hidden || false,
+ downloads: downloadData.downloads || [],
+ };
+ } catch (error) {
+ console.error(`Failed to fetch data for ${pkg.name}:`, error);
+ return {
+ name: pkg.name,
+ hidden: pkg.hidden || false,
+ downloads: [],
+ };
+ }
+ })
+ );
+
+ return {
+ packages,
+ baseline: group.baseline || false,
+ start: startDate,
+ end: endDate,
+ color: group.color,
+ error: null,
+ };
+ } catch (error) {
+ console.error(`Failed to fetch data for package group:`, error);
+ return {
+ packages: group.packages.map((pkg) => ({
+ name: pkg.name,
+ hidden: pkg.hidden || false,
+ downloads: [],
+ })),
+ baseline: group.baseline || false,
+ start: startDate,
+ end: endDate,
+ color: group.color,
+ error: `Failed to fetch package data: ${(error as Error).message}`,
+ };
+ }
+ })
+ );
+
+ return NextResponse.json(results);
+ } catch (error) {
+ console.error("API Error:", error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: error.issues }, { status: 400 });
+ }
+
+ return NextResponse.json(
+ { error: "Internal Server Error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/data-vis/src/app/api/npm/packages/route.ts b/packages/data-vis/src/app/api/npm/packages/route.ts
new file mode 100644
index 0000000..279a728
--- /dev/null
+++ b/packages/data-vis/src/app/api/npm/packages/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { getS3Database } from "@/lib/s3-db";
+
+export async function GET() {
+ try {
+ const db = await getS3Database();
+
+ const packages = db
+ .prepare(
+ `
+ SELECT DISTINCT package_name as name
+ FROM npm_package
+ ORDER BY package_name ASC
+ `
+ )
+ .all() as { name: string }[];
+
+ const packageNames = packages.map((p) => p.name);
+
+ // Close the database connection
+ db.close();
+
+ return NextResponse.json(packageNames);
+ } catch (error) {
+ console.error("Failed to fetch packages from S3 database:", error);
+ return NextResponse.json(
+ { error: "Failed to fetch packages" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/data-vis/src/app/favicon.ico b/packages/data-vis/src/app/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/packages/data-vis/src/app/favicon.ico differ
diff --git a/packages/data-vis/src/app/globals.css b/packages/data-vis/src/app/globals.css
new file mode 100644
index 0000000..97afb5e
--- /dev/null
+++ b/packages/data-vis/src/app/globals.css
@@ -0,0 +1,122 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.21 0.006 285.885);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.705 0.015 286.067);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.92 0.004 286.32);
+ --primary-foreground: oklch(0.21 0.006 285.885);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.552 0.016 285.938);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/packages/data-vis/src/app/layout.tsx b/packages/data-vis/src/app/layout.tsx
new file mode 100644
index 0000000..d6d49cd
--- /dev/null
+++ b/packages/data-vis/src/app/layout.tsx
@@ -0,0 +1,42 @@
+import type { Metadata } from "next";
+import { Inter as FontSans } from "next/font/google";
+import "./globals.css";
+import { cn } from "@/lib/utils";
+import { ThemeProvider } from "@/components/theme-provider";
+
+const fontSans = FontSans({
+ subsets: ["latin"],
+ variable: "--font-sans",
+});
+
+export const metadata: Metadata = {
+ title: "Cosmos Package Analytics",
+ description:
+ "Analyze and compare download trends for Cosmos ecosystem packages",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/packages/data-vis/src/app/page.tsx b/packages/data-vis/src/app/page.tsx
new file mode 100644
index 0000000..2882a9c
--- /dev/null
+++ b/packages/data-vis/src/app/page.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import Link from "next/link";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import { BarChart3 } from "lucide-react";
+
+import { ModeToggle } from "@/components/theme-toggle";
+import { CategoryStatsChart } from "@/components/category-stats-chart";
+
+const queryClient = new QueryClient();
+
+function CategoryAnalyticsPage() {
+ return (
+
+
+
+
+ Ecosystem Analytics
+
+
+ Download trends for key package categories in the Cosmos ecosystem.
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function Home() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/data-vis/src/app/stats/npm/page.tsx b/packages/data-vis/src/app/stats/npm/page.tsx
new file mode 100644
index 0000000..435f578
--- /dev/null
+++ b/packages/data-vis/src/app/stats/npm/page.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import * as React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { NpmStatsChart } from "@/components/npm-stats-chart";
+import { PackageSelector } from "@/components/package-selector";
+import { ModeToggle } from "@/components/theme-toggle";
+import { PackageGroup, TimeRange, BinType } from "@/lib/types";
+import { getCosmosComparisons } from "@/lib/cosmos-comparisons";
+
+const queryClient = new QueryClient();
+
+function NpmStatsPage() {
+ // Initialize with the first cosmos comparison
+ const [packageGroups, setPackageGroups] = React.useState(
+ () => getCosmosComparisons()[0]?.packageGroups || []
+ );
+ const [range, setRange] = React.useState("365-days");
+ const [binType, setBinType] = React.useState("weekly");
+
+ return (
+
+
+
+
+ NPM Package Analytics
+
+
+ Analyze and compare download trends for Cosmos ecosystem packages
+
+
+
+
+
+
+ {/* Package Selection Sidebar */}
+
+
+ {/* Chart Area */}
+
+
+
+
+
+ {/* Additional Stats Cards */}
+
+
+
Total Packages
+
+ {packageGroups.reduce(
+ (acc, group) => acc + group.packages.length,
+ 0
+ )}
+
+
+
+
+
Visible Packages
+
+ {packageGroups.reduce(
+ (acc, group) =>
+ acc + group.packages.filter((p) => !p.hidden).length,
+ 0
+ )}
+
+
+
+
+
Time Range
+
+ {range
+ .replace("-", " ")
+ .replace("days", "Days")
+ .replace("all time", "All Time")}
+
+
+
+
+ );
+}
+
+export default function Page() {
+ return (
+ Loading...}>
+
+
+
+
+ );
+}
diff --git a/packages/data-vis/src/components/category-stats-chart.tsx b/packages/data-vis/src/components/category-stats-chart.tsx
new file mode 100644
index 0000000..c57a154
--- /dev/null
+++ b/packages/data-vis/src/components/category-stats-chart.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import * as React from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Line, LineChart, CartesianGrid, XAxis, YAxis, Legend } from "recharts";
+import { format, parseISO } from "date-fns";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { formatNumber } from "@/lib/types";
+
+type CategoryData = {
+ date: string;
+ web2: number;
+ web3: number;
+ utils: number;
+};
+
+const chartConfig: ChartConfig = {
+ web2: {
+ label: "Web2",
+ color: "hsl(220, 70%, 50%)", // Blue
+ },
+ web3: {
+ label: "Web3",
+ color: "hsl(280, 70%, 50%)", // Purple
+ },
+ utils: {
+ label: "Utils",
+ color: "hsl(140, 70%, 50%)", // Green
+ },
+};
+
+export function CategoryStatsChart() {
+ const {
+ data: chartData,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["category-stats-all-time"],
+ queryFn: async () => {
+ const response = await fetch(`/api/npm/categories/stats`);
+ if (!response.ok) {
+ throw new Error("Failed to fetch category stats");
+ }
+ return response.json();
+ },
+ initialData: [],
+ });
+
+ const totalDownloads = React.useMemo(() => {
+ return {
+ web2: chartData.reduce((acc, curr) => acc + curr.web2, 0),
+ web3: chartData.reduce((acc, curr) => acc + curr.web3, 0),
+ utils: chartData.reduce((acc, curr) => acc + curr.utils, 0),
+ };
+ }, [chartData]);
+
+ return (
+
+ {/* Loading Overlay */}
+ {isLoading && (
+
+
+
+
+ Loading chart data...
+
+
+
+ )}
+
+ {/* Error Overlay */}
+ {error && (
+
+
+
+ !
+
+
Failed to load chart data
+
+
+ )}
+
+
+
+ Category Download Trends
+
+ Showing total monthly downloads for web2, web3, and utils categories
+
+
+
+ {Object.entries(totalDownloads).map(([key, value]) => (
+
+
+ {chartConfig[key]?.label}
+
+
+ {formatNumber(value)}
+
+
+ ))}
+
+
+
+
+
+
+ {
+ const date = parseISO(value);
+ return format(date, "MMM yyyy");
+ }}
+ />
+ formatNumber(Number(value))}
+ />
+ }
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/data-vis/src/components/npm-stats-chart.tsx b/packages/data-vis/src/components/npm-stats-chart.tsx
new file mode 100644
index 0000000..9d62812
--- /dev/null
+++ b/packages/data-vis/src/components/npm-stats-chart.tsx
@@ -0,0 +1,375 @@
+"use client";
+
+import * as React from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Line, LineChart, CartesianGrid, XAxis, YAxis } from "recharts";
+import { format, parseISO } from "date-fns";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+
+import {
+ PackageGroup,
+ TimeRange,
+ BinType,
+ NpmQueryData,
+ timeRanges,
+ binningOptions,
+ formatNumber,
+ getPackageColor,
+} from "@/lib/types";
+
+// Data fetching hook
+function useNpmStats({
+ packageGroups,
+ range,
+ binType,
+}: {
+ packageGroups: PackageGroup[];
+ range: TimeRange;
+ binType: BinType;
+}) {
+ return useQuery({
+ queryKey: ["npm-stats", packageGroups, range, binType],
+ queryFn: async (): Promise => {
+ const params = new URLSearchParams({
+ packageGroups: JSON.stringify(packageGroups),
+ range,
+ binType,
+ });
+
+ const response = await fetch(`/api/npm/downloads?${params}`);
+ if (!response.ok) {
+ throw new Error("Failed to fetch npm stats");
+ }
+
+ return response.json();
+ },
+ enabled: packageGroups.length > 0,
+ });
+}
+
+// Chart component
+interface NpmStatsChartProps {
+ packageGroups: PackageGroup[];
+ range: TimeRange;
+ binType: BinType;
+ height?: number;
+ onRangeChange?: (range: TimeRange) => void;
+ onBinTypeChange?: (binType: BinType) => void;
+}
+
+export function NpmStatsChart({
+ packageGroups,
+ range,
+ binType,
+ height = 400,
+ onRangeChange,
+ onBinTypeChange,
+}: NpmStatsChartProps) {
+ const {
+ data: queryData,
+ isLoading,
+ error,
+ } = useNpmStats({
+ packageGroups,
+ range,
+ binType,
+ });
+
+ // Transform data for Recharts
+ const chartData = React.useMemo(() => {
+ if (!queryData?.length) return [];
+
+ // Collect all unique dates
+ const dateSet = new Set();
+ queryData.forEach((group) => {
+ group.packages.forEach((pkg) => {
+ pkg.downloads.forEach((download) => {
+ dateSet.add(download.day);
+ });
+ });
+ });
+
+ const sortedDates = Array.from(dateSet).sort();
+
+ // Create chart data with all packages
+ return sortedDates.map((date) => {
+ const dataPoint: Record = { date };
+
+ queryData.forEach((group) => {
+ group.packages.forEach((pkg) => {
+ if (pkg.hidden) return;
+
+ const download = pkg.downloads.find((d) => d.day === date);
+ dataPoint[pkg.name] = download?.downloads || 0;
+ });
+ });
+
+ return dataPoint;
+ });
+ }, [queryData]);
+
+ // Generate chart config
+ const chartConfig = React.useMemo((): ChartConfig => {
+ const config: ChartConfig = {};
+
+ if (queryData) {
+ queryData.forEach((group) => {
+ group.packages.forEach((pkg) => {
+ if (pkg.hidden) return;
+
+ config[pkg.name] = {
+ label: pkg.name,
+ color: group.color || getPackageColor(pkg.name, packageGroups),
+ };
+ });
+ });
+ }
+
+ return config;
+ }, [queryData, packageGroups]);
+
+ // Get all visible package names for rendering lines
+ const visiblePackages = React.useMemo(() => {
+ if (!queryData) return [];
+
+ const packages: string[] = [];
+ queryData.forEach((group) => {
+ group.packages.forEach((pkg) => {
+ if (!pkg.hidden) {
+ packages.push(pkg.name);
+ }
+ });
+ });
+
+ return [...new Set(packages)];
+ }, [queryData]);
+
+ // Calculate totals for each package
+ const packageTotals = React.useMemo(() => {
+ if (!queryData) return {};
+
+ const totals: Record = {};
+ queryData.forEach((group) => {
+ group.packages.forEach((pkg) => {
+ if (!pkg.hidden) {
+ totals[pkg.name] = pkg.downloads.reduce(
+ (sum, d) => sum + d.downloads,
+ 0
+ );
+ }
+ });
+ });
+
+ return totals;
+ }, [queryData]);
+
+ if (error) {
+ return (
+
+ {/* Error Overlay */}
+
+
+
+ !
+
+
+ Failed to load chart data: {(error as Error).message}
+
+
+
+
+
+
+ NPM Package Downloads
+ Error loading package data
+
+
+
+
+ {/* Empty chart area */}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Loading Overlay */}
+ {isLoading && (
+
+
+
+
+ Loading chart data...
+
+
+
+ )}
+
+
+
+ NPM Package Downloads
+
+ Showing download trends for {visiblePackages.length} package(s) over{" "}
+ {timeRanges.find((r) => r.value === range)?.label}
+
+
+
+ {/* Package summary */}
+
+ {visiblePackages.slice(0, 3).map((packageName) => (
+
+
+ {packageName}
+
+
+ {formatNumber(packageTotals[packageName] || 0)}
+
+
+ ))}
+ {visiblePackages.length > 3 && (
+
+
+ +{visiblePackages.length - 3} more
+
+
+ )}
+
+
+
+
+ {/* Controls */}
+
+
+
+
+
+ {/* Package badges */}
+
+ {visiblePackages.map((packageName) => (
+
+ {packageName}
+
+ ))}
+
+
+
+ {/* Chart */}
+
+
+
+ {
+ const date = parseISO(value);
+ return format(date, "MMM dd");
+ }}
+ />
+
+ {
+ const date = parseISO(value as string);
+ return format(date, "MMM dd, yyyy");
+ }}
+ formatter={(value, name) => [
+ formatNumber(Number(value)),
+ chartConfig[name as string]?.label || name,
+ ]}
+ />
+ }
+ />
+ {visiblePackages.map((packageName) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/packages/data-vis/src/components/package-selector.tsx b/packages/data-vis/src/components/package-selector.tsx
new file mode 100644
index 0000000..92ec440
--- /dev/null
+++ b/packages/data-vis/src/components/package-selector.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import * as React from "react";
+import { Search, X, Plus } from "lucide-react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+import { PackageGroup, PackageComparison } from "@/lib/types";
+import { useQuery } from "@tanstack/react-query";
+
+interface PackageSelectorProps {
+ selectedPackageGroups: PackageGroup[];
+ onPackageGroupsChange: (packageGroups: PackageGroup[]) => void;
+}
+
+export function PackageSelector({
+ selectedPackageGroups,
+ onPackageGroupsChange,
+}: PackageSelectorProps) {
+ const [searchOpen, setSearchOpen] = React.useState(false);
+ const [searchValue, setSearchValue] = React.useState("");
+
+ const { data: allComparisons } = useQuery({
+ queryKey: ["npm-categories"],
+ queryFn: async () => {
+ const res = await fetch("/api/npm/categories");
+ if (!res.ok) {
+ console.error("Failed to fetch categories");
+ return [];
+ }
+ return res.json();
+ },
+ initialData: [],
+ });
+
+ const { data: popularCosmosPackages } = useQuery({
+ queryKey: ["npm-packages"],
+ queryFn: async () => {
+ const res = await fetch("/api/npm/packages");
+ if (!res.ok) {
+ console.error("Failed to fetch packages");
+ return [];
+ }
+ return res.json();
+ },
+ initialData: [],
+ });
+
+ const handlePresetSelect = (comparisonTitle: string) => {
+ if (comparisonTitle === "custom") {
+ onPackageGroupsChange([]);
+ return;
+ }
+ const comparison = allComparisons.find((c) => c.title === comparisonTitle);
+ if (comparison) {
+ onPackageGroupsChange(comparison.packageGroups);
+ }
+ };
+
+ const handleAddPackage = (packageName: string) => {
+ if (!packageName.trim()) return;
+
+ const newGroup: PackageGroup = {
+ packages: [{ name: packageName.trim() }],
+ color: null,
+ };
+
+ onPackageGroupsChange([...selectedPackageGroups, newGroup]);
+ setSearchValue("");
+ setSearchOpen(false);
+ };
+
+ const handleRemovePackageGroup = (index: number) => {
+ const newGroups = selectedPackageGroups.filter((_, i) => i !== index);
+ onPackageGroupsChange(newGroups);
+ };
+
+ const handleTogglePackageVisibility = (
+ groupIndex: number,
+ packageIndex: number
+ ) => {
+ const newGroups = [...selectedPackageGroups];
+ const pkg = newGroups[groupIndex].packages[packageIndex];
+ pkg.hidden = !pkg.hidden;
+ onPackageGroupsChange(newGroups);
+ };
+
+ const filteredPackages = popularCosmosPackages.filter((pkg) =>
+ pkg.toLowerCase().includes(searchValue.toLowerCase())
+ );
+
+ // Virtualizer for the "Selected Packages" list
+ const selectedPackagesParentRef = React.useRef(null);
+ const selectedPackagesVirtualizer = useVirtualizer({
+ count: selectedPackageGroups.length,
+ getScrollElement: () => selectedPackagesParentRef.current,
+ estimateSize: () => 50, // Estimate height of a selected package item
+ overscan: 5,
+ });
+
+ return (
+
+
+ Package Selection
+
+ Choose from popular Cosmos package comparisons or add custom packages
+
+
+
+ {/* Preset Comparisons */}
+
+
+
+
+
+ {/* Add Custom Package */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {filteredPackages.map((pkg) => (
+ handleAddPackage(pkg)}
+ >
+ {pkg}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {/* Selected Packages */}
+ {selectedPackageGroups.length > 0 && (
+
+
+
+
+ {selectedPackagesVirtualizer
+ .getVirtualItems()
+ .map((virtualItem) => {
+ const groupIndex = virtualItem.index;
+ const group = selectedPackageGroups[groupIndex];
+ return (
+
+
+
+ {group.packages.map((pkg, packageIndex) => (
+
+ handleTogglePackageVisibility(
+ groupIndex,
+ packageIndex
+ )
+ }
+ style={{
+ backgroundColor: pkg.hidden
+ ? undefined
+ : (group.color || "#000") + "20",
+ borderColor: group.color || undefined,
+ color: group.color || undefined,
+ }}
+ >
+ {pkg.name}
+ {pkg.hidden && " (hidden)"}
+
+ ))}
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {selectedPackageGroups.length === 0 && (
+
+
+
No packages selected
+
+ Choose a preset comparison or add custom packages
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/data-vis/src/components/theme-provider.tsx b/packages/data-vis/src/components/theme-provider.tsx
new file mode 100644
index 0000000..189a2b1
--- /dev/null
+++ b/packages/data-vis/src/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import * as React from "react";
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+
+export function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps) {
+ return {children};
+}
diff --git a/packages/data-vis/src/components/theme-toggle.tsx b/packages/data-vis/src/components/theme-toggle.tsx
new file mode 100644
index 0000000..9264016
--- /dev/null
+++ b/packages/data-vis/src/components/theme-toggle.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import * as React from "react";
+import { Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function ModeToggle() {
+ const { setTheme } = useTheme();
+
+ return (
+
+
+
+
+
+ setTheme("light")}>
+ Light
+
+ setTheme("dark")}>
+ Dark
+
+ setTheme("system")}>
+ System
+
+
+
+ );
+}
diff --git a/packages/data-vis/src/components/ui/badge.tsx b/packages/data-vis/src/components/ui/badge.tsx
new file mode 100644
index 0000000..0205413
--- /dev/null
+++ b/packages/data-vis/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/packages/data-vis/src/components/ui/button.tsx b/packages/data-vis/src/components/ui/button.tsx
new file mode 100644
index 0000000..5a56302
--- /dev/null
+++ b/packages/data-vis/src/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ xs: "h-7 rounded-sm gap-1 px-2 text-xs has-[>svg]:px-1.5",
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/data-vis/src/components/ui/card.tsx b/packages/data-vis/src/components/ui/card.tsx
new file mode 100644
index 0000000..d05bbc6
--- /dev/null
+++ b/packages/data-vis/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/packages/data-vis/src/components/ui/chart.tsx b/packages/data-vis/src/components/ui/chart.tsx
new file mode 100644
index 0000000..97cc280
--- /dev/null
+++ b/packages/data-vis/src/components/ui/chart.tsx
@@ -0,0 +1,353 @@
+"use client"
+
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+