fieldtheory 1.0.0
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/LICENSE +21 -0
- package/README.md +133 -0
- package/bin/ft.mjs +3 -0
- package/dist/bookmark-classify-llm.js +247 -0
- package/dist/bookmark-classify.js +223 -0
- package/dist/bookmark-media.js +186 -0
- package/dist/bookmarks-db.js +623 -0
- package/dist/bookmarks-service.js +49 -0
- package/dist/bookmarks-viz.js +531 -0
- package/dist/bookmarks.js +190 -0
- package/dist/chrome-cookies.js +146 -0
- package/dist/cli.js +381 -0
- package/dist/config.js +54 -0
- package/dist/db.js +33 -0
- package/dist/fs.js +45 -0
- package/dist/graphql-bookmarks.js +388 -0
- package/dist/paths.js +43 -0
- package/dist/types.js +1 -0
- package/dist/xauth.js +135 -0
- package/package.json +54 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { openDb } from './db.js';
|
|
2
|
+
import { twitterBookmarksIndexPath } from './paths.js';
|
|
3
|
+
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
4
|
+
const ESC = '\x1b[';
|
|
5
|
+
const RESET = `${ESC}0m`;
|
|
6
|
+
const BOLD = `${ESC}1m`;
|
|
7
|
+
const DIM = `${ESC}2m`;
|
|
8
|
+
const rgb = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`;
|
|
9
|
+
// Palette — muted, tasteful
|
|
10
|
+
const C = {
|
|
11
|
+
title: rgb(199, 146, 234), // soft lavender
|
|
12
|
+
accent: rgb(130, 170, 255), // periwinkle
|
|
13
|
+
warm: rgb(255, 180, 120), // peach
|
|
14
|
+
green: rgb(120, 220, 170), // mint
|
|
15
|
+
dim: rgb(100, 100, 120), // muted gray
|
|
16
|
+
text: rgb(200, 200, 210), // light gray
|
|
17
|
+
hot: rgb(255, 120, 140), // coral
|
|
18
|
+
gold: rgb(240, 200, 100), // amber
|
|
19
|
+
cyan: rgb(100, 220, 230), // teal
|
|
20
|
+
violet: rgb(170, 130, 255), // violet
|
|
21
|
+
};
|
|
22
|
+
// ── Block characters for bar charts ──────────────────────────────────────────
|
|
23
|
+
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
24
|
+
function bar(value, max, width, color) {
|
|
25
|
+
const ratio = max > 0 ? value / max : 0;
|
|
26
|
+
const filled = ratio * width;
|
|
27
|
+
const full = Math.floor(filled);
|
|
28
|
+
const partial = Math.round((filled - full) * 8);
|
|
29
|
+
return (color +
|
|
30
|
+
'█'.repeat(full) +
|
|
31
|
+
(partial > 0 ? BLOCKS[partial] : '') +
|
|
32
|
+
RESET +
|
|
33
|
+
' '.repeat(Math.max(0, width - full - (partial > 0 ? 1 : 0))));
|
|
34
|
+
}
|
|
35
|
+
// ── Sparkline ────────────────────────────────────────────────────────────────
|
|
36
|
+
const SPARKS = '▁▂▃▄▅▆▇█';
|
|
37
|
+
function sparkline(data, color) {
|
|
38
|
+
const max = Math.max(...data, 1);
|
|
39
|
+
return (color +
|
|
40
|
+
data.map((v) => SPARKS[Math.round((v / max) * 7)] || SPARKS[0]).join('') +
|
|
41
|
+
RESET);
|
|
42
|
+
}
|
|
43
|
+
// ── Braille dot chart (2-wide × 4-tall per character) ────────────────────────
|
|
44
|
+
const BRAILLE_BASE = 0x2800;
|
|
45
|
+
const BRAILLE_DOTS = [
|
|
46
|
+
[0x01, 0x08],
|
|
47
|
+
[0x02, 0x10],
|
|
48
|
+
[0x04, 0x20],
|
|
49
|
+
[0x40, 0x80],
|
|
50
|
+
];
|
|
51
|
+
function brailleChart(data, width, color) {
|
|
52
|
+
const max = Math.max(...data, 1);
|
|
53
|
+
const heights = data.map((v) => Math.round((v / max) * 7));
|
|
54
|
+
const chars = new Array(Math.ceil(data.length / 2)).fill(BRAILLE_BASE);
|
|
55
|
+
for (let i = 0; i < heights.length; i++) {
|
|
56
|
+
const col = i % 2; // 0 = left, 1 = right
|
|
57
|
+
const charIdx = Math.floor(i / 2);
|
|
58
|
+
const h = heights[i];
|
|
59
|
+
for (let row = 0; row < 4; row++) {
|
|
60
|
+
if (7 - row * 2 <= h) {
|
|
61
|
+
chars[charIdx] |= BRAILLE_DOTS[row][col];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return color + chars.map((c) => String.fromCharCode(c)).join('') + RESET;
|
|
66
|
+
}
|
|
67
|
+
// ── Box drawing ──────────────────────────────────────────────────────────────
|
|
68
|
+
function boxTop(width) {
|
|
69
|
+
return C.dim + '╭' + '─'.repeat(width - 2) + '╮' + RESET;
|
|
70
|
+
}
|
|
71
|
+
function boxBottom(width) {
|
|
72
|
+
return C.dim + '╰' + '─'.repeat(width - 2) + '╯' + RESET;
|
|
73
|
+
}
|
|
74
|
+
function boxRow(content, width) {
|
|
75
|
+
const stripped = content.replace(/\x1b\[[^m]*m/g, '');
|
|
76
|
+
const pad = Math.max(0, width - 4 - stripped.length);
|
|
77
|
+
return C.dim + '│ ' + RESET + content + ' '.repeat(pad) + C.dim + ' │' + RESET;
|
|
78
|
+
}
|
|
79
|
+
function boxDivider(width) {
|
|
80
|
+
return C.dim + '├' + '─'.repeat(width - 2) + '┤' + RESET;
|
|
81
|
+
}
|
|
82
|
+
// ── Gradient helpers ─────────────────────────────────────────────────────────
|
|
83
|
+
function lerpColor(from, to, t) {
|
|
84
|
+
const r = Math.round(from[0] + (to[0] - from[0]) * t);
|
|
85
|
+
const g = Math.round(from[1] + (to[1] - from[1]) * t);
|
|
86
|
+
const b = Math.round(from[2] + (to[2] - from[2]) * t);
|
|
87
|
+
return rgb(r, g, b);
|
|
88
|
+
}
|
|
89
|
+
async function queryVizData() {
|
|
90
|
+
const db = await openDb(twitterBookmarksIndexPath());
|
|
91
|
+
try {
|
|
92
|
+
const total = db.exec('SELECT COUNT(*) FROM bookmarks')[0]?.values[0]?.[0];
|
|
93
|
+
const authors = db.exec('SELECT COUNT(DISTINCT author_handle) FROM bookmarks')[0]?.values[0]?.[0];
|
|
94
|
+
const range = db.exec('SELECT MIN(posted_at), MAX(posted_at) FROM bookmarks WHERE posted_at IS NOT NULL')[0]?.values[0];
|
|
95
|
+
const topAuthorsRows = db.exec(`SELECT author_handle, COUNT(*) as c FROM bookmarks
|
|
96
|
+
WHERE author_handle IS NOT NULL
|
|
97
|
+
GROUP BY author_handle ORDER BY c DESC LIMIT 20`);
|
|
98
|
+
// Twitter date format: "Sat Mar 28 18:55:23 +0000 2026"
|
|
99
|
+
// Year is at end (-4), month name at 5-7, hour at 12-13
|
|
100
|
+
// Build a synthetic YYYY-MonName from the twitter date parts
|
|
101
|
+
const monthlyRows = db.exec(`SELECT
|
|
102
|
+
substr(bookmarked_at, -4) || '-' || substr(bookmarked_at, 5, 3) as ym,
|
|
103
|
+
COUNT(*) as c
|
|
104
|
+
FROM bookmarks WHERE bookmarked_at IS NOT NULL
|
|
105
|
+
GROUP BY ym ORDER BY ym`);
|
|
106
|
+
// Day of week — first 3 chars
|
|
107
|
+
const dowRows = db.exec(`SELECT substr(bookmarked_at, 1, 3) as dow, COUNT(*) as c
|
|
108
|
+
FROM bookmarks WHERE bookmarked_at IS NOT NULL
|
|
109
|
+
GROUP BY dow ORDER BY c DESC`);
|
|
110
|
+
// Hour of day — chars 12-13
|
|
111
|
+
const hourRows = db.exec(`SELECT CAST(substr(bookmarked_at, 12, 2) AS INTEGER) as h, COUNT(*) as c
|
|
112
|
+
FROM bookmarks WHERE bookmarked_at IS NOT NULL AND length(bookmarked_at) > 13
|
|
113
|
+
GROUP BY h ORDER BY h`);
|
|
114
|
+
// Domains from links_json
|
|
115
|
+
const domainRows = db.exec(`SELECT links_json FROM bookmarks WHERE links_json IS NOT NULL AND links_json != '[]'`);
|
|
116
|
+
const domainCounts = new Map();
|
|
117
|
+
for (const row of domainRows[0]?.values ?? []) {
|
|
118
|
+
try {
|
|
119
|
+
const links = JSON.parse(row[0]);
|
|
120
|
+
for (const link of links) {
|
|
121
|
+
const url = typeof link === 'string' ? link : link.expanded_url ?? link.url ?? '';
|
|
122
|
+
try {
|
|
123
|
+
const domain = new URL(url).hostname.replace(/^www\./, '');
|
|
124
|
+
if (domain && domain !== 'x.com' && domain !== 't.co') {
|
|
125
|
+
domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
}
|
|
133
|
+
const topDomains = [...domainCounts.entries()]
|
|
134
|
+
.sort((a, b) => b[1] - a[1])
|
|
135
|
+
.slice(0, 12)
|
|
136
|
+
.map(([domain, count]) => ({ domain, count }));
|
|
137
|
+
const mediaStats = {
|
|
138
|
+
withMedia: db.exec('SELECT COUNT(*) FROM bookmarks WHERE media_count > 0')[0]?.values[0]?.[0],
|
|
139
|
+
withLinks: db.exec('SELECT COUNT(*) FROM bookmarks WHERE link_count > 0')[0]?.values[0]?.[0],
|
|
140
|
+
total,
|
|
141
|
+
};
|
|
142
|
+
const langRows = db.exec(`SELECT language, COUNT(*) as c FROM bookmarks WHERE language IS NOT NULL
|
|
143
|
+
GROUP BY language ORDER BY c DESC LIMIT 8`);
|
|
144
|
+
const avgLen = db.exec('SELECT AVG(length(text)) FROM bookmarks')[0]?.values[0]?.[0];
|
|
145
|
+
// Recent 30 days top authors
|
|
146
|
+
const recentAuthorsRows = db.exec(`SELECT author_handle, COUNT(*) as c FROM bookmarks
|
|
147
|
+
WHERE author_handle IS NOT NULL
|
|
148
|
+
AND bookmarked_at >= (SELECT MAX(bookmarked_at) FROM bookmarks)
|
|
149
|
+
GROUP BY author_handle ORDER BY c DESC LIMIT 10`);
|
|
150
|
+
// Time capsules: oldest posts, one per year to spread the range
|
|
151
|
+
const capsuleRows = db.exec(`SELECT author_handle, text, tweet_id, posted_at, substr(posted_at, -4) as yr
|
|
152
|
+
FROM bookmarks
|
|
153
|
+
WHERE posted_at IS NOT NULL
|
|
154
|
+
AND CAST(substr(posted_at, -4) AS INTEGER) < 2023
|
|
155
|
+
GROUP BY substr(posted_at, -4)
|
|
156
|
+
ORDER BY posted_at ASC
|
|
157
|
+
LIMIT 8`);
|
|
158
|
+
const timeCapsules = (capsuleRows[0]?.values ?? []).map((r) => ({
|
|
159
|
+
author: r[0],
|
|
160
|
+
text: r[1],
|
|
161
|
+
tweetId: r[2],
|
|
162
|
+
postedAt: r[3],
|
|
163
|
+
}));
|
|
164
|
+
// Hidden gems: authors bookmarked exactly once, with long text (> 250 chars)
|
|
165
|
+
const gemRows = db.exec(`SELECT b.author_handle, b.text, b.tweet_id, b.posted_at
|
|
166
|
+
FROM bookmarks b
|
|
167
|
+
JOIN (
|
|
168
|
+
SELECT author_handle FROM bookmarks
|
|
169
|
+
WHERE author_handle IS NOT NULL
|
|
170
|
+
GROUP BY author_handle HAVING COUNT(*) = 1
|
|
171
|
+
) singles ON b.author_handle = singles.author_handle
|
|
172
|
+
WHERE length(b.text) > 250
|
|
173
|
+
ORDER BY length(b.text) DESC
|
|
174
|
+
LIMIT 8`);
|
|
175
|
+
const hiddenGems = (gemRows[0]?.values ?? []).map((r) => ({
|
|
176
|
+
author: r[0],
|
|
177
|
+
text: r[1],
|
|
178
|
+
tweetId: r[2],
|
|
179
|
+
postedAt: r[3],
|
|
180
|
+
}));
|
|
181
|
+
// Rising voices: authors with 3+ bookmarks, all from the most recent month
|
|
182
|
+
const latestMonth = db.exec(`SELECT substr(bookmarked_at, -4) || '-' || substr(bookmarked_at, 5, 3)
|
|
183
|
+
FROM bookmarks WHERE bookmarked_at IS NOT NULL
|
|
184
|
+
ORDER BY bookmarked_at DESC LIMIT 1`)[0]?.values[0]?.[0];
|
|
185
|
+
let risingVoices = [];
|
|
186
|
+
if (latestMonth) {
|
|
187
|
+
const risingRows = db.exec(`SELECT author_handle, COUNT(*) as c FROM bookmarks
|
|
188
|
+
WHERE author_handle IS NOT NULL
|
|
189
|
+
GROUP BY author_handle
|
|
190
|
+
HAVING c >= 3
|
|
191
|
+
AND MIN(substr(bookmarked_at, -4) || '-' || substr(bookmarked_at, 5, 3)) = ?
|
|
192
|
+
ORDER BY c DESC LIMIT 8`, [latestMonth]);
|
|
193
|
+
risingVoices = (risingRows[0]?.values ?? []).map((r) => ({
|
|
194
|
+
handle: r[0],
|
|
195
|
+
count: r[1],
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
// Convert "2026-Mar" to "2026-03" for proper sorting
|
|
199
|
+
const monthNumMap = {
|
|
200
|
+
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
|
|
201
|
+
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
|
|
202
|
+
};
|
|
203
|
+
const rawMonthly = (monthlyRows[0]?.values ?? []).map((r) => {
|
|
204
|
+
const raw = r[0]; // "2026-Mar"
|
|
205
|
+
const [year, monName] = raw.split('-');
|
|
206
|
+
const num = monthNumMap[monName] ?? '00';
|
|
207
|
+
return { month: `${year}-${num}`, label: `${monName} ${year}`, count: r[1] };
|
|
208
|
+
});
|
|
209
|
+
rawMonthly.sort((a, b) => a.month.localeCompare(b.month));
|
|
210
|
+
return {
|
|
211
|
+
total,
|
|
212
|
+
uniqueAuthors: authors,
|
|
213
|
+
dateRange: {
|
|
214
|
+
earliest: range?.[0] ?? '?',
|
|
215
|
+
latest: range?.[1] ?? '?',
|
|
216
|
+
},
|
|
217
|
+
topAuthors: (topAuthorsRows[0]?.values ?? []).map((r) => ({
|
|
218
|
+
handle: r[0],
|
|
219
|
+
count: r[1],
|
|
220
|
+
})),
|
|
221
|
+
monthlyActivity: rawMonthly.map((r) => ({
|
|
222
|
+
month: r.label,
|
|
223
|
+
count: r.count,
|
|
224
|
+
})),
|
|
225
|
+
dayOfWeekActivity: (dowRows[0]?.values ?? []).map((r) => ({
|
|
226
|
+
day: r[0],
|
|
227
|
+
count: r[1],
|
|
228
|
+
})),
|
|
229
|
+
hourActivity: (hourRows[0]?.values ?? []).map((r) => ({
|
|
230
|
+
hour: r[0],
|
|
231
|
+
count: r[1],
|
|
232
|
+
})),
|
|
233
|
+
topDomains,
|
|
234
|
+
mediaStats,
|
|
235
|
+
recentAuthors: (recentAuthorsRows[0]?.values ?? []).map((r) => ({
|
|
236
|
+
handle: r[0],
|
|
237
|
+
count: r[1],
|
|
238
|
+
})),
|
|
239
|
+
languages: (langRows[0]?.values ?? []).map((r) => ({
|
|
240
|
+
lang: r[0],
|
|
241
|
+
count: r[1],
|
|
242
|
+
})),
|
|
243
|
+
avgTextLength: avgLen,
|
|
244
|
+
timeCapsules,
|
|
245
|
+
hiddenGems,
|
|
246
|
+
risingVoices,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
db.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ── Render sections ──────────────────────────────────────────────────────────
|
|
254
|
+
const W = 72; // box width
|
|
255
|
+
function renderHeader(data) {
|
|
256
|
+
const lines = [];
|
|
257
|
+
lines.push('');
|
|
258
|
+
lines.push(boxTop(W));
|
|
259
|
+
lines.push(boxRow(`${C.title}${BOLD} ✦ FIELD THEORY · BOOKMARK OBSERVATORY ✦ ${RESET}`, W));
|
|
260
|
+
lines.push(boxDivider(W));
|
|
261
|
+
lines.push(boxRow(`${C.text}${data.total.toLocaleString()} bookmarks${C.dim} · ${C.text}${data.uniqueAuthors.toLocaleString()} voices${C.dim} · ${C.text}${data.languages.length} languages`, W));
|
|
262
|
+
lines.push(boxRow(`${C.dim}${data.dateRange.earliest.slice(0, 16)} → ${data.dateRange.latest.slice(0, 16)}`, W));
|
|
263
|
+
lines.push(boxBottom(W));
|
|
264
|
+
return lines;
|
|
265
|
+
}
|
|
266
|
+
function renderTopAuthors(data) {
|
|
267
|
+
const lines = [];
|
|
268
|
+
const maxCount = data.topAuthors[0]?.count ?? 1;
|
|
269
|
+
const barWidth = 28;
|
|
270
|
+
lines.push('');
|
|
271
|
+
lines.push(` ${C.accent}${BOLD}WHO YOU LISTEN TO${RESET}`);
|
|
272
|
+
lines.push(` ${C.dim}top 20 most-bookmarked voices${RESET}`);
|
|
273
|
+
lines.push('');
|
|
274
|
+
for (const author of data.topAuthors) {
|
|
275
|
+
const t = author.count / maxCount;
|
|
276
|
+
const color = lerpColor([100, 160, 255], [255, 120, 180], t);
|
|
277
|
+
const handle = `@${author.handle}`.padEnd(20);
|
|
278
|
+
const count = String(author.count).padStart(4);
|
|
279
|
+
lines.push(` ${C.text}${handle}${RESET} ${bar(author.count, maxCount, barWidth, color)} ${C.dim}${count}${RESET}`);
|
|
280
|
+
}
|
|
281
|
+
return lines;
|
|
282
|
+
}
|
|
283
|
+
function renderActivity(data) {
|
|
284
|
+
const lines = [];
|
|
285
|
+
const counts = data.monthlyActivity.map((m) => m.count);
|
|
286
|
+
const maxCount = Math.max(...counts, 1);
|
|
287
|
+
lines.push('');
|
|
288
|
+
lines.push(` ${C.warm}${BOLD}RHYTHM${RESET}`);
|
|
289
|
+
lines.push(` ${C.dim}monthly bookmarking cadence${RESET}`);
|
|
290
|
+
lines.push('');
|
|
291
|
+
// Sparkline overview
|
|
292
|
+
lines.push(` ${sparkline(counts, C.warm)}`);
|
|
293
|
+
lines.push('');
|
|
294
|
+
// Monthly bars
|
|
295
|
+
for (const m of data.monthlyActivity) {
|
|
296
|
+
const label = m.month.padEnd(8);
|
|
297
|
+
const t = m.count / maxCount;
|
|
298
|
+
const color = lerpColor([255, 160, 100], [255, 100, 120], t);
|
|
299
|
+
const count = String(m.count).padStart(5);
|
|
300
|
+
lines.push(` ${C.dim}${label}${RESET} ${bar(m.count, maxCount, 36, color)} ${C.dim}${count}${RESET}`);
|
|
301
|
+
}
|
|
302
|
+
return lines;
|
|
303
|
+
}
|
|
304
|
+
function renderDayOfWeek(data) {
|
|
305
|
+
const lines = [];
|
|
306
|
+
const dayOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
307
|
+
const ordered = dayOrder
|
|
308
|
+
.map((d) => data.dayOfWeekActivity.find((r) => r.day === d))
|
|
309
|
+
.filter(Boolean);
|
|
310
|
+
if (ordered.length === 0)
|
|
311
|
+
return [];
|
|
312
|
+
const maxCount = Math.max(...ordered.map((d) => d.count), 1);
|
|
313
|
+
const counts = ordered.map((d) => d.count);
|
|
314
|
+
lines.push('');
|
|
315
|
+
lines.push(` ${C.green}${BOLD}WEEKLY PULSE${RESET}`);
|
|
316
|
+
lines.push(` ${C.dim}which days you bookmark${RESET}`);
|
|
317
|
+
lines.push('');
|
|
318
|
+
lines.push(` ${brailleChart(counts, 14, C.green)}`);
|
|
319
|
+
lines.push('');
|
|
320
|
+
for (const d of ordered) {
|
|
321
|
+
const label = d.day.padEnd(5);
|
|
322
|
+
const t = d.count / maxCount;
|
|
323
|
+
const color = lerpColor([80, 200, 160], [120, 255, 200], t);
|
|
324
|
+
const count = String(d.count).padStart(5);
|
|
325
|
+
lines.push(` ${C.text}${label}${RESET} ${bar(d.count, maxCount, 36, color)} ${C.dim}${count}${RESET}`);
|
|
326
|
+
}
|
|
327
|
+
return lines;
|
|
328
|
+
}
|
|
329
|
+
function renderHourOfDay(data) {
|
|
330
|
+
const lines = [];
|
|
331
|
+
if (data.hourActivity.length === 0)
|
|
332
|
+
return [];
|
|
333
|
+
// Fill in all 24 hours
|
|
334
|
+
const hourMap = new Map(data.hourActivity.map((h) => [h.hour, h.count]));
|
|
335
|
+
const allHours = Array.from({ length: 24 }, (_, i) => hourMap.get(i) ?? 0);
|
|
336
|
+
const maxCount = Math.max(...allHours, 1);
|
|
337
|
+
lines.push('');
|
|
338
|
+
lines.push(` ${C.cyan}${BOLD}DAILY ARC${RESET}`);
|
|
339
|
+
lines.push(` ${C.dim}when you reach for the bookmark button${RESET}`);
|
|
340
|
+
lines.push('');
|
|
341
|
+
// Vertical bar chart using blocks
|
|
342
|
+
const chartHeight = 8;
|
|
343
|
+
for (let row = chartHeight; row >= 1; row--) {
|
|
344
|
+
let line = ' ';
|
|
345
|
+
for (let h = 0; h < 24; h++) {
|
|
346
|
+
const t = allHours[h] / maxCount;
|
|
347
|
+
const barH = t * chartHeight;
|
|
348
|
+
if (barH >= row) {
|
|
349
|
+
const color = lerpColor([60, 180, 200], [100, 240, 255], t);
|
|
350
|
+
line += color + '██' + RESET;
|
|
351
|
+
}
|
|
352
|
+
else if (barH >= row - 1 && barH > 0) {
|
|
353
|
+
const frac = barH - Math.floor(barH);
|
|
354
|
+
const partials = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
|
|
355
|
+
const idx = Math.round(frac * 7);
|
|
356
|
+
const color = lerpColor([60, 180, 200], [100, 240, 255], t);
|
|
357
|
+
line += color + partials[idx] + partials[idx] + RESET;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
line += ' ';
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
lines.push(line);
|
|
364
|
+
}
|
|
365
|
+
// Hour labels
|
|
366
|
+
let labelLine = ' ';
|
|
367
|
+
for (let h = 0; h < 24; h++) {
|
|
368
|
+
if (h % 3 === 0) {
|
|
369
|
+
labelLine += C.dim + String(h).padStart(2, '0') + RESET;
|
|
370
|
+
if (h + 1 < 24)
|
|
371
|
+
labelLine += C.dim + RESET;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
labelLine += ' ';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
lines.push(labelLine);
|
|
378
|
+
// Peak hours annotation
|
|
379
|
+
const peak = data.hourActivity.reduce((a, b) => (a.count > b.count ? a : b));
|
|
380
|
+
const quiet = data.hourActivity.reduce((a, b) => (a.count < b.count ? a : b));
|
|
381
|
+
lines.push('');
|
|
382
|
+
lines.push(` ${C.cyan}peak${RESET} ${C.text}${peak.hour}:00${RESET}${C.dim} · ${C.cyan}quiet${RESET} ${C.text}${quiet.hour}:00${RESET}`);
|
|
383
|
+
return lines;
|
|
384
|
+
}
|
|
385
|
+
function renderDomains(data) {
|
|
386
|
+
const lines = [];
|
|
387
|
+
if (data.topDomains.length === 0)
|
|
388
|
+
return [];
|
|
389
|
+
const maxCount = data.topDomains[0]?.count ?? 1;
|
|
390
|
+
lines.push('');
|
|
391
|
+
lines.push(` ${C.violet}${BOLD}WHERE LINKS LEAD${RESET}`);
|
|
392
|
+
lines.push(` ${C.dim}most-bookmarked external domains${RESET}`);
|
|
393
|
+
lines.push('');
|
|
394
|
+
for (const d of data.topDomains) {
|
|
395
|
+
const label = d.domain.padEnd(24);
|
|
396
|
+
const t = d.count / maxCount;
|
|
397
|
+
const color = lerpColor([140, 100, 230], [200, 150, 255], t);
|
|
398
|
+
const count = String(d.count).padStart(4);
|
|
399
|
+
lines.push(` ${C.text}${label}${RESET} ${bar(d.count, maxCount, 22, color)} ${C.dim}${count}${RESET}`);
|
|
400
|
+
}
|
|
401
|
+
return lines;
|
|
402
|
+
}
|
|
403
|
+
function renderMediaBreakdown(data) {
|
|
404
|
+
const lines = [];
|
|
405
|
+
const { withMedia, withLinks, total } = data.mediaStats;
|
|
406
|
+
lines.push('');
|
|
407
|
+
lines.push(` ${C.gold}${BOLD}COMPOSITION${RESET}`);
|
|
408
|
+
lines.push(` ${C.dim}what your bookmarks contain${RESET}`);
|
|
409
|
+
lines.push('');
|
|
410
|
+
const barWidth = 50;
|
|
411
|
+
const mediaPct = (withMedia / total) * 100;
|
|
412
|
+
const linkPct = (withLinks / total) * 100;
|
|
413
|
+
const textPct = Math.max(0, 100 - mediaPct - linkPct);
|
|
414
|
+
const mediaW = Math.round((withMedia / total) * barWidth);
|
|
415
|
+
const linkW = Math.round((withLinks / total) * barWidth);
|
|
416
|
+
const textW = barWidth - mediaW - linkW;
|
|
417
|
+
// Stacked bar
|
|
418
|
+
const stackedBar = rgb(120, 220, 170) + '█'.repeat(mediaW) +
|
|
419
|
+
rgb(130, 170, 255) + '█'.repeat(linkW) +
|
|
420
|
+
rgb(100, 100, 120) + '█'.repeat(Math.max(0, textW)) +
|
|
421
|
+
RESET;
|
|
422
|
+
lines.push(` ${stackedBar}`);
|
|
423
|
+
lines.push('');
|
|
424
|
+
lines.push(` ${rgb(120, 220, 170)}██${RESET} ${C.text}media ${withMedia.toLocaleString()} (${mediaPct.toFixed(0)}%)${RESET} ${rgb(130, 170, 255)}██${RESET} ${C.text}links ${withLinks.toLocaleString()} (${linkPct.toFixed(0)}%)${RESET} ${rgb(100, 100, 120)}██${RESET} ${C.text}text ${textPct.toFixed(0)}%${RESET}`);
|
|
425
|
+
return lines;
|
|
426
|
+
}
|
|
427
|
+
function renderFingerprint(data) {
|
|
428
|
+
const lines = [];
|
|
429
|
+
lines.push('');
|
|
430
|
+
lines.push(boxTop(W));
|
|
431
|
+
lines.push(boxRow(`${C.title}${BOLD}FINGERPRINT${RESET}`, W));
|
|
432
|
+
lines.push(boxDivider(W));
|
|
433
|
+
const mediaPct = ((data.mediaStats.withMedia / data.total) * 100).toFixed(0);
|
|
434
|
+
const longTailPct = data.uniqueAuthors > 0
|
|
435
|
+
? (((data.uniqueAuthors - data.topAuthors.length) / data.uniqueAuthors) * 100).toFixed(0)
|
|
436
|
+
: '0';
|
|
437
|
+
lines.push(boxRow(`${C.dim}avg bookmark length${RESET} ${C.text}${Math.round(data.avgTextLength)} chars${RESET}`, W));
|
|
438
|
+
lines.push(boxRow(`${C.dim}media-bearing${RESET} ${C.text}${mediaPct}%${RESET}`, W));
|
|
439
|
+
lines.push(boxRow(`${C.dim}long-tail authors${RESET} ${C.text}${longTailPct}% bookmarked ≤ once${RESET}`, W));
|
|
440
|
+
lines.push(boxRow(`${C.dim}top voice${RESET} ${C.text}@${data.topAuthors[0]?.handle ?? '?'} (${data.topAuthors[0]?.count ?? 0})${RESET}`, W));
|
|
441
|
+
if (data.recentAuthors.length > 0) {
|
|
442
|
+
lines.push(boxDivider(W));
|
|
443
|
+
lines.push(boxRow(`${C.accent}${BOLD}LATEST SESSION${RESET}`, W));
|
|
444
|
+
for (const a of data.recentAuthors.slice(0, 5)) {
|
|
445
|
+
lines.push(boxRow(` ${C.text}@${a.handle}${RESET} ${C.dim}×${a.count}${RESET}`, W));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
lines.push(boxBottom(W));
|
|
449
|
+
return lines;
|
|
450
|
+
}
|
|
451
|
+
function truncateText(text, max) {
|
|
452
|
+
const clean = text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
453
|
+
if (clean.length <= max)
|
|
454
|
+
return clean;
|
|
455
|
+
return clean.slice(0, max - 1) + '…';
|
|
456
|
+
}
|
|
457
|
+
function twitterDateYear(date) {
|
|
458
|
+
return date.slice(-4);
|
|
459
|
+
}
|
|
460
|
+
function renderTimeCapsules(data) {
|
|
461
|
+
const lines = [];
|
|
462
|
+
if (data.timeCapsules.length === 0)
|
|
463
|
+
return [];
|
|
464
|
+
lines.push('');
|
|
465
|
+
lines.push(` ${C.gold}${BOLD}TIME CAPSULES${RESET}`);
|
|
466
|
+
lines.push(` ${C.dim}your oldest bookmarked posts — still saved after all these years${RESET}`);
|
|
467
|
+
lines.push('');
|
|
468
|
+
for (const b of data.timeCapsules) {
|
|
469
|
+
const year = twitterDateYear(b.postedAt);
|
|
470
|
+
const monthDay = b.postedAt.slice(4, 10); // " Mar 28"
|
|
471
|
+
const color = lerpColor([240, 200, 100], [200, 160, 80], 0.5);
|
|
472
|
+
const url = `x.com/${b.author}/status/${b.tweetId}`;
|
|
473
|
+
lines.push(` ${color}${year}${RESET}${C.dim}${monthDay}${RESET} ${C.text}@${b.author}${RESET}`);
|
|
474
|
+
lines.push(` ${C.dim}${truncateText(b.text, 62)}${RESET}`);
|
|
475
|
+
lines.push(` ${DIM}${url}${RESET}`);
|
|
476
|
+
lines.push('');
|
|
477
|
+
}
|
|
478
|
+
return lines;
|
|
479
|
+
}
|
|
480
|
+
function renderHiddenGems(data) {
|
|
481
|
+
const lines = [];
|
|
482
|
+
if (data.hiddenGems.length === 0)
|
|
483
|
+
return [];
|
|
484
|
+
lines.push('');
|
|
485
|
+
lines.push(` ${C.hot}${BOLD}HIDDEN GEMS${RESET}`);
|
|
486
|
+
lines.push(` ${C.dim}one-time voices you saved — long, substantive, easy to forget${RESET}`);
|
|
487
|
+
lines.push('');
|
|
488
|
+
for (const b of data.hiddenGems) {
|
|
489
|
+
const url = `x.com/${b.author}/status/${b.tweetId}`;
|
|
490
|
+
lines.push(` ${C.hot}◆${RESET} ${C.text}@${b.author}${RESET}`);
|
|
491
|
+
lines.push(` ${C.dim}${truncateText(b.text, 62)}${RESET}`);
|
|
492
|
+
lines.push(` ${DIM}${url}${RESET}`);
|
|
493
|
+
lines.push('');
|
|
494
|
+
}
|
|
495
|
+
return lines;
|
|
496
|
+
}
|
|
497
|
+
function renderRisingVoices(data) {
|
|
498
|
+
const lines = [];
|
|
499
|
+
if (data.risingVoices.length === 0)
|
|
500
|
+
return [];
|
|
501
|
+
const maxCount = data.risingVoices[0]?.count ?? 1;
|
|
502
|
+
lines.push('');
|
|
503
|
+
lines.push(` ${C.green}${BOLD}RISING${RESET}`);
|
|
504
|
+
lines.push(` ${C.dim}new voices — all bookmarks from your most recent month${RESET}`);
|
|
505
|
+
lines.push('');
|
|
506
|
+
for (const v of data.risingVoices) {
|
|
507
|
+
const handle = `@${v.handle}`.padEnd(22);
|
|
508
|
+
const dots = C.green + '●'.repeat(v.count) + C.dim + '○'.repeat(Math.max(0, maxCount - v.count)) + RESET;
|
|
509
|
+
lines.push(` ${C.text}${handle}${RESET} ${dots} ${C.dim}${v.count}${RESET}`);
|
|
510
|
+
}
|
|
511
|
+
return lines;
|
|
512
|
+
}
|
|
513
|
+
// ── Main render ──────────────────────────────────────────────────────────────
|
|
514
|
+
export async function renderViz() {
|
|
515
|
+
const data = await queryVizData();
|
|
516
|
+
const sections = [
|
|
517
|
+
...renderHeader(data),
|
|
518
|
+
...renderTopAuthors(data),
|
|
519
|
+
...renderActivity(data),
|
|
520
|
+
...renderDayOfWeek(data),
|
|
521
|
+
...renderHourOfDay(data),
|
|
522
|
+
...renderDomains(data),
|
|
523
|
+
...renderMediaBreakdown(data),
|
|
524
|
+
...renderTimeCapsules(data),
|
|
525
|
+
...renderHiddenGems(data),
|
|
526
|
+
...renderRisingVoices(data),
|
|
527
|
+
...renderFingerprint(data),
|
|
528
|
+
'', // trailing newline
|
|
529
|
+
];
|
|
530
|
+
return sections.join('\n');
|
|
531
|
+
}
|