tui-trends 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -15
- package/dist/cli.js +270 -131
- package/package.json +5 -1
- package/dist/cli.cjs +0 -46297
package/README.md
CHANGED
|
@@ -6,14 +6,14 @@ Cyberpunk Google Trends visualizer in your terminal.
|
|
|
6
6
|
|
|
7
7
|
## Install globally
|
|
8
8
|
|
|
9
|
-
**From npm
|
|
9
|
+
**From npm:**
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g tui-trends
|
|
13
13
|
tui-trends "buy bitcoin"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
**From this repo
|
|
16
|
+
**From this repo:**
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
npm install
|
|
@@ -28,38 +28,63 @@ tui-trends "buy bitcoin"
|
|
|
28
28
|
npx tui-trends "buy bitcoin"
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Google Trends (default)
|
|
35
|
+
tui-trends "buy bitcoin"
|
|
36
|
+
|
|
37
|
+
# npm package download trends — no rate limits, great for testing
|
|
38
|
+
tui-trends --npm react
|
|
39
|
+
tui-trends --npm typescript
|
|
40
|
+
tui-trends --npm next
|
|
41
|
+
```
|
|
42
|
+
|
|
31
43
|
## Development
|
|
32
44
|
|
|
33
45
|
```bash
|
|
34
46
|
npm install
|
|
35
|
-
npm start -- "buy bitcoin"
|
|
36
|
-
npm run dev -- "buy bitcoin"
|
|
47
|
+
npm start -- "buy bitcoin" # runs via tsx, no build step
|
|
48
|
+
npm run dev -- "buy bitcoin" # tsx watch mode, reloads on save
|
|
49
|
+
npm start -- --npm react # npm mode in dev
|
|
37
50
|
```
|
|
38
51
|
|
|
39
52
|
## Keybindings
|
|
40
53
|
|
|
41
|
-
| Key
|
|
42
|
-
|
|
|
43
|
-
| `q`
|
|
44
|
-
|
|
|
45
|
-
|
|
54
|
+
| Key | Action |
|
|
55
|
+
| --------- | ------------------------ |
|
|
56
|
+
| `q` | Quit |
|
|
57
|
+
| `← →` | Cycle through themes |
|
|
58
|
+
|
|
59
|
+
## Themes
|
|
60
|
+
|
|
61
|
+
Cycle through 6 built-in colour themes with `←` / `→`:
|
|
62
|
+
|
|
63
|
+
| # | Name | Vibe |
|
|
64
|
+
|---|-------------|-------------------------------|
|
|
65
|
+
| 1 | Synthwave | Neon cyan & hot pink (default)|
|
|
66
|
+
| 2 | Matrix | All green on black |
|
|
67
|
+
| 3 | C64 | Commodore 64 blue/white |
|
|
68
|
+
| 4 | Amber | Phosphor amber monitor |
|
|
69
|
+
| 5 | Nord | Muted arctic pastels |
|
|
70
|
+
| 6 | Blood Moon | Deep red & orange |
|
|
46
71
|
|
|
47
72
|
## What you'll see
|
|
48
73
|
|
|
49
74
|
- **Loading screen** — animated spinner with ASCII art banner while data is fetched
|
|
50
|
-
- **
|
|
51
|
-
- **
|
|
52
|
-
- **
|
|
75
|
+
- **Line chart** — braille-rendered trend line (0–100 index, last 12 months)
|
|
76
|
+
- **Bar chart** — top regions (Google mode) or peak download weeks (npm mode)
|
|
77
|
+
- **Table** — related queries (Google mode) or monthly download breakdown (npm mode)
|
|
53
78
|
|
|
54
79
|
## Stack
|
|
55
80
|
|
|
56
81
|
- [Rezi](https://rezitui.dev) — TypeScript TUI framework with native C rendering engine
|
|
57
82
|
- [google-trends-api](https://www.npmjs.com/package/google-trends-api) — unofficial Google Trends client
|
|
58
|
-
- [figlet](https://www.npmjs.com/package/figlet) — ASCII art banner
|
|
59
83
|
- [tsx](https://github.com/privatenumber/tsx) — run TypeScript directly
|
|
60
84
|
|
|
61
85
|
## Notes
|
|
62
86
|
|
|
63
|
-
- Requires
|
|
64
|
-
- Google Trends may rate-limit
|
|
87
|
+
- Requires Node 18+ (uses native `fetch` for npm API)
|
|
88
|
+
- Google Trends mode hits `trends.google.com` directly and may rate-limit if called repeatedly
|
|
89
|
+
- Use `--npm` mode for unlimited local testing
|
|
65
90
|
- Best displayed in a terminal at least 120 columns wide
|
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,96 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ui,
|
|
6
|
+
rgb,
|
|
7
|
+
draculaTheme,
|
|
8
|
+
darkTheme,
|
|
9
|
+
nordTheme,
|
|
10
|
+
dimmedTheme
|
|
11
|
+
} from "@rezi-ui/core";
|
|
5
12
|
import { createNodeApp } from "@rezi-ui/node";
|
|
6
13
|
import googleTrends from "google-trends-api";
|
|
7
|
-
var
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
var THEMES = [
|
|
15
|
+
{
|
|
16
|
+
name: "Synthwave",
|
|
17
|
+
rezi: draculaTheme,
|
|
18
|
+
chart: "#00ffc8",
|
|
19
|
+
accent1: rgb(80, 250, 123),
|
|
20
|
+
accent2: rgb(0, 255, 200),
|
|
21
|
+
accent3: rgb(255, 46, 151),
|
|
22
|
+
banner1: rgb(0, 255, 200),
|
|
23
|
+
banner2: rgb(255, 46, 151),
|
|
24
|
+
barVariant: "info",
|
|
25
|
+
dim: rgb(98, 114, 164),
|
|
26
|
+
border: rgb(0, 200, 160)
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "Matrix",
|
|
30
|
+
rezi: darkTheme,
|
|
31
|
+
chart: "#00ff41",
|
|
32
|
+
accent1: rgb(0, 255, 65),
|
|
33
|
+
accent2: rgb(57, 255, 20),
|
|
34
|
+
accent3: rgb(0, 200, 40),
|
|
35
|
+
banner1: rgb(0, 255, 65),
|
|
36
|
+
banner2: rgb(0, 160, 30),
|
|
37
|
+
barVariant: "success",
|
|
38
|
+
dim: rgb(0, 100, 25),
|
|
39
|
+
border: rgb(0, 160, 40)
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "C64",
|
|
43
|
+
rezi: darkTheme,
|
|
44
|
+
chart: "#acacff",
|
|
45
|
+
accent1: rgb(172, 172, 255),
|
|
46
|
+
accent2: rgb(255, 255, 255),
|
|
47
|
+
accent3: rgb(255, 255, 100),
|
|
48
|
+
banner1: rgb(172, 172, 255),
|
|
49
|
+
banner2: rgb(100, 100, 220),
|
|
50
|
+
barVariant: "info",
|
|
51
|
+
dim: rgb(80, 80, 160),
|
|
52
|
+
border: rgb(120, 120, 220)
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "Amber",
|
|
56
|
+
rezi: dimmedTheme,
|
|
57
|
+
chart: "#ffaa00",
|
|
58
|
+
accent1: rgb(255, 200, 50),
|
|
59
|
+
accent2: rgb(255, 140, 0),
|
|
60
|
+
accent3: rgb(255, 100, 0),
|
|
61
|
+
banner1: rgb(255, 160, 0),
|
|
62
|
+
banner2: rgb(200, 80, 0),
|
|
63
|
+
barVariant: "warning",
|
|
64
|
+
dim: rgb(120, 70, 0),
|
|
65
|
+
border: rgb(180, 100, 0)
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "Nord",
|
|
69
|
+
rezi: nordTheme,
|
|
70
|
+
chart: "#88c0d0",
|
|
71
|
+
accent1: rgb(163, 190, 140),
|
|
72
|
+
accent2: rgb(136, 192, 208),
|
|
73
|
+
accent3: rgb(180, 142, 173),
|
|
74
|
+
banner1: rgb(136, 192, 208),
|
|
75
|
+
banner2: rgb(94, 129, 172),
|
|
76
|
+
barVariant: "info",
|
|
77
|
+
dim: rgb(76, 86, 106),
|
|
78
|
+
border: rgb(94, 129, 172)
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "Blood Moon",
|
|
82
|
+
rezi: darkTheme,
|
|
83
|
+
chart: "#ff3030",
|
|
84
|
+
accent1: rgb(255, 80, 80),
|
|
85
|
+
accent2: rgb(255, 140, 0),
|
|
86
|
+
accent3: rgb(255, 220, 50),
|
|
87
|
+
banner1: rgb(255, 48, 48),
|
|
88
|
+
banner2: rgb(180, 0, 0),
|
|
89
|
+
barVariant: "error",
|
|
90
|
+
dim: rgb(100, 30, 30),
|
|
91
|
+
border: rgb(180, 20, 20)
|
|
92
|
+
}
|
|
93
|
+
];
|
|
12
94
|
var BANNER_TEXT = " _____ _ _ ___ _____ ____ _____ _ _ ____ ____ \n |_ _| | | |_ _| |_ _| _ \\| ____| \\ | | _ \\/ ___| \n | | | | | || | | | | |_) | _| | \\| | | | \\___ \\ \n | | | |_| || | | | | _ <| |___| |\\ | |_| |___) |\n |_| \\___/|___| |_| |_| \\_\\_____|_| \\_|____/|____/ ";
|
|
13
95
|
var BANNER_LINES = BANNER_TEXT.split("\n").filter((l) => l.length > 0);
|
|
14
96
|
async function fetchTrends(keyword2) {
|
|
@@ -28,164 +110,211 @@ async function fetchTrends(keyword2) {
|
|
|
28
110
|
})
|
|
29
111
|
);
|
|
30
112
|
const regionsJson = JSON.parse(regionsRaw);
|
|
31
|
-
const regions = (regionsJson.default?.geoMapData ?? []).map((d) => ({
|
|
32
|
-
geoName: String(d.geoName ?? ""),
|
|
33
|
-
value: Number(d.value?.[0] ?? 0)
|
|
34
|
-
})).sort((a, b) => b.value - a.value).slice(0, 8);
|
|
113
|
+
const regions = (regionsJson.default?.geoMapData ?? []).map((d) => ({ geoName: String(d.geoName ?? ""), value: Number(d.value?.[0] ?? 0) })).sort((a, b) => b.value - a.value).slice(0, 8);
|
|
35
114
|
const queriesJson = JSON.parse(queriesRaw);
|
|
36
115
|
const rankedList = queriesJson.default?.rankedList ?? [];
|
|
37
|
-
const queries = (rankedList[0]?.rankedKeyword ?? []).slice(0,
|
|
38
|
-
|
|
39
|
-
|
|
116
|
+
const queries = (rankedList[0]?.rankedKeyword ?? []).slice(0, 12).map((d) => ({ query: String(d.query ?? ""), value: Number(d.value ?? 0) }));
|
|
117
|
+
return { timeline, regions, queries };
|
|
118
|
+
}
|
|
119
|
+
function monthLabel(yyyyMM) {
|
|
120
|
+
const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
121
|
+
const [y, m] = yyyyMM.split("-");
|
|
122
|
+
return `${names[parseInt(m ?? "1") - 1] ?? "?"} '${(y ?? "").slice(2)}`;
|
|
123
|
+
}
|
|
124
|
+
async function fetchNpm(pkg) {
|
|
125
|
+
const res = await fetch(
|
|
126
|
+
`https://api.npmjs.org/downloads/range/last-year/${encodeURIComponent(pkg)}`
|
|
127
|
+
);
|
|
128
|
+
if (!res.ok) throw new Error(`npm registry error: ${res.status} ${res.statusText}`);
|
|
129
|
+
const json = await res.json();
|
|
130
|
+
if (json.error) throw new Error(`Package not found: "${pkg}"`);
|
|
131
|
+
const daily = json.downloads ?? [];
|
|
132
|
+
const weeks = [];
|
|
133
|
+
for (let i = 0; i < daily.length; i += 7) {
|
|
134
|
+
const chunk = daily.slice(i, Math.min(i + 7, daily.length));
|
|
135
|
+
const total = chunk.reduce((s, d) => s + d.downloads, 0);
|
|
136
|
+
weeks.push({ label: chunk[0].day, total });
|
|
137
|
+
}
|
|
138
|
+
const maxWeek = Math.max(...weeks.map((w) => w.total), 1);
|
|
139
|
+
const timeline = weeks.map((w) => ({
|
|
140
|
+
formattedTime: w.label,
|
|
141
|
+
value: Math.round(w.total / maxWeek * 100)
|
|
142
|
+
}));
|
|
143
|
+
const topWeeks = [...weeks].sort((a, b) => b.total - a.total).slice(0, 8);
|
|
144
|
+
const regions = topWeeks.map((w) => ({
|
|
145
|
+
// Show as "MMM 'YY" — e.g. "Mar '25"
|
|
146
|
+
geoName: monthLabel(w.label.slice(0, 7)) + " " + w.label.slice(8, 10),
|
|
147
|
+
value: Math.round(w.total / maxWeek * 100)
|
|
148
|
+
}));
|
|
149
|
+
const monthMap = /* @__PURE__ */ new Map();
|
|
150
|
+
for (const { day, downloads } of daily) {
|
|
151
|
+
const mo = day.slice(0, 7);
|
|
152
|
+
monthMap.set(mo, (monthMap.get(mo) ?? 0) + downloads);
|
|
153
|
+
}
|
|
154
|
+
const sortedMonths = [...monthMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
155
|
+
const maxMonth = Math.max(...sortedMonths.map(([, v]) => v), 1);
|
|
156
|
+
const queries = sortedMonths.map(([mo, total]) => ({
|
|
157
|
+
query: monthLabel(mo),
|
|
158
|
+
value: Math.round(total / maxMonth * 100)
|
|
40
159
|
}));
|
|
41
160
|
return { timeline, regions, queries };
|
|
42
161
|
}
|
|
43
|
-
var
|
|
162
|
+
var rawArgs = process.argv.slice(2);
|
|
163
|
+
var mode = "google";
|
|
164
|
+
var keyword;
|
|
165
|
+
if (rawArgs[0] === "--npm") {
|
|
166
|
+
mode = "npm";
|
|
167
|
+
keyword = rawArgs[1] ?? "react";
|
|
168
|
+
} else {
|
|
169
|
+
keyword = rawArgs[0] ?? "bitcoin";
|
|
170
|
+
}
|
|
44
171
|
var app = createNodeApp({
|
|
45
|
-
initialState: { status: "loading", keyword, data: null, error: null },
|
|
46
|
-
theme:
|
|
172
|
+
initialState: { status: "loading", keyword, mode, data: null, error: null, themeIndex: 0 },
|
|
173
|
+
theme: THEMES[0].rezi
|
|
47
174
|
});
|
|
48
|
-
async function
|
|
49
|
-
app.update((s) => ({ ...s, status: "loading", data: null, error: null }));
|
|
175
|
+
async function fetchOnce(kw) {
|
|
50
176
|
try {
|
|
51
|
-
const data = await fetchTrends(
|
|
177
|
+
const data = mode === "npm" ? await fetchNpm(kw) : await fetchTrends(kw);
|
|
52
178
|
app.update((s) => ({ ...s, status: "ready", data }));
|
|
53
179
|
} catch (err) {
|
|
54
|
-
const
|
|
180
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
181
|
+
const isRateLimit = raw.includes("<HEAD>") || raw.includes("DOCTYPE") || raw.includes("not valid JSON");
|
|
182
|
+
const msg = isRateLimit ? "Google Trends is rate-limiting requests from this IP.\n\nWait 1\u20132 minutes and try again." : raw;
|
|
55
183
|
app.update((s) => ({ ...s, status: "error", error: msg }));
|
|
56
184
|
}
|
|
57
185
|
}
|
|
186
|
+
function theme(state) {
|
|
187
|
+
return THEMES[state.themeIndex] ?? THEMES[0];
|
|
188
|
+
}
|
|
189
|
+
function themedHeader(title, subtitle, t) {
|
|
190
|
+
return ui.box({ border: "rounded", px: 1, py: 0, style: { fg: t.border } }, [
|
|
191
|
+
ui.row({ gap: 1, items: "center" }, [
|
|
192
|
+
ui.text(title, { variant: "heading" }),
|
|
193
|
+
ui.text(subtitle, { dim: true }),
|
|
194
|
+
ui.spacer({ flex: 1 })
|
|
195
|
+
])
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
function xAxisLabels(timeline, count = 7) {
|
|
199
|
+
if (timeline.length === 0) return [];
|
|
200
|
+
const step = (timeline.length - 1) / (count - 1);
|
|
201
|
+
return Array.from({ length: count }, (_, i) => {
|
|
202
|
+
const raw = timeline[Math.round(i * step)]?.formattedTime ?? "";
|
|
203
|
+
if (raw.includes("-")) {
|
|
204
|
+
return monthLabel(raw.slice(0, 7));
|
|
205
|
+
}
|
|
206
|
+
const month = raw.slice(0, 3);
|
|
207
|
+
const year = raw.match(/\d{4}/)?.[0]?.slice(2) ?? "";
|
|
208
|
+
return year ? `${month} '${year}` : month;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function footer(state) {
|
|
212
|
+
const t = theme(state);
|
|
213
|
+
return ui.statusBar({
|
|
214
|
+
left: [
|
|
215
|
+
ui.text("[q]", { style: { fg: t.accent3, bold: true } }),
|
|
216
|
+
ui.text("quit", { style: { fg: t.dim } }),
|
|
217
|
+
ui.text(" [\u2190 \u2192]", { style: { fg: t.accent3, bold: true } }),
|
|
218
|
+
ui.text("cycle theme", { style: { fg: t.dim } })
|
|
219
|
+
],
|
|
220
|
+
right: [
|
|
221
|
+
ui.text(`${t.name} (${state.themeIndex + 1}/${THEMES.length})`, { style: { fg: t.accent2 } })
|
|
222
|
+
]
|
|
223
|
+
});
|
|
224
|
+
}
|
|
58
225
|
function loadingView(state) {
|
|
226
|
+
const t = theme(state);
|
|
59
227
|
return ui.page({
|
|
60
228
|
body: ui.column({ gap: 1, items: "center", justify: "center" }, [
|
|
61
|
-
ui.column(
|
|
62
|
-
{
|
|
63
|
-
|
|
64
|
-
(line, i) => ui.text(line, {
|
|
65
|
-
style: {
|
|
66
|
-
fg: i % 2 === 0 ? NEON_CYAN : NEON_PINK,
|
|
67
|
-
bold: true
|
|
68
|
-
},
|
|
69
|
-
key: String(i)
|
|
70
|
-
})
|
|
71
|
-
)
|
|
72
|
-
),
|
|
229
|
+
ui.column({ gap: 0 }, BANNER_LINES.map(
|
|
230
|
+
(line, i) => ui.text(line, { style: { fg: i % 2 === 0 ? t.banner1 : t.banner2, bold: true }, key: String(i) })
|
|
231
|
+
)),
|
|
73
232
|
ui.spacer({ size: 1 }),
|
|
74
233
|
ui.row({ gap: 1, items: "center" }, [
|
|
75
234
|
ui.spinner({ variant: "dots" }),
|
|
76
|
-
ui.text(`Fetching trends for "${state.keyword}"\u2026`, {
|
|
77
|
-
style: { fg: NEON_GREEN }
|
|
78
|
-
})
|
|
235
|
+
ui.text(`Fetching ${state.mode === "npm" ? "npm downloads" : "trends"} for "${state.keyword}"\u2026`, { style: { fg: t.accent1 } })
|
|
79
236
|
]),
|
|
80
|
-
ui.text("This may take a few seconds", { style: { fg:
|
|
81
|
-
])
|
|
237
|
+
ui.text("This may take a few seconds", { style: { fg: t.dim } })
|
|
238
|
+
]),
|
|
239
|
+
footer: footer(state)
|
|
82
240
|
});
|
|
83
241
|
}
|
|
84
242
|
function errorView(state) {
|
|
243
|
+
const t = theme(state);
|
|
85
244
|
return ui.page({
|
|
86
|
-
header:
|
|
245
|
+
header: themedHeader("TUI TRENDS", `"${state.keyword}"`, t),
|
|
87
246
|
body: ui.column({ gap: 2 }, [
|
|
88
247
|
ui.callout(state.error ?? "Unknown error", { variant: "error" }),
|
|
89
|
-
ui.text("Press [
|
|
90
|
-
])
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
function xAxisLabels(timeline, count = 7) {
|
|
94
|
-
if (timeline.length === 0) return [];
|
|
95
|
-
const step = (timeline.length - 1) / (count - 1);
|
|
96
|
-
return Array.from({ length: count }, (_, i) => {
|
|
97
|
-
const point = timeline[Math.round(i * step)];
|
|
98
|
-
const raw = point?.formattedTime ?? "";
|
|
99
|
-
const month = raw.slice(0, 3);
|
|
100
|
-
const year = raw.match(/\d{4}/)?.[0]?.slice(2) ?? "";
|
|
101
|
-
return year ? `${month} '${year}` : month;
|
|
248
|
+
ui.text("Press [q] to quit", { style: { fg: t.dim } })
|
|
249
|
+
]),
|
|
250
|
+
footer: footer(state)
|
|
102
251
|
});
|
|
103
252
|
}
|
|
104
253
|
function dashboardView(state) {
|
|
254
|
+
const t = theme(state);
|
|
105
255
|
const data = state.data;
|
|
106
|
-
const
|
|
107
|
-
const
|
|
256
|
+
const isNpm = state.mode === "npm";
|
|
257
|
+
const vals = data.timeline.map((p) => p.value);
|
|
258
|
+
const xlabels = xAxisLabels(data.timeline);
|
|
108
259
|
return ui.page({
|
|
109
|
-
header:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
260
|
+
header: themedHeader(
|
|
261
|
+
"TUI TRENDS",
|
|
262
|
+
isNpm ? `"${state.keyword}" \u2014 npm downloads (weekly index, 0\u2013100)` : `"${state.keyword}" \u2014 interest over the last 12 months`,
|
|
263
|
+
t
|
|
264
|
+
),
|
|
113
265
|
body: ui.column({ gap: 1 }, [
|
|
114
|
-
// ──
|
|
115
|
-
ui.panel(
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
ui.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
ui.text(`Avg: ${Math.round(timelineValues.reduce((a, b) => a + b, 0) / timelineValues.length)}`, { style: { fg: NEON_CYAN } }),
|
|
136
|
-
ui.text(`Current: ${timelineValues.at(-1) ?? 0}`, { style: { fg: NEON_PINK } })
|
|
137
|
-
])
|
|
138
|
-
]
|
|
139
|
-
),
|
|
140
|
-
// ── Bottom row: regions + queries ────────────────────────────
|
|
266
|
+
// ── Line chart ───────────────────────────────────────────────
|
|
267
|
+
ui.panel({ title: isNpm ? "\u25B8 Weekly Downloads" : "\u25B8 Interest Over Time", variant: "rounded", p: 1, gap: 1, style: { fg: t.border } }, [
|
|
268
|
+
ui.lineChart({
|
|
269
|
+
width: 90,
|
|
270
|
+
height: 12,
|
|
271
|
+
series: [{ label: state.keyword, color: t.chart, data: vals }],
|
|
272
|
+
axes: { y: { min: 0, max: 100 } },
|
|
273
|
+
showLegend: false,
|
|
274
|
+
blitter: "braille"
|
|
275
|
+
}),
|
|
276
|
+
ui.row({ gap: 0 }, xlabels.flatMap((label, i) => [
|
|
277
|
+
ui.text(label, { style: { fg: t.dim }, key: `xl${i}` }),
|
|
278
|
+
...i < xlabels.length - 1 ? [ui.spacer({ flex: 1, key: `xs${i}` })] : []
|
|
279
|
+
])),
|
|
280
|
+
ui.row({ gap: 2 }, [
|
|
281
|
+
ui.text(`Peak: ${Math.max(...vals)}`, { style: { fg: t.accent1, bold: true } }),
|
|
282
|
+
ui.text(`Avg: ${Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)}`, { style: { fg: t.accent2 } }),
|
|
283
|
+
ui.text(`Current: ${vals.at(-1) ?? 0}`, { style: { fg: t.accent3 } })
|
|
284
|
+
])
|
|
285
|
+
]),
|
|
286
|
+
// ── Bottom row ───────────────────────────────────────────────
|
|
141
287
|
ui.row({ gap: 1 }, [
|
|
142
|
-
ui.box({ flex: 1 }, [
|
|
143
|
-
ui.panel(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
154
|
-
]
|
|
155
|
-
)
|
|
288
|
+
ui.box({ flex: 1, border: "none" }, [
|
|
289
|
+
ui.panel({ title: isNpm ? "\u25B8 Peak Weeks" : "\u25B8 Top Regions", variant: "rounded", p: 1, style: { fg: t.border } }, [
|
|
290
|
+
ui.barChart(
|
|
291
|
+
data.regions.map((r) => ({
|
|
292
|
+
label: r.geoName.length > 16 ? r.geoName.slice(0, 15) + "\u2026" : r.geoName,
|
|
293
|
+
value: r.value,
|
|
294
|
+
variant: t.barVariant
|
|
295
|
+
})),
|
|
296
|
+
{ orientation: "horizontal", showValues: true }
|
|
297
|
+
)
|
|
298
|
+
])
|
|
156
299
|
]),
|
|
157
|
-
ui.box({ flex: 1 }, [
|
|
158
|
-
ui.panel(
|
|
159
|
-
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
})
|
|
172
|
-
]
|
|
173
|
-
)
|
|
300
|
+
ui.box({ flex: 1, border: "none" }, [
|
|
301
|
+
ui.panel({ title: isNpm ? "\u25B8 Monthly Breakdown" : "\u25B8 Related Queries", variant: "rounded", p: 1, style: { fg: t.border } }, [
|
|
302
|
+
ui.table({
|
|
303
|
+
id: "queries-table",
|
|
304
|
+
columns: [
|
|
305
|
+
{ key: "query", header: isNpm ? "Month" : "Query", flex: 1, overflow: "ellipsis" },
|
|
306
|
+
{ key: "value", header: isNpm ? "Index" : "Score", width: 7, align: "right" }
|
|
307
|
+
],
|
|
308
|
+
data: data.queries,
|
|
309
|
+
getRowKey: (r) => r.query,
|
|
310
|
+
showHeader: true,
|
|
311
|
+
borderStyle: { variant: "single", color: t.dim }
|
|
312
|
+
})
|
|
313
|
+
])
|
|
174
314
|
])
|
|
175
315
|
])
|
|
176
316
|
]),
|
|
177
|
-
footer:
|
|
178
|
-
left: [
|
|
179
|
-
ui.text("[q]", { style: { fg: NEON_PINK, bold: true } }),
|
|
180
|
-
ui.text("quit", { style: { fg: DIM_TEXT } }),
|
|
181
|
-
ui.text(" [r]", { style: { fg: NEON_PINK, bold: true } }),
|
|
182
|
-
ui.text("refresh", { style: { fg: DIM_TEXT } }),
|
|
183
|
-
ui.text(" [\u2191\u2193] scroll queries", { style: { fg: DIM_TEXT } })
|
|
184
|
-
],
|
|
185
|
-
right: [
|
|
186
|
-
ui.text("powered by Google Trends", { style: { fg: DIM_TEXT } })
|
|
187
|
-
]
|
|
188
|
-
})
|
|
317
|
+
footer: footer(state)
|
|
189
318
|
});
|
|
190
319
|
}
|
|
191
320
|
app.view((state) => {
|
|
@@ -193,13 +322,23 @@ app.view((state) => {
|
|
|
193
322
|
if (state.status === "error") return errorView(state);
|
|
194
323
|
return dashboardView(state);
|
|
195
324
|
});
|
|
325
|
+
function cycleTheme(currentIndex, dir) {
|
|
326
|
+
const next = (currentIndex + dir + THEMES.length) % THEMES.length;
|
|
327
|
+
app.update((s) => ({ ...s, themeIndex: next }));
|
|
328
|
+
}
|
|
196
329
|
app.keys({
|
|
197
|
-
q: () => {
|
|
330
|
+
q: (ctx) => {
|
|
198
331
|
void app.stop();
|
|
199
332
|
},
|
|
200
|
-
|
|
201
|
-
|
|
333
|
+
left: (ctx) => {
|
|
334
|
+
cycleTheme(ctx.state.themeIndex, -1);
|
|
335
|
+
},
|
|
336
|
+
right: (ctx) => {
|
|
337
|
+
cycleTheme(ctx.state.themeIndex, 1);
|
|
338
|
+
},
|
|
339
|
+
t: (ctx) => {
|
|
340
|
+
cycleTheme(ctx.state.themeIndex, 1);
|
|
202
341
|
}
|
|
203
342
|
});
|
|
204
|
-
void
|
|
343
|
+
void fetchOnce(keyword);
|
|
205
344
|
await app.start();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tui-trends",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Cyberpunk Google Trends visualizer in your terminal",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
"@rezi-ui/core": "0.1.0-alpha.45",
|
|
23
23
|
"@rezi-ui/native": "0.1.0-alpha.45",
|
|
24
24
|
"@rezi-ui/node": "0.1.0-alpha.45",
|
|
25
|
+
"g": "^2.0.1",
|
|
25
26
|
"google-trends-api": "^4.9.2"
|
|
26
27
|
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
27
31
|
"devDependencies": {
|
|
28
32
|
"@types/node": "^22.0.0",
|
|
29
33
|
"esbuild": "^0.27.3",
|