brainplug 0.4.0__py3-none-any.whl
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.
- brainplug/__init__.py +3 -0
- brainplug/__main__.py +300 -0
- brainplug/agent.py +749 -0
- brainplug/analysis.py +453 -0
- brainplug/app.py +184 -0
- brainplug/brain.py +184 -0
- brainplug/connect.py +354 -0
- brainplug/demo.py +165 -0
- brainplug/export.py +142 -0
- brainplug/memory.py +356 -0
- brainplug/notion_api.py +135 -0
- brainplug/pipeline.py +95 -0
- brainplug/render.py +47 -0
- brainplug/sources.py +1193 -0
- brainplug/stopwords.py +33 -0
- brainplug/sync.py +109 -0
- brainplug/templates/app.html +212 -0
- brainplug/templates/viewer.html +1050 -0
- brainplug/vendor/INTER_LICENSE +93 -0
- brainplug/vendor/THREE_LICENSE +21 -0
- brainplug/vendor/inter-latin.woff2 +0 -0
- brainplug/vendor/three.module.min.js +6 -0
- brainplug-0.4.0.dist-info/METADATA +364 -0
- brainplug-0.4.0.dist-info/RECORD +28 -0
- brainplug-0.4.0.dist-info/WHEEL +5 -0
- brainplug-0.4.0.dist-info/entry_points.txt +2 -0
- brainplug-0.4.0.dist-info/licenses/LICENSE +21 -0
- brainplug-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="__LANG__">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<link rel="icon" href="data:,">
|
|
7
|
+
<title>__TITLE__ · Brainplug</title>
|
|
8
|
+
<style>
|
|
9
|
+
@font-face { font-family: "Inter"; font-weight: 100 900; font-display: swap;
|
|
10
|
+
src: url(data:font/woff2;base64,__FONT__) format("woff2"); }
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #000; --panel: rgba(0, 0, 0, 0.74); --line: rgba(255, 255, 255, 0.13);
|
|
13
|
+
--text: #f4f4f6; --muted: #8d8d96; --accent: #a77bff;
|
|
14
|
+
--spectrum: linear-gradient(90deg, #6f8cff 0%, #a77bff 22%, #ff6ad5 45%, #ff5d7a 62%, #ff8a3c 82%, #ffb62e 100%);
|
|
15
|
+
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
16
|
+
}
|
|
17
|
+
.spectrum { background: var(--spectrum); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
html, body { margin: 0; height: 100%; color: var(--text); overflow: hidden; background: #000; }
|
|
20
|
+
button, input, select { font: inherit; color: inherit; }
|
|
21
|
+
header { position: fixed; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 14px;
|
|
22
|
+
padding: 14px 20px; z-index: 6; pointer-events: none; }
|
|
23
|
+
header > * { pointer-events: auto; }
|
|
24
|
+
.brand { min-width: 0; margin-right: auto; }
|
|
25
|
+
.brand h1 { margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -0.015em; width: fit-content; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
26
|
+
.brand p { margin: 2px 0 0; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
27
|
+
.badge { margin-left: 8px; padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 500; letter-spacing: 0; color: #000; background: #f4f4f6;
|
|
28
|
+
-webkit-text-fill-color: #000; vertical-align: middle; }
|
|
29
|
+
nav, .pill { display: flex; gap: 2px; background: var(--panel); border: 1px solid var(--line);
|
|
30
|
+
border-radius: 99px; padding: 4px; backdrop-filter: blur(10px); }
|
|
31
|
+
nav button, .pill button { all: unset; cursor: pointer; padding: 6px 13px; border-radius: 99px; font-size: 13px; color: var(--muted); white-space: nowrap; }
|
|
32
|
+
nav button.on, .pill button.on { background: #f4f4f6; color: #000; }
|
|
33
|
+
.tools { display: flex; gap: 8px; margin-left: auto; }
|
|
34
|
+
.tools input, .tools select, .ctl select { background: var(--panel); border: 1px solid var(--line); border-radius: 99px;
|
|
35
|
+
padding: 7px 13px; font-size: 13px; outline: none; backdrop-filter: blur(10px); }
|
|
36
|
+
.tools input { width: 170px; }
|
|
37
|
+
.tools input:focus { border-color: var(--accent); }
|
|
38
|
+
select option { background: #000; }
|
|
39
|
+
|
|
40
|
+
.view { position: fixed; inset: 0; display: none; }
|
|
41
|
+
.view.on { display: block; }
|
|
42
|
+
#scene { position: fixed; inset: 0; }
|
|
43
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; backdrop-filter: blur(10px); }
|
|
44
|
+
h3 { margin: 0 0 8px; font-size: 12px; font-weight: 500; color: var(--muted); }
|
|
45
|
+
|
|
46
|
+
#side { position: fixed; left: 16px; top: 78px; width: 240px; max-height: calc(100% - 160px); overflow: auto; padding: 12px; z-index: 4; }
|
|
47
|
+
.topic { display: grid; grid-template-columns: 12px 1fr auto; gap: 8px; align-items: center; padding: 5px 6px; border-radius: 8px; cursor: pointer; font-size: 13px; }
|
|
48
|
+
.topic:hover, .row:hover, .rel:hover { background: rgba(255, 255, 255, 0.06); }
|
|
49
|
+
.topic.dim, .chip.off { opacity: 0.35; }
|
|
50
|
+
.dot { width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 8px currentColor; flex: none; }
|
|
51
|
+
.topic .bar { grid-column: 2 / 4; height: 3px; border-radius: 3px; opacity: 0.7; }
|
|
52
|
+
.n { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; }
|
|
53
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
54
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; border-radius: 99px; font-size: 12px;
|
|
55
|
+
border: 1px solid var(--line); cursor: pointer; user-select: none; white-space: nowrap; }
|
|
56
|
+
.chip.static { cursor: default; }
|
|
57
|
+
.shape { width: 9px; height: 9px; background: currentColor; flex: none; }
|
|
58
|
+
.shape.chat { border-radius: 50%; } .shape.note { transform: rotate(45deg) scale(0.82); }
|
|
59
|
+
.shape.message { border-radius: 50%; background: none; border: 2px solid currentColor; }
|
|
60
|
+
|
|
61
|
+
#bar { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
|
|
62
|
+
padding: 6px 10px; z-index: 4; border-radius: 99px; max-width: calc(100% - 20px); }
|
|
63
|
+
#bar .pill { border: 0; background: none; padding: 0; backdrop-filter: none; }
|
|
64
|
+
#bar .sep { width: 1px; height: 20px; background: var(--line); }
|
|
65
|
+
#play { all: unset; cursor: pointer; width: 28px; height: 28px; border-radius: 50%; background: rgba(255, 255, 255, 0.11); text-align: center; line-height: 28px; font-size: 12px; }
|
|
66
|
+
#cut { width: 180px; accent-color: var(--accent); }
|
|
67
|
+
#cutLabel { font-size: 12px; color: var(--muted); min-width: 120px; font-variant-numeric: tabular-nums; }
|
|
68
|
+
#hint { position: fixed; left: 50%; bottom: 62px; transform: translateX(-50%); color: var(--muted); font-size: 11px; z-index: 3; text-align: center; white-space: nowrap; }
|
|
69
|
+
|
|
70
|
+
#detail { position: fixed; right: 16px; top: 78px; width: 310px; max-height: calc(100% - 160px); overflow: auto; padding: 14px; z-index: 5; display: none; }
|
|
71
|
+
#detail.on { display: block; }
|
|
72
|
+
#detail h2 { margin: 8px 0 8px; font-size: 16px; line-height: 1.3; }
|
|
73
|
+
#detail .close { all: unset; cursor: pointer; float: right; color: var(--muted); font-size: 18px; line-height: 1; padding: 2px 6px; }
|
|
74
|
+
#detail .meta, .muted { color: var(--muted); font-size: 12px; }
|
|
75
|
+
#detail blockquote { margin: 10px 0; padding: 8px 10px; border-left: 2px solid var(--line); color: #c9c9d1; font-size: 12.5px; line-height: 1.45; }
|
|
76
|
+
.rel { display: grid; grid-template-columns: 10px 1fr auto; gap: 8px; align-items: center; padding: 6px; border-radius: 8px; cursor: pointer; font-size: 12.5px; }
|
|
77
|
+
.rel small { color: var(--muted); }
|
|
78
|
+
#tip { position: fixed; pointer-events: none; z-index: 7; background: rgba(0, 0, 0, 0.94); border: 1px solid var(--line);
|
|
79
|
+
border-radius: 10px; padding: 8px 10px; font-size: 12px; max-width: 260px; display: none; }
|
|
80
|
+
#tip b { display: block; font-size: 13px; margin-bottom: 2px; }
|
|
81
|
+
.label3d { position: fixed; z-index: 3; font-size: 12px; font-weight: 600; pointer-events: none; transform: translate(-50%, -50%);
|
|
82
|
+
text-shadow: 0 0 6px #000, 0 0 12px #000; white-space: nowrap; }
|
|
83
|
+
#sideToggle { display: none; }
|
|
84
|
+
|
|
85
|
+
.page { position: fixed; inset: 72px 0 0 0; overflow: auto; padding: 8px 28px 48px; }
|
|
86
|
+
.wrap { max-width: 1120px; margin: 0 auto; }
|
|
87
|
+
.page h2 { font-size: 30px; line-height: 1.15; margin: 10px 0 6px; font-weight: 600; letter-spacing: -0.02em; width: fit-content; max-width: 100%;
|
|
88
|
+
background: var(--spectrum); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
|
89
|
+
.page .sub { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
|
|
90
|
+
.ctl { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin: 0 0 14px; font-size: 12px; color: var(--muted); }
|
|
91
|
+
.ctl label { display: flex; align-items: center; gap: 8px; }
|
|
92
|
+
.ctl input[type=range] { accent-color: var(--accent); }
|
|
93
|
+
#wavesSvg { width: 100%; display: block; }
|
|
94
|
+
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
|
95
|
+
.card { padding: 14px 16px; }
|
|
96
|
+
.card .big { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
|
97
|
+
.card .small { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
|
98
|
+
.wide { grid-column: span 2; } .full { grid-column: 1 / -1; }
|
|
99
|
+
#heat { display: grid; grid-template-columns: 34px repeat(24, 1fr); gap: 3px; font-size: 10px; color: var(--muted); }
|
|
100
|
+
#heat div.c { aspect-ratio: 1; border-radius: 3px; }
|
|
101
|
+
.share { display: grid; grid-template-columns: 190px 1fr 56px; gap: 10px; align-items: center; font-size: 13px; margin: 6px 0; }
|
|
102
|
+
.share .track { height: 8px; background: rgba(255, 255, 255, 0.06); border-radius: 8px; overflow: hidden; }
|
|
103
|
+
.share .fill { height: 100%; border-radius: 8px; }
|
|
104
|
+
.share .fill.all { background: var(--spectrum); }
|
|
105
|
+
footer { color: var(--muted); font-size: 11px; text-align: center; margin-top: 26px; }
|
|
106
|
+
|
|
107
|
+
#tl { display: grid; grid-template-columns: 190px 1fr; gap: 22px; align-items: start; }
|
|
108
|
+
#tlMonths { position: sticky; top: 0; max-height: calc(100vh - 190px); overflow: auto; padding: 10px; }
|
|
109
|
+
.mo { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 5px 8px; border-radius: 8px; cursor: pointer; font-size: 13px; }
|
|
110
|
+
.mo:hover { background: rgba(255, 255, 255, 0.06); }
|
|
111
|
+
.mo i { grid-column: 1 / 3; height: 2px; border-radius: 2px; background: var(--spectrum); opacity: 0.8; }
|
|
112
|
+
.tlHead { font-size: 13px; font-weight: 600; margin: 18px 0 6px; color: var(--muted); }
|
|
113
|
+
.row { display: grid; grid-template-columns: 10px 1fr auto auto; gap: 10px; align-items: center; padding: 7px 8px; border-radius: 8px; cursor: pointer; font-size: 13.5px; }
|
|
114
|
+
.row .ttl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
115
|
+
.more { all: unset; cursor: pointer; margin: 16px 0; padding: 8px 16px; border-radius: 99px; border: 1px solid var(--line); font-size: 13px; }
|
|
116
|
+
|
|
117
|
+
table.pv { border-collapse: separate; border-spacing: 3px; font-size: 12.5px; }
|
|
118
|
+
table.pv th { font-weight: 500; color: var(--muted); text-align: right; padding: 4px 8px; white-space: nowrap; }
|
|
119
|
+
table.pv thead th { text-align: center; }
|
|
120
|
+
table.pv td { text-align: center; padding: 6px 10px; border-radius: 5px; min-width: 44px; font-variant-numeric: tabular-nums; }
|
|
121
|
+
table.pv .tot { color: var(--muted); }
|
|
122
|
+
.scroll { overflow: auto; padding-bottom: 8px; }
|
|
123
|
+
|
|
124
|
+
.rv { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
|
125
|
+
.rv .card p { margin: 0 0 8px; font-size: 13.5px; line-height: 1.45; }
|
|
126
|
+
.rv .line { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13.5px; }
|
|
127
|
+
.rv .line b { font-weight: 600; }
|
|
128
|
+
.rv .go { cursor: pointer; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; }
|
|
129
|
+
.arrow { font-variant-numeric: tabular-nums; color: var(--muted); margin-left: auto; white-space: nowrap; }
|
|
130
|
+
pre#ctx { white-space: pre-wrap; font: 12px/1.5 ui-monospace, Menlo, Consolas, monospace; color: #c9c9d1; margin: 0 0 10px;
|
|
131
|
+
max-height: 260px; overflow: auto; padding: 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.05); }
|
|
132
|
+
.btn { all: unset; cursor: pointer; padding: 7px 14px; border-radius: 99px; background: #f4f4f6; color: #000; font-size: 13px; font-weight: 600; }
|
|
133
|
+
.btn:disabled { opacity: 0.45; cursor: wait; }
|
|
134
|
+
.btn[hidden] { display: none; }
|
|
135
|
+
#shareCard { width: min(920px, calc(100% - 24px)); max-height: calc(100% - 24px); padding: 20px;
|
|
136
|
+
color: var(--text); background: #101014; border: 1px solid var(--line); border-radius: 18px; overflow: auto; }
|
|
137
|
+
#shareCard::backdrop { background: rgba(0, 0, 0, 0.8); }
|
|
138
|
+
.card-preview { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 24px; align-items: start; }
|
|
139
|
+
#yearCard { display: block; width: 100%; height: auto; border-radius: 10px; background: #000; }
|
|
140
|
+
.card-actions { display: flex; flex-direction: column; gap: 16px; }
|
|
141
|
+
.card-actions h2 { margin: 0; font-size: 22px; }
|
|
142
|
+
.card-actions p { margin: 0; line-height: 1.5; }
|
|
143
|
+
.card-actions label { display: flex; gap: 8px; align-items: center; font-size: 13px; }
|
|
144
|
+
.card-actions input { accent-color: var(--accent); }
|
|
145
|
+
#cardStatus { min-height: 36px; }
|
|
146
|
+
#closeCard { align-self: start; background: transparent; color: var(--text); border: 1px solid var(--line); }
|
|
147
|
+
@media (max-width: 600px) {
|
|
148
|
+
.card-preview { grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
|
149
|
+
#yearCard { max-height: 52vh; object-fit: contain; }
|
|
150
|
+
}
|
|
151
|
+
button:focus-visible, input:focus-visible, select:focus-visible, .chip:focus-visible { outline: 2px solid #a77bff; outline-offset: 2px; }
|
|
152
|
+
|
|
153
|
+
@media (max-width: 900px) {
|
|
154
|
+
header { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
|
|
155
|
+
nav { order: 3; width: 100%; overflow-x: auto; }
|
|
156
|
+
.tools input { width: 120px; }
|
|
157
|
+
#side { top: auto; bottom: 110px; left: 10px; right: 10px; width: auto; max-height: 34vh; display: none; }
|
|
158
|
+
#side.on { display: block; }
|
|
159
|
+
#sideToggle { display: block; position: fixed; left: 10px; bottom: 66px; z-index: 5; }
|
|
160
|
+
#detail { top: auto; bottom: 110px; left: 10px; right: 10px; width: auto; max-height: 42vh; }
|
|
161
|
+
#bar { bottom: 10px; gap: 6px; padding: 5px 8px; }
|
|
162
|
+
#cut { width: 110px; } #cutLabel, #synWrap, #synSep { display: none; }
|
|
163
|
+
#hint { display: none; }
|
|
164
|
+
.page { inset: 128px 0 0 0; padding: 8px 14px 40px; }
|
|
165
|
+
.grid { grid-template-columns: repeat(2, 1fr); } .rv { grid-template-columns: 1fr; }
|
|
166
|
+
.wide, .full { grid-column: 1 / -1; }
|
|
167
|
+
.share { grid-template-columns: 120px 1fr 44px; }
|
|
168
|
+
#tl { grid-template-columns: 1fr; } #tlMonths { display: none; }
|
|
169
|
+
.row { grid-template-columns: 10px 1fr auto; } .row .src { display: none; }
|
|
170
|
+
.label3d { font-size: 10px; }
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
173
|
+
</head>
|
|
174
|
+
<body>
|
|
175
|
+
<header>
|
|
176
|
+
<div class="brand"><h1 id="title" class="spectrum"></h1><p id="subtitle"></p></div>
|
|
177
|
+
<nav id="tabs"></nav>
|
|
178
|
+
<div class="tools">
|
|
179
|
+
<input id="q" type="search" autocomplete="off">
|
|
180
|
+
<select id="palette" aria-label="palette">
|
|
181
|
+
<option value="spectrum">Spectrum</option><option value="aurora">Aurora</option><option value="lava">Lava</option>
|
|
182
|
+
<option value="ocean">Ocean</option><option value="mono">Mono</option>
|
|
183
|
+
</select>
|
|
184
|
+
</div>
|
|
185
|
+
</header>
|
|
186
|
+
|
|
187
|
+
<section class="view on" id="view-brain">
|
|
188
|
+
<div id="scene"></div>
|
|
189
|
+
<aside id="side" class="panel">
|
|
190
|
+
<h3 id="hTopics"></h3><div id="topics"></div>
|
|
191
|
+
<h3 id="hSources" style="margin-top:14px"></h3><div id="sources" class="chips"></div>
|
|
192
|
+
</aside>
|
|
193
|
+
<button id="sideToggle" class="chip panel"></button>
|
|
194
|
+
<div id="labels"></div>
|
|
195
|
+
<div id="hint"></div>
|
|
196
|
+
<div id="bar" class="panel">
|
|
197
|
+
<div class="pill" id="layout"><button data-m="0" class="on"></button><button data-m="1"></button></div>
|
|
198
|
+
<span class="sep"></span>
|
|
199
|
+
<div class="pill" id="synWrap"><button id="syn"></button></div>
|
|
200
|
+
<span class="sep" id="synSep"></span>
|
|
201
|
+
<button id="play" aria-label="time-lapse">▶</button>
|
|
202
|
+
<input id="cut" type="range" min="0" max="1000" value="1000">
|
|
203
|
+
<span id="cutLabel"></span>
|
|
204
|
+
</div>
|
|
205
|
+
<aside id="detail" class="panel"></aside>
|
|
206
|
+
<div id="tip"></div>
|
|
207
|
+
</section>
|
|
208
|
+
|
|
209
|
+
<section class="view" id="view-timeline"><div class="page"><div class="wrap">
|
|
210
|
+
<h2 id="tlTitle"></h2><p class="sub" id="tlSub"></p>
|
|
211
|
+
<div id="tl"><aside id="tlMonths" class="panel"></aside><div id="tlList"></div></div>
|
|
212
|
+
</div></div></section>
|
|
213
|
+
|
|
214
|
+
<section class="view" id="view-waves"><div class="page"><div class="wrap">
|
|
215
|
+
<h2 id="wvTitle"></h2><p class="sub" id="wvSub"></p>
|
|
216
|
+
<div class="ctl">
|
|
217
|
+
<label><span id="wvSmoothL"></span><input id="wvSmooth" type="range" min="0" max="4" value="1"></label>
|
|
218
|
+
<label><span id="wvScaleL"></span><select id="wvScale"></select></label>
|
|
219
|
+
</div>
|
|
220
|
+
<svg id="wavesSvg"></svg>
|
|
221
|
+
</div></div></section>
|
|
222
|
+
|
|
223
|
+
<section class="view" id="view-tables"><div class="page"><div class="wrap">
|
|
224
|
+
<h2 id="tbTitle"></h2><p class="sub" id="tbSub"></p>
|
|
225
|
+
<div class="ctl">
|
|
226
|
+
<label><span id="tbRowsL"></span><select id="tbRows"></select></label>
|
|
227
|
+
<label><span id="tbColsL"></span><select id="tbCols"></select></label>
|
|
228
|
+
<label><span id="tbMeasL"></span><select id="tbMeas"></select></label>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="scroll" id="tbOut"></div>
|
|
231
|
+
</div></div></section>
|
|
232
|
+
|
|
233
|
+
<section class="view" id="view-review"><div class="page"><div class="wrap">
|
|
234
|
+
<h2 id="rvTitle"></h2><p class="sub" id="rvSub"></p>
|
|
235
|
+
<div class="rv" id="rvCards"></div>
|
|
236
|
+
</div></div></section>
|
|
237
|
+
|
|
238
|
+
<section class="view" id="view-numbers"><div class="page"><div class="wrap">
|
|
239
|
+
<h2 id="nbTitle"></h2><p class="sub" id="nbSub"></p>
|
|
240
|
+
<div class="ctl"><button class="btn" id="shareCardOpen"></button></div>
|
|
241
|
+
<div class="grid" id="cards"></div>
|
|
242
|
+
<footer id="footer"></footer>
|
|
243
|
+
</div></div></section>
|
|
244
|
+
|
|
245
|
+
<dialog id="shareCard" aria-labelledby="shareCardTitle">
|
|
246
|
+
<div class="card-preview">
|
|
247
|
+
<canvas id="yearCard" width="1080" height="1350" role="img"></canvas>
|
|
248
|
+
<div class="card-actions">
|
|
249
|
+
<h2 id="shareCardTitle"></h2>
|
|
250
|
+
<p class="muted" id="shareCardNote"></p>
|
|
251
|
+
<label><input id="showTopicNames" type="checkbox"><span id="showTopicNamesLabel"></span></label>
|
|
252
|
+
<button class="btn" id="downloadCard" disabled></button>
|
|
253
|
+
<button class="btn" id="copyCard" disabled hidden></button>
|
|
254
|
+
<p class="muted" id="cardStatus" role="status" aria-live="polite"></p>
|
|
255
|
+
<button class="btn" id="closeCard"></button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</dialog>
|
|
259
|
+
|
|
260
|
+
<script id="bp-data" type="application/json">__DATA__</script>
|
|
261
|
+
<script id="three-src" type="text/plain">__THREE__</script>
|
|
262
|
+
<script type="module">
|
|
263
|
+
const DATA = JSON.parse(document.getElementById('bp-data').textContent);
|
|
264
|
+
const LANG = document.documentElement.lang === 'es' ? 'es' : 'en';
|
|
265
|
+
const L = {
|
|
266
|
+
en: {
|
|
267
|
+
tabs: { brain: 'Brain', timeline: 'Timeline', waves: 'Waves', tables: 'Tables', review: 'Review', numbers: 'Numbers' },
|
|
268
|
+
sub: (n, a, b) => `${n} items · ${a} → ${b}`, private: 'private', search: 'Search…',
|
|
269
|
+
topics: 'Topics', sources: 'Sources', brain: 'Brain', galaxy: 'Galaxy', synapses: 'Synapses',
|
|
270
|
+
hint: 'Drag to rotate · scroll to zoom · click a dot to open it',
|
|
271
|
+
kinds: ['AI chat', 'note', 'message'], kindsPl: ['AI chats', 'notes & docs', 'messages'],
|
|
272
|
+
hidden: 'Hidden (private mode)', nodate: 'no date', words: 'words', msgs: 'messages', sent: 'sent',
|
|
273
|
+
related: 'Related', linked: 'linked', similar: 'similar', none: 'Nothing related found.',
|
|
274
|
+
privateNote: 'Private mode: titles and text are not in this file.', soFar: 'so far',
|
|
275
|
+
tlTitle: 'Timeline', tlSub: 'Everything you wrote, newest first. Click an entry to find it in the brain.',
|
|
276
|
+
showMore: 'Show more', noMatch: 'Nothing matches the current filters.',
|
|
277
|
+
wvTitle: 'How your interests rise and fall', wvSub: 'Items per month for each topic.',
|
|
278
|
+
smooth: 'Smoothing', scale: 'Scale', shared: 'Shared', own: 'Each topic its own',
|
|
279
|
+
tbTitle: 'Table builder', tbSub: 'Cross any two properties of your record.',
|
|
280
|
+
rows: 'Rows', cols: 'Columns', measure: 'Measure', total: 'Total',
|
|
281
|
+
dims: { topic: 'Topic', source: 'Source', kind: 'Type', year: 'Year', month: 'Month', weekday: 'Weekday', daypart: 'Time of day' },
|
|
282
|
+
meas: { items: 'Items', words: 'Words you wrote', writes: 'Messages you sent' },
|
|
283
|
+
dayparts: ['Night (0–6)', 'Morning (6–12)', 'Afternoon (12–18)', 'Evening (18–24)'],
|
|
284
|
+
rvTitle: 'Review', rvSub: d => `What changed in your thinking, as of ${d}.`,
|
|
285
|
+
rising: 'Heating up', fading: 'Cooling down', vs: d => `last ${d} days vs the ${d} before`,
|
|
286
|
+
dormant: 'Left behind', dormantRow: (d, n) => `nothing since ${d} (${n} days)`,
|
|
287
|
+
newest: 'Newest interest', since: m => `first appeared in ${m}`,
|
|
288
|
+
bridges: 'Bridges between topics', bridgeRow: n => `${n} item${n === 1 ? '' : 's'} in between`,
|
|
289
|
+
cross: 'Across your tools', crossSub: n => `${n} connections join things you wrote in different apps.`,
|
|
290
|
+
deep: 'Deep dives', yearAgo: 'This month, a year ago', nothing: 'Nothing here yet.',
|
|
291
|
+
ctx: 'Context card for your AI', ctxSub: 'Paste this into ChatGPT or Claude so it knows what you care about.',
|
|
292
|
+
copy: 'Copy', copied: 'Copied ✓',
|
|
293
|
+
cardOpen: 'Share', cardTitle: 'Your year with AI', cardAllTime: 'All time',
|
|
294
|
+
cardNote: 'Activity uses item dates and hours. Check your preview before sharing.',
|
|
295
|
+
cardTopics: 'Show topic names', cardTopic: n => `Topic ${n}`, cardDownload: 'Download PNG',
|
|
296
|
+
cardCopy: 'Copy image', cardClose: 'Close', cardError: 'Could not create the image. Please try again.',
|
|
297
|
+
cardCopyError: 'Could not copy the image. Download the PNG instead.', cardHour: 'Favourite hour',
|
|
298
|
+
cardChatWords: 'Words written to AI chats', cardSources: 'Top sources', cardTopTopics: 'Top topics · share of items',
|
|
299
|
+
ctxHead: (a, b) => `# About me\n(Generated by Brainplug from my own writing, ${a} to ${b}.)`,
|
|
300
|
+
ctxTopics: 'What I think and write about most:', ctxNow: 'Lately I am focused on:', ctxDrop: 'I have moved away from:',
|
|
301
|
+
ctxHabit: (d, h) => `I do most of my thinking on ${d}s around ${h}:00.`,
|
|
302
|
+
ctxEnd: 'Use this to pick relevant examples. Ask before assuming anything else about me.',
|
|
303
|
+
nbTitle: 'Your record in numbers', nbSub: (a, b) => `From ${a} to ${b}.`,
|
|
304
|
+
items: 'Items', writes: 'Things you wrote', wordsYou: 'Words you wrote', pages: 'pages',
|
|
305
|
+
activeDays: 'Active days', streak: 'Longest streak', days: 'days in a row',
|
|
306
|
+
busiestMonth: 'Busiest month', rhythm: 'Your rhythm', mostOn: (d, h) => `Most active on ${d}s around ${h}:00`,
|
|
307
|
+
night: 'Night owl', nightSub: 'of what you wrote, between 00:00 and 06:00', longest: 'Longest item',
|
|
308
|
+
bySource: 'Where you write', heat: 'When you write', share: 'What you write about', undated: n => `${n} without a date`,
|
|
309
|
+
footer: v => `Made with Brainplug ${v} · generated locally, nothing left your computer`,
|
|
310
|
+
src: { memory: 'AI memory' },
|
|
311
|
+
weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
|
312
|
+
weekdaysLong: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
|
313
|
+
months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
314
|
+
},
|
|
315
|
+
es: {
|
|
316
|
+
tabs: { brain: 'Cerebro', timeline: 'Cronología', waves: 'Olas', tables: 'Tablas', review: 'Repaso', numbers: 'Números' },
|
|
317
|
+
sub: (n, a, b) => `${n} elementos · ${a} → ${b}`, private: 'privado', search: 'Buscar…',
|
|
318
|
+
topics: 'Temas', sources: 'Fuentes', brain: 'Cerebro', galaxy: 'Galaxia', synapses: 'Sinapsis',
|
|
319
|
+
hint: 'Arrastra para girar · rueda para acercar · pulsa un punto para abrirlo',
|
|
320
|
+
kinds: ['chat con IA', 'nota', 'mensaje'], kindsPl: ['chats con IA', 'notas y documentos', 'mensajes'],
|
|
321
|
+
hidden: 'Oculto (modo privado)', nodate: 'sin fecha', words: 'palabras', msgs: 'mensajes', sent: 'enviados',
|
|
322
|
+
related: 'Relacionado', linked: 'enlazado', similar: 'parecido', none: 'No hay nada relacionado.',
|
|
323
|
+
privateNote: 'Modo privado: los títulos y el texto no están en este archivo.', soFar: 'hasta aquí',
|
|
324
|
+
tlTitle: 'Cronología', tlSub: 'Todo lo que has escrito, de lo más nuevo a lo más antiguo. Pulsa una entrada para verla en el cerebro.',
|
|
325
|
+
showMore: 'Ver más', noMatch: 'Nada coincide con los filtros actuales.',
|
|
326
|
+
wvTitle: 'Cómo suben y bajan tus intereses', wvSub: 'Elementos al mes de cada tema.',
|
|
327
|
+
smooth: 'Suavizado', scale: 'Escala', shared: 'Compartida', own: 'Propia de cada tema',
|
|
328
|
+
tbTitle: 'Constructor de tablas', tbSub: 'Cruza dos propiedades cualesquiera de tu registro.',
|
|
329
|
+
rows: 'Filas', cols: 'Columnas', measure: 'Medida', total: 'Total',
|
|
330
|
+
dims: { topic: 'Tema', source: 'Fuente', kind: 'Tipo', year: 'Año', month: 'Mes', weekday: 'Día de la semana', daypart: 'Momento del día' },
|
|
331
|
+
meas: { items: 'Elementos', words: 'Palabras escritas', writes: 'Mensajes enviados' },
|
|
332
|
+
dayparts: ['Madrugada (0–6)', 'Mañana (6–12)', 'Tarde (12–18)', 'Noche (18–24)'],
|
|
333
|
+
rvTitle: 'Repaso', rvSub: d => `Qué ha cambiado en lo que piensas, a fecha de ${d}.`,
|
|
334
|
+
rising: 'Al alza', fading: 'A la baja', vs: d => `últimos ${d} días frente a los ${d} anteriores`,
|
|
335
|
+
dormant: 'Abandonados', dormantRow: (d, n) => `nada desde el ${d} (${n} días)`,
|
|
336
|
+
newest: 'Interés más reciente', since: m => `apareció por primera vez en ${m}`,
|
|
337
|
+
bridges: 'Puentes entre temas', bridgeRow: n => `${n} elemento${n === 1 ? '' : 's'} a medio camino`,
|
|
338
|
+
cross: 'Entre tus herramientas', crossSub: n => `${n} conexiones unen cosas que escribiste en apps distintas.`,
|
|
339
|
+
deep: 'Inmersiones', yearAgo: 'Este mes, hace un año', nothing: 'Aún no hay nada aquí.',
|
|
340
|
+
ctx: 'Ficha de contexto para tu IA', ctxSub: 'Pégala en ChatGPT o Claude para que sepa qué te interesa.',
|
|
341
|
+
copy: 'Copiar', copied: 'Copiado ✓',
|
|
342
|
+
cardOpen: 'Compartir', cardTitle: 'Tu año con la IA', cardAllTime: 'Todo el historial',
|
|
343
|
+
cardNote: 'La actividad usa las fechas y horas de los elementos. Revisa la tarjeta antes de compartirla.',
|
|
344
|
+
cardTopics: 'Mostrar nombres de temas', cardTopic: n => `Tema ${n}`, cardDownload: 'Descargar PNG',
|
|
345
|
+
cardCopy: 'Copiar imagen', cardClose: 'Cerrar', cardError: 'No se pudo crear la imagen. Inténtalo de nuevo.',
|
|
346
|
+
cardCopyError: 'No se pudo copiar la imagen. Descarga el PNG.', cardHour: 'Hora favorita',
|
|
347
|
+
cardChatWords: 'Palabras escritas en chats con IA', cardSources: 'Fuentes principales', cardTopTopics: 'Temas principales · proporción de elementos',
|
|
348
|
+
ctxHead: (a, b) => `# Sobre mí\n(Generado por Brainplug a partir de lo que he escrito, del ${a} al ${b}.)`,
|
|
349
|
+
ctxTopics: 'Sobre lo que más pienso y escribo:', ctxNow: 'Últimamente estoy centrado en:', ctxDrop: 'He dejado de lado:',
|
|
350
|
+
ctxHabit: (d, h) => `Suelo pensar y escribir los ${d} sobre las ${h}:00.`,
|
|
351
|
+
ctxEnd: 'Úsalo para elegir ejemplos relevantes. Pregunta antes de suponer cualquier otra cosa sobre mí.',
|
|
352
|
+
nbTitle: 'Tu registro en números', nbSub: (a, b) => `Del ${a} al ${b}.`,
|
|
353
|
+
items: 'Elementos', writes: 'Cosas que escribiste', wordsYou: 'Palabras escritas', pages: 'páginas',
|
|
354
|
+
activeDays: 'Días activos', streak: 'Racha más larga', days: 'días seguidos',
|
|
355
|
+
busiestMonth: 'Mes con más actividad', rhythm: 'Tu ritmo', mostOn: (d, h) => `Más activo los ${d} sobre las ${h}:00`,
|
|
356
|
+
night: 'Búho nocturno', nightSub: 'de lo que escribiste, entre las 00:00 y las 06:00', longest: 'Elemento más largo',
|
|
357
|
+
bySource: 'Dónde escribes', heat: 'Cuándo escribes', share: 'De qué escribes', undated: n => `${n} sin fecha`,
|
|
358
|
+
footer: v => `Hecho con Brainplug ${v} · generado en local, nada salió de tu ordenador`,
|
|
359
|
+
src: { memory: 'Memoria de la IA', notes: 'Notas' },
|
|
360
|
+
weekdays: ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'],
|
|
361
|
+
weekdaysLong: ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábados', 'domingos'],
|
|
362
|
+
months: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
|
|
363
|
+
},
|
|
364
|
+
}[LANG];
|
|
365
|
+
|
|
366
|
+
const N = DATA.numbers, P = DATA.points, IT = DATA.items, TP = DATA.topics, E = DATA.edges, R = DATA.review;
|
|
367
|
+
const SRC = Object.fromEntries(N.sources.map(s => [s.id, s]));
|
|
368
|
+
const KIND = ['chat', 'note', 'message'];
|
|
369
|
+
const $ = id => document.getElementById(id);
|
|
370
|
+
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
371
|
+
const fmt = n => Number(n).toLocaleString(LANG);
|
|
372
|
+
const monthName = ym => { const [y, m] = ym.split('-'); return `${L.months[+m - 1]} ${y}`; };
|
|
373
|
+
const touch = matchMedia('(pointer: coarse)').matches;
|
|
374
|
+
const narrow = () => innerWidth < 900;
|
|
375
|
+
|
|
376
|
+
const PALETTES = {
|
|
377
|
+
spectrum: ['#6f8cff', '#ff6ad5', '#ffb62e', '#a77bff', '#ff5d7a', '#ff8a3c', '#b9c6ff', '#ffb3e6', '#ffe1a3', '#cf74f0', '#ff7359', '#d9c2ff', '#8f83ff', '#ff64a8', '#ffa035', '#ffc2a1'],
|
|
378
|
+
aurora: ['#4cc9f0', '#f72585', '#b8f35a', '#ffb703', '#9d4edd', '#2ec4b6', '#ff6b6b', '#ffd6a5', '#80ffdb', '#c77dff', '#f15bb5', '#00bbf9', '#fee440', '#a0c4ff', '#ff9f1c', '#06d6a0'],
|
|
379
|
+
lava: ['#ffd166', '#ff9f1c', '#ff6b35', '#f94144', '#ffe8a3', '#f8961e', '#ff8fa3', '#e85d04', '#ffba08', '#ff4d6d', '#ffb4a2', '#dc2f02', '#f9c74f', '#e5989b', '#faa307', '#ff7b54'],
|
|
380
|
+
ocean: ['#4cc9f0', '#80ffdb', '#4895ef', '#64dfdf', '#9d8df1', '#48bfe3', '#72efdd', '#5e60ce', '#90e0ef', '#06d6a0', '#00b4d8', '#ade8f4', '#7b8cff', '#56cfe1', '#b8f1ff', '#3fa7d6'],
|
|
381
|
+
mono: ['#ffffff', '#c8d0dc', '#9aa5b5', '#e9ecef', '#b3bcc9', '#dfe4ea', '#8f9bad', '#f4f6f8', '#aab4c3', '#d3d9e1', '#bfc7d2', '#eef1f5', '#a2adbd', '#cfd6df', '#e3e7ec', '#98a3b3'],
|
|
382
|
+
};
|
|
383
|
+
let pal = 'spectrum';
|
|
384
|
+
const col = k => PALETTES[pal][k % 16];
|
|
385
|
+
const itemTitle = i => IT[i].t ? esc(IT[i].t) : `<i>${L.hidden}</i>`;
|
|
386
|
+
const itemDate = i => IT[i].u ? L.nodate : IT[i].d;
|
|
387
|
+
const srcName = s => L.src[s] || (SRC[s] ? SRC[s].name : s);
|
|
388
|
+
const kindOf = i => KIND[P[i][9]];
|
|
389
|
+
const dotHtml = k => `<span class="dot" style="background:${col(k)};color:${col(k)}"></span>`;
|
|
390
|
+
|
|
391
|
+
/* ---------------------------------------------------------------- state */
|
|
392
|
+
const S = { view: 'brain', focus: -1, off: new Set(), q: '', sel: -1, morph: 0, morphTo: 0, cut: 1, playing: false, syn: false };
|
|
393
|
+
const hay = IT.map(it => `${it.t || ''} ${(it.tm || []).join(' ')} ${TP[it.k].name} ${srcName(it.s)}`.toLowerCase());
|
|
394
|
+
const vis = new Uint8Array(IT.length).fill(1);
|
|
395
|
+
const order = IT.map((_, i) => i).sort((a, b) => IT[b].d < IT[a].d ? -1 : IT[b].d > IT[a].d ? 1 : b - a);
|
|
396
|
+
const adj = IT.map(() => []);
|
|
397
|
+
E.forEach(([a, b, w, kind]) => { adj[a].push({ j: b, w, kind }); adj[b].push({ j: a, w, kind }); });
|
|
398
|
+
adj.forEach(list => list.sort((x, y) => y.kind - x.kind || y.w - x.w));
|
|
399
|
+
|
|
400
|
+
$('title').innerHTML = esc(DATA.meta.title) + (DATA.meta.private ? `<span class="badge">${L.private}</span>` : '');
|
|
401
|
+
$('subtitle').textContent = L.sub(fmt(N.items), N.first, N.last);
|
|
402
|
+
$('q').placeholder = L.search;
|
|
403
|
+
$('hTopics').textContent = L.topics; $('hSources').textContent = L.sources;
|
|
404
|
+
$('hint').textContent = L.hint;
|
|
405
|
+
$('sideToggle').textContent = L.topics;
|
|
406
|
+
$('sideToggle').onclick = () => $('side').classList.toggle('on');
|
|
407
|
+
document.querySelector('#layout [data-m="0"]').textContent = L.brain;
|
|
408
|
+
document.querySelector('#layout [data-m="1"]').textContent = L.galaxy;
|
|
409
|
+
$('syn').textContent = L.synapses;
|
|
410
|
+
|
|
411
|
+
/* ----------------------------------------------------------------- tabs */
|
|
412
|
+
const views = { brain: () => {}, timeline: drawTimeline, waves: drawWaves, tables: drawTable, review: drawReview, numbers: drawNumbers };
|
|
413
|
+
Object.keys(views).forEach(v => {
|
|
414
|
+
const b = document.createElement('button');
|
|
415
|
+
b.textContent = L.tabs[v]; b.dataset.view = v; b.className = v === 'brain' ? 'on' : '';
|
|
416
|
+
b.onclick = () => show(v);
|
|
417
|
+
$('tabs').appendChild(b);
|
|
418
|
+
});
|
|
419
|
+
function show(v) {
|
|
420
|
+
S.view = v;
|
|
421
|
+
document.querySelectorAll('#tabs button').forEach(x => x.classList.toggle('on', x.dataset.view === v));
|
|
422
|
+
document.querySelectorAll('.view').forEach(x => x.classList.toggle('on', x.id === 'view-' + v));
|
|
423
|
+
views[v]();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ------------------------------------------------------ legend, sources */
|
|
427
|
+
function drawLegend() {
|
|
428
|
+
$('topics').innerHTML = TP.map(t => `<div class="topic${S.focus >= 0 && S.focus !== t.id ? ' dim' : ''}" data-k="${t.id}">
|
|
429
|
+
${dotHtml(t.id)}<span>${esc(t.name)}</span><span class="n">${t.count}</span>
|
|
430
|
+
<span class="bar" style="background:${col(t.id)};width:${Math.max(4, t.share * 100 / TP[0].share)}%"></span></div>`).join('');
|
|
431
|
+
$('topics').querySelectorAll('.topic').forEach(el => el.onclick = () => { S.focus = S.focus === +el.dataset.k ? -1 : +el.dataset.k; refilter(); });
|
|
432
|
+
$('sources').innerHTML = N.sources.map(s => {
|
|
433
|
+
const kind = KIND[P[IT.findIndex(it => it.s === s.id)][9]];
|
|
434
|
+
return `<span class="chip${S.off.has(s.id) ? ' off' : ''}" data-s="${s.id}"><span class="shape ${kind}"></span>${esc(srcName(s.id))} <span class="n">${s.count}</span></span>`;
|
|
435
|
+
}).join('');
|
|
436
|
+
$('sources').querySelectorAll('.chip').forEach(el => el.onclick = () => { S.off.has(el.dataset.s) ? S.off.delete(el.dataset.s) : S.off.add(el.dataset.s); refilter(); });
|
|
437
|
+
}
|
|
438
|
+
$('q').addEventListener('input', e => { S.q = e.target.value.trim().toLowerCase(); refilter(); });
|
|
439
|
+
$('palette').addEventListener('change', e => { pal = e.target.value; recolor(); });
|
|
440
|
+
|
|
441
|
+
/* ---------------------------------------------------------------- three */
|
|
442
|
+
const THREE = await import(URL.createObjectURL(new Blob([$('three-src').textContent], { type: 'text/javascript' })));
|
|
443
|
+
const host = $('scene');
|
|
444
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
445
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
446
|
+
host.appendChild(renderer.domElement);
|
|
447
|
+
const scene = new THREE.Scene();
|
|
448
|
+
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
|
|
449
|
+
const root = new THREE.Group();
|
|
450
|
+
scene.add(root);
|
|
451
|
+
|
|
452
|
+
const U = { uScale: { value: 1 }, uTime: { value: 0 }, uMorph: { value: 0 }, uCut: { value: 1 } };
|
|
453
|
+
const vert = `
|
|
454
|
+
attribute vec3 pos2; attribute float size; attribute float alpha; attribute vec3 color; attribute float tn; attribute float shape;
|
|
455
|
+
uniform float uScale; uniform float uTime; uniform float uMode; uniform float uMorph; uniform float uCut;
|
|
456
|
+
varying vec3 vColor; varying float vAlpha; varying float vShape;
|
|
457
|
+
void main() {
|
|
458
|
+
vec3 p = mix(position, pos2, uMorph);
|
|
459
|
+
float a = alpha;
|
|
460
|
+
if (uMode > 0.5 && uMode < 1.5) a *= 0.65 + 0.35 * sin(uTime * 1.3 + position.y * 5.0 + position.z * 3.0);
|
|
461
|
+
if (uMode > 1.5) a *= 0.45 + 0.55 * pow(0.5 + 0.5 * sin(uTime * 2.2 - position.y * 7.0), 3.0);
|
|
462
|
+
a *= step(tn, uCut);
|
|
463
|
+
float fresh = (1.0 - smoothstep(0.0, 0.035, uCut - tn)) * step(uCut, 0.999) * step(0.0, tn);
|
|
464
|
+
vColor = color + fresh * 0.6; vAlpha = a; vShape = shape;
|
|
465
|
+
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
|
466
|
+
gl_PointSize = size * (1.0 + 1.8 * fresh) * uScale / -mv.z;
|
|
467
|
+
gl_Position = projectionMatrix * mv;
|
|
468
|
+
}`;
|
|
469
|
+
const frag = `
|
|
470
|
+
varying vec3 vColor; varying float vAlpha; varying float vShape;
|
|
471
|
+
void main() {
|
|
472
|
+
vec2 c = gl_PointCoord - 0.5;
|
|
473
|
+
float d = length(c), a, core;
|
|
474
|
+
if (vShape < 0.5) { // chat: soft disc
|
|
475
|
+
if (d > 0.5) discard;
|
|
476
|
+
a = smoothstep(0.5, 0.0, d); a *= a; core = smoothstep(0.16, 0.0, d);
|
|
477
|
+
} else if (vShape < 1.5) { // note: diamond
|
|
478
|
+
float m = abs(c.x) + abs(c.y);
|
|
479
|
+
if (m > 0.5) discard;
|
|
480
|
+
a = smoothstep(0.5, 0.05, m); a = a * a * 1.25; core = smoothstep(0.2, 0.0, m);
|
|
481
|
+
} else { // message: ring
|
|
482
|
+
if (d > 0.5) discard;
|
|
483
|
+
float ring = smoothstep(0.5, 0.4, d) * smoothstep(0.22, 0.32, d);
|
|
484
|
+
a = max(ring * 0.9, pow(smoothstep(0.5, 0.0, d), 2.0) * 0.45); core = ring * 0.6;
|
|
485
|
+
}
|
|
486
|
+
gl_FragColor = vec4(vColor * (0.55 + 0.9 * core), a * vAlpha);
|
|
487
|
+
}`;
|
|
488
|
+
function cloud(attrs, mode) {
|
|
489
|
+
const g = new THREE.BufferGeometry();
|
|
490
|
+
for (const [name, arr, n] of attrs) g.setAttribute(name, new THREE.Float32BufferAttribute(arr, n));
|
|
491
|
+
const m = new THREE.ShaderMaterial({ vertexShader: vert, fragmentShader: frag, transparent: true, depthWrite: false,
|
|
492
|
+
blending: THREE.AdditiveBlending, uniforms: { ...U, uMode: { value: mode } } });
|
|
493
|
+
const p = new THREE.Points(g, m);
|
|
494
|
+
p.frustumCulled = false;
|
|
495
|
+
root.add(p);
|
|
496
|
+
return p;
|
|
497
|
+
}
|
|
498
|
+
const rgb = k => new THREE.Color(col(k)).toArray();
|
|
499
|
+
const flat = (rows, a, b) => { const out = new Float32Array(rows.length * (b - a)); rows.forEach((r, i) => { for (let j = a; j < b; j++) out[i * (b - a) + j - a] = r[j]; }); return out; };
|
|
500
|
+
function rnd32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
|
501
|
+
|
|
502
|
+
// decorative shell: brain outline that turns into galactic dust and orbit rings
|
|
503
|
+
const nShell = DATA.shell.length / 3, rnd = rnd32(42), dust = new Float32Array(nShell * 3);
|
|
504
|
+
const gauss = () => Math.sqrt(-2 * Math.log(rnd() + 1e-9)) * Math.cos(6.2832 * rnd());
|
|
505
|
+
for (let i = 0; i < nShell; i++) {
|
|
506
|
+
let r, ang = rnd() * 6.2832, y;
|
|
507
|
+
if (rnd() < 0.3) { r = [0.78, 1.22, 1.66, 2.08][i % 4] + (rnd() - 0.5) * 0.012; y = (rnd() - 0.5) * 0.012; }
|
|
508
|
+
else { r = 0.3 + 1.9 * Math.sqrt(rnd()); y = gauss() * 0.045; if (rnd() < 0.6) ang = 6.2832 * Math.floor(rnd() * TP.length) / TP.length + 1.75 * r + gauss() * 0.3; }
|
|
509
|
+
dust.set([r * Math.cos(ang), y, r * Math.sin(ang)], i * 3);
|
|
510
|
+
}
|
|
511
|
+
const shellCloud = cloud([['position', DATA.shell, 3], ['pos2', dust, 3], ['color', new Float32Array(nShell * 3).map((_, i) => [0.55, 0.65, 0.95][i % 3]), 3],
|
|
512
|
+
['size', new Float32Array(nShell).fill(5.5), 1], ['alpha', new Float32Array(nShell).fill(0.5), 1],
|
|
513
|
+
['tn', new Float32Array(nShell).fill(-1), 1], ['shape', new Float32Array(nShell), 1]], 1);
|
|
514
|
+
|
|
515
|
+
const baseSize = P.map(p => 15 * p[7]);
|
|
516
|
+
const ptCloud = cloud([['position', flat(P, 0, 3), 3], ['pos2', flat(P, 3, 6), 3], ['color', P.flatMap(p => rgb(p[6])), 3],
|
|
517
|
+
['size', baseSize.slice(), 1], ['alpha', new Float32Array(P.length).fill(0.95), 1], ['tn', P.map(p => p[8]), 1], ['shape', P.map(p => p[9]), 1]], 0);
|
|
518
|
+
|
|
519
|
+
const SP = DATA.spine;
|
|
520
|
+
const spCloud = cloud([['position', flat(SP, 0, 3), 3], ['pos2', flat(SP, 3, 6), 3], ['color', SP.flatMap(s => rgb(s[6])), 3],
|
|
521
|
+
['size', new Float32Array(SP.length).fill(5), 1], ['alpha', new Float32Array(SP.length).fill(0.75), 1],
|
|
522
|
+
['tn', SP.map(s => s[7]), 1], ['shape', new Float32Array(SP.length), 1]], 2);
|
|
523
|
+
|
|
524
|
+
// lines: all connections ("synapses") and the selected item's connections
|
|
525
|
+
const lineVert = `attribute vec3 pos2; attribute float tn; uniform float uMorph; varying float vT;
|
|
526
|
+
void main() { vT = tn; gl_Position = projectionMatrix * modelViewMatrix * vec4(mix(position, pos2, uMorph), 1.0); }`;
|
|
527
|
+
const lineFrag = `uniform vec3 uColor; uniform float uOpacity; uniform float uCut; varying float vT;
|
|
528
|
+
void main() { if (vT > uCut) discard; gl_FragColor = vec4(uColor, uOpacity); }`;
|
|
529
|
+
function lines(pairs, color, opacity) {
|
|
530
|
+
const a = new Float32Array(pairs.length * 6), b = new Float32Array(pairs.length * 6), t = new Float32Array(pairs.length * 2);
|
|
531
|
+
pairs.forEach(([i, j], n) => {
|
|
532
|
+
a.set(P[i].slice(0, 3), n * 6); a.set(P[j].slice(0, 3), n * 6 + 3);
|
|
533
|
+
b.set(P[i].slice(3, 6), n * 6); b.set(P[j].slice(3, 6), n * 6 + 3);
|
|
534
|
+
t[n * 2] = t[n * 2 + 1] = Math.max(P[i][8], P[j][8]);
|
|
535
|
+
});
|
|
536
|
+
const g = new THREE.BufferGeometry();
|
|
537
|
+
g.setAttribute('position', new THREE.BufferAttribute(a, 3)); g.setAttribute('pos2', new THREE.BufferAttribute(b, 3));
|
|
538
|
+
g.setAttribute('tn', new THREE.BufferAttribute(t, 1));
|
|
539
|
+
const m = new THREE.ShaderMaterial({ vertexShader: lineVert, fragmentShader: lineFrag, transparent: true, depthWrite: false,
|
|
540
|
+
blending: THREE.AdditiveBlending, uniforms: { uMorph: U.uMorph, uCut: U.uCut, uColor: { value: new THREE.Color(color) }, uOpacity: { value: opacity } } });
|
|
541
|
+
const obj = new THREE.LineSegments(g, m);
|
|
542
|
+
obj.frustumCulled = false;
|
|
543
|
+
root.add(obj);
|
|
544
|
+
return obj;
|
|
545
|
+
}
|
|
546
|
+
const drop = obj => { if (obj) { root.remove(obj); obj.geometry.dispose(); obj.material.dispose(); } return null; };
|
|
547
|
+
let synLines = null, selLines = null;
|
|
548
|
+
function drawSynapses() {
|
|
549
|
+
synLines = drop(synLines);
|
|
550
|
+
if (S.syn) synLines = lines(E.filter(([a, b]) => vis[a] && vis[b]), '#9fb4ff', 0.13);
|
|
551
|
+
}
|
|
552
|
+
$('syn').onclick = () => { S.syn = !S.syn; $('syn').classList.toggle('on', S.syn); drawSynapses(); };
|
|
553
|
+
|
|
554
|
+
/* -------------------------------------------------------------- filters */
|
|
555
|
+
function refilter() {
|
|
556
|
+
let n = 0;
|
|
557
|
+
const a = ptCloud.geometry.attributes.alpha;
|
|
558
|
+
for (let i = 0; i < IT.length; i++) {
|
|
559
|
+
vis[i] = (S.focus < 0 || IT[i].k === S.focus) && !S.off.has(IT[i].s) && (!S.q || hay[i].includes(S.q)) ? 1 : 0;
|
|
560
|
+
a.array[i] = vis[i] ? 0.95 : 0.05;
|
|
561
|
+
n += vis[i];
|
|
562
|
+
}
|
|
563
|
+
a.needsUpdate = true;
|
|
564
|
+
const sa = spCloud.geometry.attributes.alpha;
|
|
565
|
+
SP.forEach((s, i) => { sa.array[i] = S.focus < 0 || s[6] === S.focus ? 0.75 : 0.04; });
|
|
566
|
+
sa.needsUpdate = true;
|
|
567
|
+
labels.forEach((l, k) => { l.el.style.opacity = S.focus < 0 || S.focus === k ? 1 : 0.15; });
|
|
568
|
+
$('subtitle').textContent = n === IT.length ? L.sub(fmt(N.items), N.first, N.last) : `${fmt(n)} / ${fmt(IT.length)}`;
|
|
569
|
+
drawLegend();
|
|
570
|
+
drawSynapses();
|
|
571
|
+
if (S.view === 'timeline') drawTimeline();
|
|
572
|
+
}
|
|
573
|
+
function recolor() {
|
|
574
|
+
const c = ptCloud.geometry.attributes.color; P.forEach((p, i) => c.array.set(rgb(p[6]), i * 3)); c.needsUpdate = true;
|
|
575
|
+
const sc = spCloud.geometry.attributes.color; SP.forEach((s, i) => sc.array.set(rgb(s[6]), i * 3)); sc.needsUpdate = true;
|
|
576
|
+
labels.forEach((l, k) => { l.el.style.color = col(k); });
|
|
577
|
+
drawLegend(); select(S.sel); views[S.view]();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* --------------------------------------------------------------- labels */
|
|
581
|
+
const centre = (k, o) => { const c = new THREE.Vector3(); let n = 0; P.forEach(p => { if (p[6] === k) { c.x += p[o]; c.y += p[o + 1]; c.z += p[o + 2]; n++; } }); return c.divideScalar(Math.max(1, n)); };
|
|
582
|
+
const labels = TP.map(t => {
|
|
583
|
+
const el = document.createElement('div');
|
|
584
|
+
el.className = 'label3d'; el.style.color = col(t.id); el.textContent = t.name;
|
|
585
|
+
$('labels').appendChild(el);
|
|
586
|
+
return { el, a: centre(t.id, 0), b: centre(t.id, 3) };
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
/* ------------------------------------------------------- camera, picking */
|
|
590
|
+
let yaw = -1.25, pitch = 0.18, dist = 5.0, pitchTo = 0.18, distTo = 5.0, gliding = false;
|
|
591
|
+
let autoRotate = true, dragging = false, moved = 0, lastX = 0, lastY = 0, pinch = 0, idle = 0;
|
|
592
|
+
const el = renderer.domElement, mouse = { x: -9, y: -9 }, v = new THREE.Vector3();
|
|
593
|
+
const zoom = f => { dist = Math.max(2.2, Math.min(14, dist * f)); gliding = false; };
|
|
594
|
+
el.addEventListener('pointerdown', e => { dragging = true; moved = 0; autoRotate = false; gliding = false; lastX = e.clientX; lastY = e.clientY; clearTimeout(idle); });
|
|
595
|
+
addEventListener('pointerup', () => { if (dragging) { dragging = false; idle = setTimeout(() => { if (S.sel < 0) autoRotate = true; }, 5000); } });
|
|
596
|
+
addEventListener('pointermove', e => {
|
|
597
|
+
if (dragging) {
|
|
598
|
+
yaw += (e.clientX - lastX) * 0.006;
|
|
599
|
+
pitch = Math.max(-1.3, Math.min(1.3, pitch + (e.clientY - lastY) * 0.004));
|
|
600
|
+
moved += Math.abs(e.clientX - lastX) + Math.abs(e.clientY - lastY);
|
|
601
|
+
lastX = e.clientX; lastY = e.clientY;
|
|
602
|
+
}
|
|
603
|
+
mouse.x = e.clientX; mouse.y = e.clientY;
|
|
604
|
+
});
|
|
605
|
+
el.addEventListener('click', e => { if (moved < 6) select(pick(e.clientX, e.clientY)); });
|
|
606
|
+
el.addEventListener('wheel', e => { e.preventDefault(); zoom(1 + e.deltaY * 0.001); }, { passive: false });
|
|
607
|
+
el.addEventListener('touchmove', e => {
|
|
608
|
+
if (e.touches.length === 2) {
|
|
609
|
+
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
|
610
|
+
if (pinch) zoom(pinch / d);
|
|
611
|
+
pinch = d;
|
|
612
|
+
}
|
|
613
|
+
}, { passive: true });
|
|
614
|
+
el.addEventListener('touchend', () => { pinch = 0; });
|
|
615
|
+
|
|
616
|
+
function place(i, out) {
|
|
617
|
+
const p = P[i], m = S.morph;
|
|
618
|
+
return out.set(p[0] + (p[3] - p[0]) * m, p[1] + (p[4] - p[1]) * m, p[2] + (p[5] - p[2]) * m).add(root.position);
|
|
619
|
+
}
|
|
620
|
+
function screen(vec) { vec.project(camera); return { x: (vec.x * 0.5 + 0.5) * innerWidth, y: (-vec.y * 0.5 + 0.5) * innerHeight, z: vec.z }; }
|
|
621
|
+
function pick(mx, my) {
|
|
622
|
+
let best = -1, bd = touch ? 26 : 13;
|
|
623
|
+
for (let i = 0; i < P.length; i++) {
|
|
624
|
+
if (!vis[i] || P[i][8] > S.cut) continue;
|
|
625
|
+
const s = screen(place(i, v));
|
|
626
|
+
if (s.z > 1) continue;
|
|
627
|
+
const d = Math.hypot(s.x - mx, s.y - my);
|
|
628
|
+
if (d < bd) { bd = d; best = i; }
|
|
629
|
+
}
|
|
630
|
+
return best;
|
|
631
|
+
}
|
|
632
|
+
const tip = $('tip');
|
|
633
|
+
let hovered = -1;
|
|
634
|
+
function grow(i, f) { if (i >= 0) { const a = ptCloud.geometry.attributes.size; a.array[i] = baseSize[i] * f; a.needsUpdate = true; } }
|
|
635
|
+
function hover() {
|
|
636
|
+
const i = dragging ? -1 : pick(mouse.x, mouse.y);
|
|
637
|
+
if (i !== hovered) { grow(hovered, hovered === S.sel ? 2.6 : 1); hovered = i; grow(i, 2.4); }
|
|
638
|
+
el.style.cursor = i >= 0 ? 'pointer' : 'grab';
|
|
639
|
+
if (i < 0) { tip.style.display = 'none'; return; }
|
|
640
|
+
tip.innerHTML = `<b>${itemTitle(i)}</b><span style="color:${col(IT[i].k)}">● ${esc(TP[IT[i].k].name)}</span><br>
|
|
641
|
+
<span class="muted">${esc(srcName(IT[i].s))} · ${itemDate(i)}</span>`;
|
|
642
|
+
tip.style.display = 'block';
|
|
643
|
+
tip.style.left = Math.min(innerWidth - 270, mouse.x + 14) + 'px';
|
|
644
|
+
tip.style.top = Math.min(innerHeight - 80, mouse.y + 14) + 'px';
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/* --------------------------------------------------------------- detail */
|
|
648
|
+
function select(i) {
|
|
649
|
+
grow(S.sel, 1);
|
|
650
|
+
S.sel = i;
|
|
651
|
+
selLines = drop(selLines);
|
|
652
|
+
const box = $('detail');
|
|
653
|
+
if (i < 0) { box.classList.remove('on'); return; }
|
|
654
|
+
grow(i, 2.6);
|
|
655
|
+
autoRotate = false;
|
|
656
|
+
const it = IT[i], rel = adj[i].slice(0, 8);
|
|
657
|
+
if (rel.length) selLines = lines(rel.map(r => [i, r.j]), col(it.k), 0.85);
|
|
658
|
+
const stats = [`${fmt(it.w)} ${L.words}`];
|
|
659
|
+
if (kindOf(i) === 'chat') stats.push(`${it.n} ${L.msgs}`);
|
|
660
|
+
if (kindOf(i) === 'message') stats.push(`${it.n} ${L.sent}`);
|
|
661
|
+
box.innerHTML = `<button class="close" aria-label="close">×</button>
|
|
662
|
+
<span class="chip static"><span class="shape ${kindOf(i)}"></span>${esc(srcName(it.s))} · ${L.kinds[P[i][9]]}</span>
|
|
663
|
+
<h2>${itemTitle(i)}</h2>
|
|
664
|
+
<div class="meta">${itemDate(i)} · ${stats.join(' · ')}</div>
|
|
665
|
+
<div class="chips" style="margin:10px 0"><span class="chip" id="dTopic">${dotHtml(it.k)}${esc(TP[it.k].name)}</span>
|
|
666
|
+
${(it.tm || []).map(t => `<span class="chip static">${esc(t)}</span>`).join('')}</div>
|
|
667
|
+
${it.x ? `<blockquote>${esc(it.x)}${it.x.length >= 180 ? '…' : ''}</blockquote>` : ''}
|
|
668
|
+
${DATA.meta.private ? `<p class="muted">${L.privateNote}</p>` : ''}
|
|
669
|
+
<h3 style="margin-top:14px">${L.related}</h3>
|
|
670
|
+
${rel.length ? rel.map(r => `<div class="rel" data-j="${r.j}">${dotHtml(IT[r.j].k)}<span>${itemTitle(r.j)}<br><small>${esc(srcName(IT[r.j].s))} · ${itemDate(r.j)}</small></span>
|
|
671
|
+
<small>${r.kind ? L.linked : Math.round(r.w * 100) + '% ' + L.similar}</small></div>`).join('') : `<p class="muted">${L.none}</p>`}`;
|
|
672
|
+
box.classList.add('on');
|
|
673
|
+
box.querySelector('.close').onclick = () => select(-1);
|
|
674
|
+
box.querySelector('#dTopic').onclick = () => { S.focus = it.k; refilter(); };
|
|
675
|
+
box.querySelectorAll('.rel').forEach(r => r.onclick = () => select(+r.dataset.j));
|
|
676
|
+
}
|
|
677
|
+
function openItem(i) { show('brain'); if (!vis[i]) { S.focus = -1; S.q = ''; $('q').value = ''; S.off.clear(); refilter(); } select(i); }
|
|
678
|
+
|
|
679
|
+
/* ---------------------------------------------------- layout, time-lapse */
|
|
680
|
+
document.querySelectorAll('#layout button').forEach(b => b.onclick = () => setLayout(+b.dataset.m));
|
|
681
|
+
const camDist = m => narrow() ? 7.4 + 4.6 * m : 5.0 + 1.9 * m;
|
|
682
|
+
function setLayout(m) {
|
|
683
|
+
S.morphTo = m;
|
|
684
|
+
document.querySelectorAll('#layout button').forEach(b => b.classList.toggle('on', +b.dataset.m === m));
|
|
685
|
+
pitchTo = m ? 0.62 : 0.18; distTo = camDist(m); gliding = true;
|
|
686
|
+
}
|
|
687
|
+
const tns = P.map(p => p[8]).sort((a, b) => a - b);
|
|
688
|
+
const t0 = Date.parse(N.first), t1 = Date.parse(N.last);
|
|
689
|
+
function setCut(c) {
|
|
690
|
+
S.cut = Math.max(0, Math.min(1, c));
|
|
691
|
+
U.uCut.value = S.cut >= 1 ? 1 : S.cut;
|
|
692
|
+
$('cut').value = Math.round(S.cut * 1000);
|
|
693
|
+
let lo = 0, hi = tns.length;
|
|
694
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; tns[mid] <= S.cut ? lo = mid + 1 : hi = mid; }
|
|
695
|
+
const d = new Date(t0 + (t1 - t0) * S.cut);
|
|
696
|
+
$('cutLabel').textContent = S.cut >= 1 ? `${fmt(IT.length)} · ${monthName(N.last.slice(0, 7))}` : `${fmt(lo)} · ${L.months[d.getMonth()]} ${d.getFullYear()}`;
|
|
697
|
+
}
|
|
698
|
+
$('cut').addEventListener('input', e => { S.playing = false; $('play').textContent = '▶'; setCut(e.target.value / 1000); });
|
|
699
|
+
$('play').onclick = () => { S.playing = !S.playing; $('play').textContent = S.playing ? '❚❚' : '▶'; if (S.playing && S.cut >= 1) setCut(0); };
|
|
700
|
+
|
|
701
|
+
/* --------------------------------------------------------------- render */
|
|
702
|
+
function resize() {
|
|
703
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
704
|
+
camera.aspect = innerWidth / innerHeight;
|
|
705
|
+
camera.updateProjectionMatrix();
|
|
706
|
+
U.uScale.value = 4.6 * innerHeight * renderer.getPixelRatio() / 900;
|
|
707
|
+
}
|
|
708
|
+
addEventListener('resize', resize);
|
|
709
|
+
resize();
|
|
710
|
+
dist = distTo = camDist(0);
|
|
711
|
+
|
|
712
|
+
const clock = new THREE.Clock();
|
|
713
|
+
let prev = 0;
|
|
714
|
+
function frame() {
|
|
715
|
+
requestAnimationFrame(frame);
|
|
716
|
+
const t = clock.getElapsedTime(), dt = Math.min(0.1, t - prev);
|
|
717
|
+
prev = t;
|
|
718
|
+
if (S.view !== 'brain') return;
|
|
719
|
+
if (autoRotate) yaw += 0.0025;
|
|
720
|
+
if (S.playing) { setCut(S.cut + dt / 14); if (S.cut >= 1) { S.playing = false; $('play').textContent = '▶'; } }
|
|
721
|
+
S.morph += (S.morphTo - S.morph) * Math.min(1, dt * 3.2);
|
|
722
|
+
if (Math.abs(S.morphTo - S.morph) < 0.0005) S.morph = S.morphTo;
|
|
723
|
+
if (gliding) { pitch += (pitchTo - pitch) * 0.05; dist += (distTo - dist) * 0.05; if (Math.abs(distTo - dist) < 0.01) gliding = false; }
|
|
724
|
+
U.uMorph.value = S.morph; U.uTime.value = t;
|
|
725
|
+
root.position.y = 0.32 * (1 - S.morph);
|
|
726
|
+
camera.position.set(Math.sin(yaw) * Math.cos(pitch) * dist, Math.sin(pitch) * dist, Math.cos(yaw) * Math.cos(pitch) * dist);
|
|
727
|
+
camera.lookAt(0, 0, 0);
|
|
728
|
+
labels.forEach(l => { const s = screen(v.copy(l.a).lerp(l.b, S.morph).add(root.position)); l.el.style.left = s.x + 'px'; l.el.style.top = s.y + 'px'; });
|
|
729
|
+
if (!touch) hover();
|
|
730
|
+
renderer.render(scene, camera);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/* ------------------------------------------------------------- timeline */
|
|
734
|
+
let tlLimit = 300;
|
|
735
|
+
function drawTimeline() {
|
|
736
|
+
$('tlTitle').textContent = L.tlTitle; $('tlSub').textContent = L.tlSub;
|
|
737
|
+
const rows = order.filter(i => vis[i]);
|
|
738
|
+
const key = i => IT[i].u ? '' : IT[i].d.slice(0, 7);
|
|
739
|
+
const counts = new Map();
|
|
740
|
+
rows.forEach(i => counts.set(key(i), (counts.get(key(i)) || 0) + 1));
|
|
741
|
+
const max = Math.max(1, ...counts.values());
|
|
742
|
+
$('tlMonths').innerHTML = [...counts].map(([m, n]) => `<div class="mo" data-m="${m}"><span>${m ? monthName(m) : L.nodate}</span><span class="n">${n}</span><i style="width:${n / max * 100}%"></i></div>`).join('');
|
|
743
|
+
let html = '', last = null;
|
|
744
|
+
rows.slice(0, tlLimit).forEach(i => {
|
|
745
|
+
if (key(i) !== last) { last = key(i); html += `<div class="tlHead" id="m-${last}">${last ? monthName(last) : L.nodate} · ${counts.get(last)}</div>`; }
|
|
746
|
+
html += `<div class="row" data-i="${i}">${dotHtml(IT[i].k)}<span class="ttl">${itemTitle(i)} <span class="muted">· ${esc(TP[IT[i].k].name)}</span></span>
|
|
747
|
+
<span class="chip static src"><span class="shape ${kindOf(i)}"></span>${esc(srcName(IT[i].s))}</span><span class="n">${itemDate(i)}</span></div>`;
|
|
748
|
+
});
|
|
749
|
+
if (!rows.length) html = `<p class="muted">${L.noMatch}</p>`;
|
|
750
|
+
if (rows.length > tlLimit) html += `<button class="more">${L.showMore} (${fmt(rows.length - tlLimit)})</button>`;
|
|
751
|
+
$('tlList').innerHTML = html;
|
|
752
|
+
$('tlList').querySelectorAll('.row').forEach(r => r.onclick = () => openItem(+r.dataset.i));
|
|
753
|
+
const more = $('tlList').querySelector('.more');
|
|
754
|
+
if (more) more.onclick = () => { tlLimit += 600; drawTimeline(); };
|
|
755
|
+
$('tlMonths').querySelectorAll('.mo').forEach(m => m.onclick = () => {
|
|
756
|
+
const idx = rows.findIndex(i => key(i) === m.dataset.m);
|
|
757
|
+
if (idx >= tlLimit) { tlLimit = idx + 100; drawTimeline(); }
|
|
758
|
+
const head = document.getElementById('m-' + m.dataset.m);
|
|
759
|
+
if (head) head.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/* ---------------------------------------------------------------- waves */
|
|
764
|
+
$('wvSmoothL').textContent = L.smooth; $('wvScaleL').textContent = L.scale;
|
|
765
|
+
$('wvScale').innerHTML = `<option value="shared">${L.shared}</option><option value="own">${L.own}</option>`;
|
|
766
|
+
$('wvSmooth').oninput = $('wvScale').onchange = drawWaves;
|
|
767
|
+
function drawWaves() {
|
|
768
|
+
$('wvTitle').textContent = L.wvTitle; $('wvSub').textContent = L.wvSub;
|
|
769
|
+
const svg = $('wavesSvg'), W = svg.clientWidth || 1000, rowH = 46, nar = narrow();
|
|
770
|
+
const left = nar ? 8 : 190, top = 30, H = top + TP.length * rowH + 60, M = DATA.months;
|
|
771
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.setAttribute('height', H);
|
|
772
|
+
const passes = +$('wvSmooth').value, own = $('wvScale').value === 'own';
|
|
773
|
+
const series = DATA.waves.map(raw => { let s = raw.slice(); for (let p = 0; p < passes; p++) s = s.map((_, i) => (s[i - 1] ?? s[i]) * 0.25 + s[i] * 0.5 + (s[i + 1] ?? s[i]) * 0.25); return s; });
|
|
774
|
+
const gmax = Math.max(1, ...series.flat());
|
|
775
|
+
const x = i => left + (W - left - 34) * (M.length === 1 ? 0.5 : i / (M.length - 1));
|
|
776
|
+
let out = '';
|
|
777
|
+
TP.forEach((t, k) => {
|
|
778
|
+
const base = top + (k + 1) * rowH, s = series[k], max = own ? Math.max(1, ...s) : gmax;
|
|
779
|
+
const y = i => base - (s[i] / max) * rowH * (own ? 0.9 : 1.9);
|
|
780
|
+
let d = `M ${x(0)} ${y(0)}`;
|
|
781
|
+
for (let i = 1; i < M.length; i++) { const cx = (x(i - 1) + x(i)) / 2; d += ` C ${cx} ${y(i - 1)}, ${cx} ${y(i)}, ${x(i)} ${y(i)}`; }
|
|
782
|
+
const c = col(t.id);
|
|
783
|
+
out += `<defs><linearGradient id="g${k}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity="0.55"/><stop offset="1" stop-color="${c}" stop-opacity="0.04"/></linearGradient></defs>
|
|
784
|
+
<path d="${d} L ${x(M.length - 1)} ${base} L ${x(0)} ${base} Z" fill="url(#g${k})"/><path d="${d}" fill="none" stroke="${c}" stroke-width="1.8"/>` +
|
|
785
|
+
(nar ? `<text x="${left + 2}" y="${base - 5}" fill="${c}" font-size="11" font-weight="600" paint-order="stroke" stroke="#000" stroke-width="3">${esc(t.name)} · ${t.count}</text>`
|
|
786
|
+
: `<text x="${left - 12}" y="${base - 4}" text-anchor="end" fill="${c}" font-size="12" font-weight="600">${esc(t.name)}</text>
|
|
787
|
+
<text x="${left - 12}" y="${base + 10}" text-anchor="end" fill="#8d8d96" font-size="10">${t.count}</text>`);
|
|
788
|
+
});
|
|
789
|
+
const every = Math.max(1, Math.ceil(M.length / (nar ? 4 : 9)));
|
|
790
|
+
M.forEach((m, i) => { if (i % every === 0) out += `<line x1="${x(i)}" x2="${x(i)}" y1="${top}" y2="${H - 44}" stroke="rgba(255,255,255,0.06)"/><text x="${x(i)}" y="${H - 26}" text-anchor="middle" fill="#8d8d96" font-size="11">${monthName(m)}</text>`; });
|
|
791
|
+
svg.innerHTML = out;
|
|
792
|
+
}
|
|
793
|
+
addEventListener('resize', () => { if (S.view === 'waves') drawWaves(); });
|
|
794
|
+
|
|
795
|
+
/* --------------------------------------------------------------- tables */
|
|
796
|
+
const wd = i => (new Date(IT[i].d + 'T12:00:00').getDay() + 6) % 7;
|
|
797
|
+
const DIMS = {
|
|
798
|
+
topic: { get: i => IT[i].k, keys: () => TP.map(t => t.id), name: k => TP[k].name, color: k => col(k) },
|
|
799
|
+
source: { get: i => IT[i].s, keys: () => N.sources.map(s => s.id), name: srcName },
|
|
800
|
+
kind: { get: i => P[i][9], keys: () => [0, 1, 2].filter(k => P.some(p => p[9] === k)), name: k => L.kindsPl[k] },
|
|
801
|
+
year: { get: i => IT[i].d.slice(0, 4), time: true, name: k => k },
|
|
802
|
+
month: { get: i => IT[i].d.slice(0, 7), time: true, name: monthName },
|
|
803
|
+
weekday: { get: wd, time: true, keys: () => [0, 1, 2, 3, 4, 5, 6], name: k => L.weekdays[k] },
|
|
804
|
+
daypart: { get: i => Math.min(3, Math.floor(IT[i].h / 6)), time: true, keys: () => [0, 1, 2, 3], name: k => L.dayparts[k] },
|
|
805
|
+
};
|
|
806
|
+
const MEAS = { items: () => 1, words: i => IT[i].w, writes: i => IT[i].p };
|
|
807
|
+
$('tbRowsL').textContent = L.rows; $('tbColsL').textContent = L.cols; $('tbMeasL').textContent = L.measure;
|
|
808
|
+
const dimOpts = Object.keys(DIMS).map(d => `<option value="${d}">${L.dims[d]}</option>`).join('');
|
|
809
|
+
$('tbRows').innerHTML = dimOpts; $('tbCols').innerHTML = dimOpts; $('tbCols').value = 'source';
|
|
810
|
+
$('tbMeas').innerHTML = Object.keys(MEAS).map(m => `<option value="${m}">${L.meas[m]}</option>`).join('');
|
|
811
|
+
$('tbRows').onchange = $('tbCols').onchange = $('tbMeas').onchange = drawTable;
|
|
812
|
+
function drawTable() {
|
|
813
|
+
$('tbTitle').textContent = L.tbTitle; $('tbSub').textContent = L.tbSub;
|
|
814
|
+
const r = DIMS[$('tbRows').value], c = DIMS[$('tbCols').value], m = MEAS[$('tbMeas').value];
|
|
815
|
+
const cells = new Map(), rk = new Set(), ck = new Set();
|
|
816
|
+
for (let i = 0; i < IT.length; i++) {
|
|
817
|
+
if (!vis[i] || (IT[i].u && (r.time || c.time))) continue;
|
|
818
|
+
const a = r.get(i), b = c.get(i);
|
|
819
|
+
rk.add(a); ck.add(b);
|
|
820
|
+
cells.set(a + '|' + b, (cells.get(a + '|' + b) || 0) + m(i));
|
|
821
|
+
}
|
|
822
|
+
const keys = (d, seen) => (d.keys ? d.keys().filter(k => seen.has(k)) : [...seen].sort());
|
|
823
|
+
const rows = keys(r, rk), cols = keys(c, ck), val = (a, b) => cells.get(a + '|' + b) || 0;
|
|
824
|
+
const max = Math.max(1, ...cells.values());
|
|
825
|
+
const cell = n => `<td style="background:rgba(167,123,255,${n ? (0.08 + 0.72 * Math.sqrt(n / max)).toFixed(2) : 0.03})">${n ? fmt(n) : ''}</td>`;
|
|
826
|
+
const sum = arr => arr.reduce((x, y) => x + y, 0);
|
|
827
|
+
$('tbOut').innerHTML = rows.length ? `<table class="pv"><thead><tr><th></th>${cols.map(b => `<th>${esc(c.name(b))}</th>`).join('')}<th>${L.total}</th></tr></thead><tbody>
|
|
828
|
+
${rows.map(a => `<tr><th style="${r.color ? 'color:' + r.color(a) : ''}">${esc(r.name(a))}</th>${cols.map(b => cell(val(a, b))).join('')}<td class="tot">${fmt(sum(cols.map(b => val(a, b))))}</td></tr>`).join('')}
|
|
829
|
+
<tr><th>${L.total}</th>${cols.map(b => `<td class="tot">${fmt(sum(rows.map(a => val(a, b))))}</td>`).join('')}<td class="tot">${fmt(sum([...cells.values()]))}</td></tr></tbody></table>` : `<p class="muted">${L.noMatch}</p>`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* --------------------------------------------------------------- review */
|
|
833
|
+
function contextCard() {
|
|
834
|
+
const names = list => list.map(r => TP[r.k].name).join(', ');
|
|
835
|
+
const out = [L.ctxHead(N.first, N.last), '', L.ctxTopics];
|
|
836
|
+
TP.slice(0, 8).forEach(t => out.push(`- ${t.name} (${Math.round(t.share * 100)}%)${t.terms.length ? ': ' + t.terms.join(', ') : ''}`));
|
|
837
|
+
if (R.rising.length) out.push('', `${L.ctxNow} ${names(R.rising)}.`);
|
|
838
|
+
if (R.dormant.length) out.push(`${L.ctxDrop} ${names(R.dormant)}.`);
|
|
839
|
+
out.push(L.ctxHabit(L.weekdaysLong[N.busiest_weekday], N.busiest_hour), '', L.ctxEnd);
|
|
840
|
+
return out.join('\n');
|
|
841
|
+
}
|
|
842
|
+
function drawReview() {
|
|
843
|
+
$('rvTitle').textContent = L.rvTitle; $('rvSub').textContent = L.rvSub(N.last);
|
|
844
|
+
const tname = k => `<b style="color:${col(k)}">${esc(TP[k].name)}</b>`;
|
|
845
|
+
const go = i => `<span class="go" data-i="${i}">${itemTitle(i)}</span>`;
|
|
846
|
+
const card = (title, body, cls = '') => `<div class="card panel ${cls}"><h3>${title}</h3>${body || `<p class="muted">${L.nothing}</p>`}</div>`;
|
|
847
|
+
const trend = list => list.map(r => `<div class="line">${dotHtml(r.k)}${tname(r.k)}<span class="arrow">${r.before} → ${r.now}</span></div>`).join('');
|
|
848
|
+
const itemLine = i => `<div class="line">${dotHtml(IT[i].k)}<span>${go(i)} <span class="muted">· ${esc(srcName(IT[i].s))} · ${itemDate(i)}</span></span></div>`;
|
|
849
|
+
let h = '';
|
|
850
|
+
h += card(`${L.rising} <span class="muted" style="text-transform:none;letter-spacing:0">· ${L.vs(R.window)}</span>`, trend(R.rising));
|
|
851
|
+
h += card(`${L.fading} <span class="muted" style="text-transform:none;letter-spacing:0">· ${L.vs(R.window)}</span>`, trend(R.fading));
|
|
852
|
+
h += card(L.dormant, R.dormant.map(r => `<div class="line">${dotHtml(r.k)}<span>${tname(r.k)} <span class="muted">· ${L.dormantRow(r.last, r.days)}</span></span></div>`).join(''));
|
|
853
|
+
h += card(L.newest, R.newest ? `<div class="line">${dotHtml(R.newest.k)}<span>${tname(R.newest.k)} <span class="muted">· ${L.since(monthName(R.newest.since))}</span></span></div>` : '');
|
|
854
|
+
h += card(L.bridges, R.bridges.map(b => `<div class="line"><span>${tname(b.a)} ↔ ${tname(b.b)} <span class="muted">· ${L.bridgeRow(b.n)}</span><br><span class="muted">${b.ex.map(go).join(' · ')}</span></span></div>`).join(''));
|
|
855
|
+
h += card(L.cross, R.cross.length ? `<p class="muted">${L.crossSub(fmt(R.cross_total))}</p>` + R.cross.map(([a, b]) => `<div class="line"><span>${go(a)} <span class="muted">(${esc(srcName(IT[a].s))})</span> ↔ ${go(b)} <span class="muted">(${esc(srcName(IT[b].s))})</span></span></div>`).join('') : '');
|
|
856
|
+
h += card(L.deep, R.deep.map(i => `<div class="line">${dotHtml(IT[i].k)}<span>${go(i)} <span class="muted">· ${fmt(IT[i].w)} ${L.words}</span></span></div>`).join(''));
|
|
857
|
+
h += card(L.yearAgo, R.year_ago.map(itemLine).join(''));
|
|
858
|
+
h += card(L.ctx, `<p class="muted">${L.ctxSub}</p><pre id="ctx">${esc(contextCard())}</pre><button class="btn" id="copy">${L.copy}</button>`, 'full');
|
|
859
|
+
$('rvCards').innerHTML = h;
|
|
860
|
+
$('rvCards').querySelectorAll('.go').forEach(g => g.onclick = () => openItem(+g.dataset.i));
|
|
861
|
+
$('copy').onclick = async () => {
|
|
862
|
+
const text = $('ctx').textContent;
|
|
863
|
+
try { await navigator.clipboard.writeText(text); } catch { const r = document.createRange(); r.selectNodeContents($('ctx')); getSelection().removeAllRanges(); getSelection().addRange(r); document.execCommand('copy'); }
|
|
864
|
+
$('copy').textContent = L.copied;
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/* ----------------------------------------------------------- share card */
|
|
869
|
+
// Only aggregate fields enter the card. Never draw item titles, text or terms.
|
|
870
|
+
function yearCardStats() {
|
|
871
|
+
let year = '';
|
|
872
|
+
IT.forEach(it => { if (!it.u && it.d && it.d.slice(0, 4) > year) year = it.d.slice(0, 4); });
|
|
873
|
+
const allTime = !year || IT.filter(it => !it.u && it.d.slice(0, 4) === year).length < 20;
|
|
874
|
+
const months = new Map(), sources = new Map(), topics = new Map(), days = new Set(), hours = Array(24).fill(0);
|
|
875
|
+
let items = 0, words = 0, chatWords = 0;
|
|
876
|
+
IT.forEach((it, i) => {
|
|
877
|
+
if (!allTime && (it.u || it.d.slice(0, 4) !== year)) return;
|
|
878
|
+
items++; words += it.w;
|
|
879
|
+
if (kindOf(i) === 'chat') chatWords += it.w;
|
|
880
|
+
sources.set(it.s, (sources.get(it.s) || 0) + 1);
|
|
881
|
+
topics.set(it.k, (topics.get(it.k) || 0) + 1);
|
|
882
|
+
if (!it.u && it.d) {
|
|
883
|
+
const month = it.d.slice(0, 7);
|
|
884
|
+
months.set(month, (months.get(month) || 0) + 1);
|
|
885
|
+
// UTC arithmetic on calendar dates avoids daylight-saving discontinuities.
|
|
886
|
+
days.add(Date.parse(it.d + 'T00:00:00Z') / 86400000);
|
|
887
|
+
if (Number.isInteger(it.h) && it.h >= 0 && it.h < 24) hours[it.h]++;
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
let streak = 0, run = 0, prev = -Infinity;
|
|
891
|
+
[...days].sort((a, b) => a - b).forEach(day => { run = day === prev + 1 ? run + 1 : 1; streak = Math.max(streak, run); prev = day; });
|
|
892
|
+
// Ties use the earliest month/hour and source/topic identifier for stability.
|
|
893
|
+
const ranked = map => [...map].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
|
|
894
|
+
const first = IT.reduce((y, it) => (!it.u && it.d && (!y || it.d.slice(0, 4) < y) ? it.d.slice(0, 4) : y), '');
|
|
895
|
+
return { year, first, allTime, items, words, chatWords, streak, month: ranked(months)[0],
|
|
896
|
+
hour: hours.some(Boolean) ? hours.indexOf(Math.max(...hours)) : null,
|
|
897
|
+
sources: ranked(sources).slice(0, 5), topics: ranked(topics).slice(0, 5) };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const shareDialog = $('shareCard'), shareCanvas = $('yearCard');
|
|
901
|
+
const shareBackground = document.createElement('canvas');
|
|
902
|
+
shareBackground.width = 1080; shareBackground.height = 1350;
|
|
903
|
+
let shareStats, cardReady = false;
|
|
904
|
+
$('shareCardOpen').textContent = L.cardOpen;
|
|
905
|
+
$('shareCardTitle').textContent = L.cardTitle;
|
|
906
|
+
$('shareCardNote').textContent = L.cardNote;
|
|
907
|
+
$('showTopicNamesLabel').textContent = L.cardTopics;
|
|
908
|
+
$('downloadCard').textContent = L.cardDownload;
|
|
909
|
+
$('copyCard').textContent = L.cardCopy;
|
|
910
|
+
$('closeCard').textContent = L.cardClose;
|
|
911
|
+
$('copyCard').hidden = !(navigator.clipboard?.write && window.ClipboardItem);
|
|
912
|
+
$('showTopicNames').checked = !DATA.meta.private;
|
|
913
|
+
shareCanvas.setAttribute('aria-label', L.cardTitle);
|
|
914
|
+
|
|
915
|
+
function cardButtons(ready) {
|
|
916
|
+
$('downloadCard').disabled = $('copyCard').disabled = $('showTopicNames').disabled = !ready;
|
|
917
|
+
}
|
|
918
|
+
function drawYearCard() {
|
|
919
|
+
const ctx = shareCanvas.getContext('2d'), s = shareStats;
|
|
920
|
+
ctx.clearRect(0, 0, 1080, 1350);
|
|
921
|
+
ctx.drawImage(shareBackground, 0, 0);
|
|
922
|
+
ctx.fillStyle = 'rgba(0,0,0,0.64)'; ctx.fillRect(0, 0, 1080, 1350);
|
|
923
|
+
const shade = ctx.createLinearGradient(0, 300, 0, 1350);
|
|
924
|
+
shade.addColorStop(0, 'rgba(0,0,0,0)'); shade.addColorStop(1, 'rgba(0,0,0,0.85)');
|
|
925
|
+
ctx.fillStyle = shade; ctx.fillRect(0, 0, 1080, 1350);
|
|
926
|
+
const gradient = width => {
|
|
927
|
+
const g = ctx.createLinearGradient(72, 0, 72 + width, 0);
|
|
928
|
+
['#6f8cff', '#a77bff', '#ff6ad5', '#ff5d7a', '#ff8a3c', '#ffb62e'].forEach((c, i) => g.addColorStop([0, .22, .45, .62, .82, 1][i], c));
|
|
929
|
+
return g;
|
|
930
|
+
};
|
|
931
|
+
const text = (value, x, y, size, color = '#f4f4f6', width = 936, weight = 500, align = 'left') => {
|
|
932
|
+
ctx.font = `${weight} ${size}px Inter`;
|
|
933
|
+
let str = String(value);
|
|
934
|
+
// Truncate long names without squeezing the type or overlapping counts.
|
|
935
|
+
if (ctx.measureText(str).width > width) {
|
|
936
|
+
while (str.length && ctx.measureText(str + '…').width > width) str = str.slice(0, -1);
|
|
937
|
+
str += '…';
|
|
938
|
+
}
|
|
939
|
+
ctx.fillStyle = color; ctx.textAlign = align; ctx.fillText(str, x, y);
|
|
940
|
+
};
|
|
941
|
+
ctx.font = '650 64px Inter';
|
|
942
|
+
text(L.cardTitle, 72, 124, 64, gradient(ctx.measureText(L.cardTitle).width), 936, 650);
|
|
943
|
+
// a year with little in it: the card covers all time, and says so
|
|
944
|
+
const span = s.allTime && s.first && s.first !== s.year ? `${s.first}–${s.year}` : s.year;
|
|
945
|
+
text(span || L.cardAllTime, 72, 244, span ? 112 : 72, '#f4f4f6', 936, 650);
|
|
946
|
+
if (s.allTime && span) text(L.cardAllTime, 76, 290, 24, '#c5c5d0');
|
|
947
|
+
const metric = (label, value, x, y, width, size = 64) => {
|
|
948
|
+
text(value, x, y, size, '#f4f4f6', width, 600);
|
|
949
|
+
text(label, x, y + 36, 21, '#b5b5c1', width);
|
|
950
|
+
};
|
|
951
|
+
metric(L.items, fmt(s.items), 72, 394, 420);
|
|
952
|
+
metric(L.wordsYou, fmt(s.words), 552, 394, 456);
|
|
953
|
+
metric(L.cardChatWords, fmt(s.chatWords), 72, 532, 936, 56);
|
|
954
|
+
metric(L.busiestMonth, s.month ? monthName(s.month[0]) : '—', 72, 654, 300, 32);
|
|
955
|
+
metric(L.streak, `${fmt(s.streak)} ${L.days}`, 396, 654, 288, 29);
|
|
956
|
+
metric(L.cardHour, s.hour === null ? '—' : `${String(s.hour).padStart(2, '0')}:00`, 732, 654, 276, 32);
|
|
957
|
+
text(L.cardSources, 72, 780, 22, '#b5b5c1', 390);
|
|
958
|
+
text(L.cardTopTopics, 552, 780, 19, '#b5b5c1', 456);
|
|
959
|
+
s.sources.forEach(([id, count], i) => {
|
|
960
|
+
const y = 842 + i * 72;
|
|
961
|
+
text(srcName(id), 72, y, 25, '#f4f4f6', 270);
|
|
962
|
+
text(fmt(count), 456, y, 25, '#c5c5d0', 100, 500, 'right');
|
|
963
|
+
});
|
|
964
|
+
s.topics.forEach(([k, count], i) => {
|
|
965
|
+
const y = 842 + i * 72;
|
|
966
|
+
text($('showTopicNames').checked ? TP[k].name : L.cardTopic(i + 1), 552, y, 25, col(k), 340);
|
|
967
|
+
text(`${Math.round(count / Math.max(1, s.items) * 100)}%`, 1008, y, 24, '#c5c5d0', 100, 500, 'right');
|
|
968
|
+
ctx.fillStyle = col(k); ctx.fillRect(552, y + 16, 456 * count / Math.max(1, s.items), 3);
|
|
969
|
+
});
|
|
970
|
+
ctx.fillStyle = gradient(936); ctx.fillRect(72, 1220, 936, 2);
|
|
971
|
+
text('brainplug.app', 72, 1284, 30, '#f4f4f6', 936, 600);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
$('shareCardOpen').onclick = async () => {
|
|
975
|
+
if (shareDialog.open) return;
|
|
976
|
+
cardReady = false; cardButtons(false); $('cardStatus').textContent = '';
|
|
977
|
+
shareCanvas.getContext('2d').clearRect(0, 0, 1080, 1350);
|
|
978
|
+
shareDialog.showModal();
|
|
979
|
+
try {
|
|
980
|
+
await document.fonts.load('600 64px Inter');
|
|
981
|
+
shareStats = yearCardStats();
|
|
982
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
983
|
+
// Render and read in this same frame, even when a different tab is open.
|
|
984
|
+
renderer.render(scene, camera);
|
|
985
|
+
const ctx = shareBackground.getContext('2d'), source = renderer.domElement;
|
|
986
|
+
const scale = Math.max(1080 / source.width, 1350 / source.height);
|
|
987
|
+
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 1080, 1350);
|
|
988
|
+
ctx.drawImage(source, (1080 - source.width * scale) / 2, (1350 - source.height * scale) / 2, source.width * scale, source.height * scale);
|
|
989
|
+
drawYearCard(); cardReady = true; cardButtons(true);
|
|
990
|
+
} catch { $('cardStatus').textContent = L.cardError; }
|
|
991
|
+
};
|
|
992
|
+
$('closeCard').onclick = () => shareDialog.close();
|
|
993
|
+
$('showTopicNames').onchange = () => { if (cardReady) { drawYearCard(); $('cardStatus').textContent = ''; } };
|
|
994
|
+
const cardBlob = () => new Promise((resolve, reject) => shareCanvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG encoding failed')), 'image/png'));
|
|
995
|
+
$('downloadCard').onclick = async () => {
|
|
996
|
+
if (!cardReady) return;
|
|
997
|
+
try {
|
|
998
|
+
const url = URL.createObjectURL(await cardBlob()), link = document.createElement('a');
|
|
999
|
+
link.href = url; link.download = `brainplug-${shareStats.year || 'all-time'}.png`;
|
|
1000
|
+
link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
1001
|
+
$('cardStatus').textContent = '';
|
|
1002
|
+
} catch { $('cardStatus').textContent = L.cardError; }
|
|
1003
|
+
};
|
|
1004
|
+
$('copyCard').onclick = async () => {
|
|
1005
|
+
if (!cardReady) return;
|
|
1006
|
+
try {
|
|
1007
|
+
// Start the clipboard write in the click handler to retain user activation.
|
|
1008
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': cardBlob() })]);
|
|
1009
|
+
$('cardStatus').textContent = L.copied;
|
|
1010
|
+
} catch { $('cardStatus').textContent = L.cardCopyError; }
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
/* -------------------------------------------------------------- numbers */
|
|
1014
|
+
function drawNumbers() {
|
|
1015
|
+
$('nbTitle').textContent = L.nbTitle; $('nbSub').textContent = L.nbSub(N.first, N.last);
|
|
1016
|
+
const card = (label, big, small = '', cls = '') => `<div class="card panel ${cls}"><h3>${label}</h3><div class="big">${big}</div><div class="small">${small}</div></div>`;
|
|
1017
|
+
const kinds = KIND.map((k, i) => N.kinds[k] ? `${fmt(N.kinds[k])} ${L.kindsPl[i]}` : '').filter(Boolean).join(' · ');
|
|
1018
|
+
let h = card(L.items, fmt(N.items), kinds + (N.undated ? ` · ${L.undated(N.undated)}` : ''));
|
|
1019
|
+
h += card(L.writes, fmt(N.writes), `${fmt(N.active_days)} ${L.activeDays.toLowerCase()}`);
|
|
1020
|
+
h += card(L.wordsYou, fmt(N.words), `≈ ${fmt(Math.round(N.words / 250))} ${L.pages}`);
|
|
1021
|
+
h += card(L.streak, `${N.streak}`, L.days);
|
|
1022
|
+
h += card(L.busiestMonth, monthName(N.busiest_month.month), `${N.busiest_month.count} ${L.items.toLowerCase()}`);
|
|
1023
|
+
h += card(L.rhythm, `${String(N.busiest_hour).padStart(2, '0')}:00`, L.mostOn(L.weekdaysLong[N.busiest_weekday], N.busiest_hour));
|
|
1024
|
+
h += card(L.night, `${Math.round(N.night_share * 100)}%`, L.nightSub);
|
|
1025
|
+
h += card(L.longest, `${fmt(N.longest.words)} ${L.words}`, N.longest.title ? esc(N.longest.title) : `<i>${L.hidden}</i>`);
|
|
1026
|
+
const bar = (name, share, rel, color, extra = '') => `<div class="share"><span style="color:${color || 'inherit'}">${name}</span><div class="track"><div class="fill${color ? '' : ' all'}" style="width:${(rel * 100).toFixed(1)}%;${color ? 'background:' + color : ''}"></div></div><span style="text-align:right;color:#8b93a7">${share}${extra}</span></div>`;
|
|
1027
|
+
h += `<div class="card panel full"><h3>${L.bySource}</h3>${N.sources.map(s => bar(esc(srcName(s.id)), fmt(s.count), s.count / N.sources[0].count, '')).join('')}</div>`;
|
|
1028
|
+
const hmax = Math.max(1, ...N.heat.flat());
|
|
1029
|
+
let heat = '<div></div>' + Array.from({ length: 24 }, (_, x) => `<div style="text-align:center">${x % 3 === 0 ? x : ''}</div>`).join('');
|
|
1030
|
+
N.heat.forEach((row, d) => { heat += `<div>${L.weekdays[d]}</div>` + row.map(n => `<div class="c" title="${n}" style="background:rgba(167,123,255,${n ? (0.12 + 0.88 * Math.sqrt(n / hmax)).toFixed(2) : 0.04})"></div>`).join(''); });
|
|
1031
|
+
h += `<div class="card panel full"><h3>${L.heat}</h3><div id="heat">${heat}</div></div>`;
|
|
1032
|
+
h += `<div class="card panel full"><h3>${L.share}</h3>${TP.map(t => bar(esc(t.name), Math.round(t.share * 100), t.share / TP[0].share, col(t.id), '%')).join('')}</div>`;
|
|
1033
|
+
$('cards').innerHTML = h;
|
|
1034
|
+
$('footer').textContent = L.footer(DATA.meta.version);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* ----------------------------------------------------------------- boot */
|
|
1038
|
+
drawLegend();
|
|
1039
|
+
setCut(1);
|
|
1040
|
+
frame();
|
|
1041
|
+
// small API used by the screenshot and test scripts
|
|
1042
|
+
window.__bp = {
|
|
1043
|
+
ready: true, state: S, show, select, openItem, setLayout, setCut,
|
|
1044
|
+
pause: () => { autoRotate = false; clearTimeout(idle); }, yaw: y => { yaw = y; },
|
|
1045
|
+
morph: m => { setLayout(m >= 0.5 ? 1 : 0); S.morph = S.morphTo = m; pitch = pitchTo = 0.18 + 0.44 * m; dist = distTo = camDist(m); gliding = false; },
|
|
1046
|
+
project: i => { const s = screen(place(i, new THREE.Vector3())); return { x: s.x, y: s.y }; },
|
|
1047
|
+
};
|
|
1048
|
+
</script>
|
|
1049
|
+
</body>
|
|
1050
|
+
</html>
|