@yuuki824/kanshi 0.1.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/.env.example +19 -0
- package/Dockerfile +27 -0
- package/README.md +138 -0
- package/app/__init__.py +0 -0
- package/app/config.py +72 -0
- package/app/dockerstats.py +207 -0
- package/app/main.py +174 -0
- package/app/storage.py +220 -0
- package/app/vitals.py +170 -0
- package/bin/kanshi.js +55 -0
- package/docker-compose.yml +59 -0
- package/package.json +35 -0
- package/requirements.txt +4 -0
- package/web/app.js +344 -0
- package/web/index.html +105 -0
- package/web/style.css +272 -0
- package/web/treemap.js +188 -0
package/web/style.css
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/* ── Palette roles ───────────────────────────────────────────────────────────
|
|
2
|
+
Values from the validated reference palette. Categorical slots in use: two
|
|
3
|
+
(blue = folder, orange = file); validated all-pairs in both modes, so the
|
|
4
|
+
treemap is safe under every CVD simulation. Everything else is ink, surface
|
|
5
|
+
or a reserved status token. */
|
|
6
|
+
.viz-root {
|
|
7
|
+
color-scheme: light;
|
|
8
|
+
--plane: #f9f9f7;
|
|
9
|
+
--surface-1: #fcfcfb;
|
|
10
|
+
--text-primary: #0b0b0b;
|
|
11
|
+
--text-secondary: #52514e;
|
|
12
|
+
--text-muted: #898781;
|
|
13
|
+
--gridline: #e1e0d9;
|
|
14
|
+
--baseline: #c3c2b7;
|
|
15
|
+
--border: rgba(11, 11, 11, 0.10);
|
|
16
|
+
|
|
17
|
+
--series-1: #2a78d6; /* the one categorical slot in use */
|
|
18
|
+
--neutral-mark: #c3c2b7; /* aggregated "other" — deliberately not a slot */
|
|
19
|
+
--tile: #1c5cab; /* blue ramp step 550; white label = 7.1:1 */
|
|
20
|
+
--tile-ink: #ffffff;
|
|
21
|
+
--tile-rest-ink: #0b0b0b;
|
|
22
|
+
|
|
23
|
+
--status-good: #0ca30c;
|
|
24
|
+
--status-warning: #fab219;
|
|
25
|
+
--status-critical: #d03b3b;
|
|
26
|
+
|
|
27
|
+
/* Meter tracks: a lighter step of the fill's own ramp, so state reads across
|
|
28
|
+
the whole bar rather than only the filled part. */
|
|
29
|
+
--track-ok: #cde2fb;
|
|
30
|
+
--track-warn: #fdeac4;
|
|
31
|
+
--track-crit: #f4d2d2;
|
|
32
|
+
--ghost: rgba(11, 11, 11, 0.05);
|
|
33
|
+
}
|
|
34
|
+
@media (prefers-color-scheme: dark) {
|
|
35
|
+
:root:where(:not([data-theme="light"])) .viz-root {
|
|
36
|
+
color-scheme: dark;
|
|
37
|
+
--plane: #0d0d0d;
|
|
38
|
+
--surface-1: #1a1a19;
|
|
39
|
+
--text-primary: #ffffff;
|
|
40
|
+
--text-secondary: #c3c2b7;
|
|
41
|
+
--text-muted: #898781;
|
|
42
|
+
--gridline: #2c2c2a;
|
|
43
|
+
--baseline: #383835;
|
|
44
|
+
--border: rgba(255, 255, 255, 0.10);
|
|
45
|
+
--series-1: #3987e5;
|
|
46
|
+
--neutral-mark: #55544f;
|
|
47
|
+
--tile: #256abf;
|
|
48
|
+
--tile-ink: #ffffff;
|
|
49
|
+
--tile-rest-ink: #ffffff;
|
|
50
|
+
--track-ok: #184f95;
|
|
51
|
+
--track-warn: #5c410a;
|
|
52
|
+
--track-crit: #4f1a1a;
|
|
53
|
+
--ghost: rgba(255, 255, 255, 0.06);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
:root[data-theme="dark"] .viz-root {
|
|
57
|
+
color-scheme: dark;
|
|
58
|
+
--plane: #0d0d0d;
|
|
59
|
+
--surface-1: #1a1a19;
|
|
60
|
+
--text-primary: #ffffff;
|
|
61
|
+
--text-secondary: #c3c2b7;
|
|
62
|
+
--text-muted: #898781;
|
|
63
|
+
--gridline: #2c2c2a;
|
|
64
|
+
--baseline: #383835;
|
|
65
|
+
--border: rgba(255, 255, 255, 0.10);
|
|
66
|
+
--series-1: #3987e5;
|
|
67
|
+
--neutral-mark: #55544f;
|
|
68
|
+
--tile: #256abf;
|
|
69
|
+
--tile-ink: #ffffff;
|
|
70
|
+
--tile-rest-ink: #ffffff;
|
|
71
|
+
--track-ok: #184f95;
|
|
72
|
+
--track-warn: #5c410a;
|
|
73
|
+
--track-crit: #4f1a1a;
|
|
74
|
+
--ghost: rgba(255, 255, 255, 0.06);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
* { box-sizing: border-box; }
|
|
78
|
+
|
|
79
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
80
|
+
|
|
81
|
+
body {
|
|
82
|
+
margin: 0;
|
|
83
|
+
padding: 0 0 env(safe-area-inset-bottom);
|
|
84
|
+
background: var(--plane);
|
|
85
|
+
color: var(--text-primary);
|
|
86
|
+
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
87
|
+
transition: opacity .25s ease;
|
|
88
|
+
}
|
|
89
|
+
body.is-stale { opacity: .55; } /* hold the last render, never a skeleton */
|
|
90
|
+
|
|
91
|
+
.visually-hidden {
|
|
92
|
+
position: absolute; width: 1px; height: 1px; overflow: hidden;
|
|
93
|
+
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ── Top bar ─────────────────────────────────────────────────────────────── */
|
|
97
|
+
.topbar {
|
|
98
|
+
position: sticky; top: 0; z-index: 10;
|
|
99
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
100
|
+
gap: 12px; padding: 10px 16px calc(10px + env(safe-area-inset-top)) 16px;
|
|
101
|
+
padding-top: calc(10px + env(safe-area-inset-top));
|
|
102
|
+
background: color-mix(in srgb, var(--plane) 88%, transparent);
|
|
103
|
+
backdrop-filter: blur(10px);
|
|
104
|
+
border-bottom: 1px solid var(--border);
|
|
105
|
+
}
|
|
106
|
+
.topbar h1 {
|
|
107
|
+
margin: 0; font-size: 15px; font-weight: 650; letter-spacing: .14em;
|
|
108
|
+
text-transform: uppercase; color: var(--text-secondary);
|
|
109
|
+
}
|
|
110
|
+
.topbar-meta { display: flex; align-items: center; gap: 12px; }
|
|
111
|
+
|
|
112
|
+
.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
|
|
113
|
+
.conn i {
|
|
114
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
115
|
+
background: var(--text-muted); flex: none;
|
|
116
|
+
}
|
|
117
|
+
.conn.live i { background: var(--status-good); }
|
|
118
|
+
.conn.down i { background: var(--status-critical); }
|
|
119
|
+
|
|
120
|
+
main { max-width: 760px; margin: 0 auto; padding: 12px 12px 28px; display: grid; gap: 12px; min-width: 0; }
|
|
121
|
+
|
|
122
|
+
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
123
|
+
.card {
|
|
124
|
+
background: var(--surface-1);
|
|
125
|
+
border: 1px solid var(--border);
|
|
126
|
+
border-radius: 14px;
|
|
127
|
+
padding: 14px;
|
|
128
|
+
/* A grid item's automatic minimum size is its content, so without this a
|
|
129
|
+
single long path inside the card widens the page instead of truncating. */
|
|
130
|
+
min-width: 0;
|
|
131
|
+
}
|
|
132
|
+
.card-head {
|
|
133
|
+
display: flex; align-items: baseline; justify-content: space-between;
|
|
134
|
+
gap: 10px; margin-bottom: 12px;
|
|
135
|
+
}
|
|
136
|
+
.card-head h2 {
|
|
137
|
+
margin: 0; font-size: 12px; font-weight: 650; letter-spacing: .1em;
|
|
138
|
+
text-transform: uppercase; color: var(--text-secondary);
|
|
139
|
+
}
|
|
140
|
+
.muted { color: var(--text-muted); }
|
|
141
|
+
.small { font-size: 12px; }
|
|
142
|
+
|
|
143
|
+
/* ── Hero figure (exactly one on the page) ───────────────────────────────── */
|
|
144
|
+
.hero-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
145
|
+
.hero {
|
|
146
|
+
font-size: 56px; line-height: 1; font-weight: 620;
|
|
147
|
+
letter-spacing: -.02em; color: var(--text-primary);
|
|
148
|
+
font-variant-numeric: proportional-nums; /* never tabular at display size */
|
|
149
|
+
}
|
|
150
|
+
.hero-unit { font-size: 22px; font-weight: 500; color: var(--text-muted); margin-left: 3px; }
|
|
151
|
+
.hero-label { margin-top: 6px; }
|
|
152
|
+
|
|
153
|
+
.minitable { margin: 0; display: grid; grid-template-columns: auto auto; gap: 3px 12px; align-content: start; }
|
|
154
|
+
.minitable dt { color: var(--text-muted); font-size: 12px; }
|
|
155
|
+
.minitable dd { margin: 0; font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary); }
|
|
156
|
+
|
|
157
|
+
/* Per-core bars: nominal categories, so one hue for all of them — the height
|
|
158
|
+
already carries the value and a ramp would double-encode it. */
|
|
159
|
+
.cores { display: flex; align-items: stretch; gap: 2px; height: 34px; margin-top: 14px; }
|
|
160
|
+
.cores div { flex: 1; background: var(--track-ok); border-radius: 2px; position: relative; overflow: hidden; }
|
|
161
|
+
.cores div span {
|
|
162
|
+
position: absolute; left: 0; right: 0; bottom: 0;
|
|
163
|
+
background: var(--series-1); border-radius: 2px;
|
|
164
|
+
transition: height .3s ease;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ── Meters ──────────────────────────────────────────────────────────────── */
|
|
168
|
+
.meters { display: grid; gap: 13px; }
|
|
169
|
+
.meter-head {
|
|
170
|
+
display: flex; justify-content: space-between; align-items: baseline;
|
|
171
|
+
gap: 8px; margin-bottom: 5px; font-size: 13px;
|
|
172
|
+
}
|
|
173
|
+
.meter-name { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
174
|
+
.meter-val { color: var(--text-primary); font-variant-numeric: tabular-nums; flex: none; font-size: 12px; }
|
|
175
|
+
.meter-val b { font-weight: 620; }
|
|
176
|
+
.meter-track { height: 9px; border-radius: 5px; background: var(--track-ok); overflow: hidden; }
|
|
177
|
+
.meter-fill { height: 100%; border-radius: 5px; background: var(--series-1); transition: width .35s ease; }
|
|
178
|
+
.meter.warn .meter-track { background: var(--track-warn); }
|
|
179
|
+
.meter.warn .meter-fill { background: var(--status-warning); }
|
|
180
|
+
.meter.crit .meter-track { background: var(--track-crit); }
|
|
181
|
+
.meter.crit .meter-fill { background: var(--status-critical); }
|
|
182
|
+
/* Status hue never carries meaning alone: the % and an icon ride alongside. */
|
|
183
|
+
.meter .flag { font-size: 11px; margin-right: 4px; }
|
|
184
|
+
.meter.ok .flag { display: none; }
|
|
185
|
+
|
|
186
|
+
/* ── Buttons & chips ─────────────────────────────────────────────────────── */
|
|
187
|
+
.btn {
|
|
188
|
+
font: inherit; font-size: 12px; padding: 6px 12px; min-height: 32px;
|
|
189
|
+
border-radius: 8px; border: 1px solid var(--border);
|
|
190
|
+
background: var(--ghost); color: var(--text-secondary); cursor: pointer;
|
|
191
|
+
}
|
|
192
|
+
.btn:active { transform: translateY(1px); }
|
|
193
|
+
.btn[disabled] { opacity: .5; cursor: default; }
|
|
194
|
+
.btn-quiet { border-color: transparent; background: transparent; }
|
|
195
|
+
|
|
196
|
+
.rootbar, .sortbar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
197
|
+
.chip {
|
|
198
|
+
font: inherit; font-size: 12px; padding: 5px 11px; min-height: 30px;
|
|
199
|
+
border-radius: 999px; border: 1px solid var(--border);
|
|
200
|
+
background: transparent; color: var(--text-muted); cursor: pointer;
|
|
201
|
+
}
|
|
202
|
+
.chip.is-on { background: var(--series-1); border-color: transparent; color: #fff; }
|
|
203
|
+
/* Paths can be arbitrarily long; a wrapping row still can't shrink a single
|
|
204
|
+
over-wide item, so each chip truncates at the container's width. */
|
|
205
|
+
.rootbar .chip { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
206
|
+
|
|
207
|
+
/* ── Breadcrumb ──────────────────────────────────────────────────────────── */
|
|
208
|
+
.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; margin-bottom: 8px; font-size: 12px; }
|
|
209
|
+
.crumbs button {
|
|
210
|
+
font: inherit; font-size: 12px; background: none; border: 0; padding: 4px 5px;
|
|
211
|
+
color: var(--series-1); cursor: pointer; border-radius: 5px; min-height: 28px;
|
|
212
|
+
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
213
|
+
}
|
|
214
|
+
.crumbs button[disabled] { color: var(--text-muted); cursor: default; }
|
|
215
|
+
.crumbs .sep { color: var(--text-muted); }
|
|
216
|
+
|
|
217
|
+
/* ── Treemap ─────────────────────────────────────────────────────────────── */
|
|
218
|
+
.treemap-wrap { width: 100%; }
|
|
219
|
+
#treemap { display: block; width: 100%; height: 300px; border-radius: 10px; overflow: hidden; touch-action: manipulation; }
|
|
220
|
+
#treemap .tile { cursor: pointer; outline: none; }
|
|
221
|
+
#treemap .tile rect { transition: opacity .15s ease; }
|
|
222
|
+
#treemap .tile:hover rect, #treemap .tile:focus-visible rect { opacity: .78; }
|
|
223
|
+
#treemap .tile:focus-visible rect { stroke: var(--text-primary); stroke-width: 2px; }
|
|
224
|
+
#treemap text { pointer-events: none; font-family: inherit; }
|
|
225
|
+
.focusline { margin: 8px 0 0; min-height: 1.45em; overflow-wrap: anywhere; }
|
|
226
|
+
#stor-meta { overflow-wrap: anywhere; }
|
|
227
|
+
|
|
228
|
+
.legend { display: flex; gap: 14px; list-style: none; margin: 9px 0 0; padding: 0; font-size: 12px; color: var(--text-muted); }
|
|
229
|
+
.legend li { display: flex; align-items: center; gap: 5px; }
|
|
230
|
+
.sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
|
231
|
+
.sw-dir { background: var(--tile); } .sw-file { background: var(--tile); opacity: .55; }
|
|
232
|
+
.sw-rest { background: var(--neutral-mark); }
|
|
233
|
+
/* Kind is carried by the glyph, not the fill — one hue means no CVD exposure. */
|
|
234
|
+
.legend .g { font-size: 12px; color: var(--text-muted); width: 10px; text-align: center; }
|
|
235
|
+
|
|
236
|
+
/* ── Tables (the WCAG-clean twin of every chart on the page) ─────────────── */
|
|
237
|
+
.tbl { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 13px; }
|
|
238
|
+
.tbl th {
|
|
239
|
+
text-align: left; font-weight: 500; font-size: 11px; letter-spacing: .06em;
|
|
240
|
+
text-transform: uppercase; color: var(--text-muted);
|
|
241
|
+
padding: 6px 4px; border-bottom: 1px solid var(--gridline);
|
|
242
|
+
}
|
|
243
|
+
.tbl td { padding: 9px 4px; border-bottom: 1px solid var(--gridline); vertical-align: middle; }
|
|
244
|
+
.tbl tr:last-child td { border-bottom: 0; }
|
|
245
|
+
.tbl .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
246
|
+
.tbl tbody tr.tap { cursor: pointer; }
|
|
247
|
+
.tbl tbody tr.tap:hover td { background: var(--ghost); }
|
|
248
|
+
#stor-tbl td:first-child, #stor-tbl th:first-child { max-width: 0; width: 60%; }
|
|
249
|
+
.name-cell { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
|
250
|
+
.name-cell .sw { flex: none; }
|
|
251
|
+
.name-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
252
|
+
|
|
253
|
+
/* ── Container rows ──────────────────────────────────────────────────────── */
|
|
254
|
+
.ctr-tbl td:first-child { max-width: 0; width: 45%; }
|
|
255
|
+
.ctr-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
256
|
+
.ctr-sub { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
257
|
+
.ctr-bar { height: 3px; border-radius: 2px; background: var(--track-ok); margin-top: 5px; overflow: hidden; }
|
|
258
|
+
.ctr-bar i { display: block; height: 100%; background: var(--series-1); border-radius: 2px; transition: width .3s ease; }
|
|
259
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex: none; }
|
|
260
|
+
.dot.run { background: var(--status-good); } .dot.stop { background: var(--text-muted); }
|
|
261
|
+
.dot.bad { background: var(--status-critical); }
|
|
262
|
+
tr.is-stopped { opacity: .55; }
|
|
263
|
+
|
|
264
|
+
.foot { display: flex; justify-content: space-between; align-items: center; padding: 0 4px; }
|
|
265
|
+
|
|
266
|
+
@media (prefers-reduced-motion: reduce) {
|
|
267
|
+
* { transition: none !important; animation: none !important; }
|
|
268
|
+
}
|
|
269
|
+
@media (min-width: 560px) {
|
|
270
|
+
#treemap { height: 380px; }
|
|
271
|
+
.hero { font-size: 64px; }
|
|
272
|
+
}
|
package/web/treemap.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/* Squarified treemap, ~120 lines of SVG. No charting library: the whole point
|
|
2
|
+
* of this app is to have no external dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Bruls/Huizing/van Wijk squarified layout — greedily fills rows along the
|
|
5
|
+
* short side, keeping each tile as close to square as it can, so tiles stay
|
|
6
|
+
* tappable on a phone instead of degenerating into slivers. */
|
|
7
|
+
(function (global) {
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
11
|
+
const GAP = 2; // 2px surface gap between fills — never a stroke border
|
|
12
|
+
const RADIUS = 3;
|
|
13
|
+
const GLYPH = { dir: "▸", file: "·", rest: "⋯" };
|
|
14
|
+
|
|
15
|
+
function worstRatio(row, rowSum, shortSide, scale) {
|
|
16
|
+
if (!row.length) return Infinity;
|
|
17
|
+
const s = rowSum * scale;
|
|
18
|
+
if (s <= 0) return Infinity;
|
|
19
|
+
const max = row[0].size * scale;
|
|
20
|
+
const min = row[row.length - 1].size * scale;
|
|
21
|
+
const sq = shortSide * shortSide;
|
|
22
|
+
return Math.max((sq * max) / (s * s), (s * s) / (sq * min));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function squarify(items, x, y, w, h) {
|
|
26
|
+
const out = [];
|
|
27
|
+
let remaining = items.filter((d) => d.size > 0).slice().sort((a, b) => b.size - a.size);
|
|
28
|
+
|
|
29
|
+
while (remaining.length && w > 0.5 && h > 0.5) {
|
|
30
|
+
let totalRem = 0;
|
|
31
|
+
for (const item of remaining) totalRem += item.size;
|
|
32
|
+
if (totalRem <= 0) break;
|
|
33
|
+
|
|
34
|
+
const scale = (w * h) / totalRem;
|
|
35
|
+
const shortSide = Math.min(w, h);
|
|
36
|
+
const row = [];
|
|
37
|
+
let rowSum = 0;
|
|
38
|
+
let prevWorst = Infinity;
|
|
39
|
+
|
|
40
|
+
while (remaining.length) {
|
|
41
|
+
const candidate = remaining[0];
|
|
42
|
+
const nextSum = rowSum + candidate.size;
|
|
43
|
+
const ratio = worstRatio(row.concat([candidate]), nextSum, shortSide, scale);
|
|
44
|
+
// Adding this tile is only worth it while it makes the row *less* oblong.
|
|
45
|
+
if (row.length === 0 || ratio <= prevWorst) {
|
|
46
|
+
row.push(remaining.shift());
|
|
47
|
+
rowSum = nextSum;
|
|
48
|
+
prevWorst = ratio;
|
|
49
|
+
} else break;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rowArea = rowSum * scale;
|
|
53
|
+
if (w >= h) {
|
|
54
|
+
const rw = rowArea / h;
|
|
55
|
+
let cy = y;
|
|
56
|
+
for (const item of row) {
|
|
57
|
+
const rh = (item.size * scale) / rw;
|
|
58
|
+
out.push({ item: item, x: x, y: cy, w: rw, h: rh });
|
|
59
|
+
cy += rh;
|
|
60
|
+
}
|
|
61
|
+
x += rw; w -= rw;
|
|
62
|
+
} else {
|
|
63
|
+
const rh = rowArea / w;
|
|
64
|
+
let cx = x;
|
|
65
|
+
for (const item of row) {
|
|
66
|
+
const rw = (item.size * scale) / rh;
|
|
67
|
+
out.push({ item: item, x: cx, y: y, w: rw, h: rh });
|
|
68
|
+
cx += rw;
|
|
69
|
+
}
|
|
70
|
+
y += rh; h -= rh;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function el(name, attrs) {
|
|
77
|
+
const node = document.createElementNS(NS, name);
|
|
78
|
+
for (const key in attrs) node.setAttribute(key, attrs[key]);
|
|
79
|
+
return node;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Trim a label until it truly fits its tile. The character estimate below is
|
|
83
|
+
* only an estimate — proportional type makes "WWW" three times the width of
|
|
84
|
+
* "iii" — so anything still over budget is measured and cut for real. The
|
|
85
|
+
* node has to be in the document before getComputedTextLength() means
|
|
86
|
+
* anything, hence the second pass after every tile is placed. */
|
|
87
|
+
function fitLabel(entry) {
|
|
88
|
+
const node = entry.node;
|
|
89
|
+
if (node.getComputedTextLength() <= entry.room) return;
|
|
90
|
+
// One proportional guess lands within a character or two; the loop closes
|
|
91
|
+
// the rest. Both run on a handful of labels, never the whole tree.
|
|
92
|
+
let chars = Math.max(1, Math.floor(entry.name.length * (entry.room / node.getComputedTextLength())) - 1);
|
|
93
|
+
node.textContent = entry.prefix + entry.name.slice(0, chars) + "…";
|
|
94
|
+
while (chars > 1 && node.getComputedTextLength() > entry.room) {
|
|
95
|
+
chars -= 1;
|
|
96
|
+
node.textContent = entry.prefix + entry.name.slice(0, chars) + "…";
|
|
97
|
+
}
|
|
98
|
+
// Not even one character plus the ellipsis fits: drop the label rather than
|
|
99
|
+
// let it bleed over the tile edge. The table below still carries the name.
|
|
100
|
+
if (node.getComputedTextLength() > entry.room) node.textContent = "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* render(svg, children, { width, height, fmt, onSelect, onFocus }) */
|
|
104
|
+
function render(svg, children, opts) {
|
|
105
|
+
const W = opts.width, H = opts.height;
|
|
106
|
+
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
|
107
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
108
|
+
|
|
109
|
+
if (!children || !children.length) {
|
|
110
|
+
const note = el("text", { x: W / 2, y: H / 2, "text-anchor": "middle",
|
|
111
|
+
fill: "var(--text-muted)", "font-size": 13 });
|
|
112
|
+
note.textContent = "Nothing to show here";
|
|
113
|
+
svg.appendChild(note);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const placed = squarify(children, 0, 0, W, H);
|
|
118
|
+
const pending = []; // labels to measure once they are in the document
|
|
119
|
+
|
|
120
|
+
for (const cell of placed) {
|
|
121
|
+
const d = cell.item;
|
|
122
|
+
// Inset by half the gap on every side: neighbours each give up 1px, so
|
|
123
|
+
// the visible channel between two fills is exactly GAP.
|
|
124
|
+
const x = cell.x + GAP / 2, y = cell.y + GAP / 2;
|
|
125
|
+
const w = Math.max(0, cell.w - GAP), h = Math.max(0, cell.h - GAP);
|
|
126
|
+
if (w <= 0 || h <= 0) continue;
|
|
127
|
+
|
|
128
|
+
const isRest = d.kind === "rest";
|
|
129
|
+
const group = el("g", { class: "tile", tabindex: 0, role: "button" });
|
|
130
|
+
group.setAttribute("aria-label",
|
|
131
|
+
d.name + ", " + opts.fmt(d.size) + (d.kind === "dir" ? ", folder" : ""));
|
|
132
|
+
|
|
133
|
+
group.appendChild(el("rect", {
|
|
134
|
+
x: x, y: y, width: w, height: h,
|
|
135
|
+
rx: Math.min(RADIUS, w / 2, h / 2),
|
|
136
|
+
fill: isRest ? "var(--neutral-mark)" : "var(--tile)",
|
|
137
|
+
"fill-opacity": d.kind === "file" ? 0.72 : 1,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const ink = isRest ? "var(--tile-rest-ink)" : "var(--tile-ink)";
|
|
141
|
+
// Only label a tile when the text genuinely fits with padding — a clipped
|
|
142
|
+
// label is worse than none, and the table below carries every value.
|
|
143
|
+
if (w >= 58 && h >= 24) {
|
|
144
|
+
const label = el("text", {
|
|
145
|
+
x: x + 7, y: y + 16, fill: ink, "font-size": 12.5,
|
|
146
|
+
"font-weight": 600, "letter-spacing": "-.005em",
|
|
147
|
+
});
|
|
148
|
+
const glyph = GLYPH[d.kind] || "";
|
|
149
|
+
const prefix = glyph ? glyph + " " : "";
|
|
150
|
+
// The glyph rides in the same line box, so it has to come out of the
|
|
151
|
+
// budget too — otherwise every labelled tile runs two characters long
|
|
152
|
+
// and the name is sliced by the SVG clip instead of ellipsised. 6.9 is
|
|
153
|
+
// a deliberately pessimistic advance width: real names carry capitals
|
|
154
|
+
// and spaces, which run wider than the lowercase average.
|
|
155
|
+
const maxChars = Math.floor((w - 14) / 6.9) - prefix.length;
|
|
156
|
+
let name = d.name;
|
|
157
|
+
if (name.length > maxChars) name = name.slice(0, Math.max(1, maxChars - 1)) + "…";
|
|
158
|
+
label.textContent = prefix + name;
|
|
159
|
+
group.appendChild(label);
|
|
160
|
+
pending.push({ node: label, prefix: prefix, name: d.name, room: w - 14 });
|
|
161
|
+
|
|
162
|
+
if (h >= 42) {
|
|
163
|
+
const value = el("text", {
|
|
164
|
+
x: x + 7, y: y + 32, fill: ink, "fill-opacity": 0.85,
|
|
165
|
+
"font-size": 11.5, "font-weight": 500,
|
|
166
|
+
});
|
|
167
|
+
value.textContent = opts.fmt(d.size);
|
|
168
|
+
group.appendChild(value);
|
|
169
|
+
pending.push({ node: value, prefix: "", name: opts.fmt(d.size), room: w - 14 });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const focus = function () { opts.onFocus && opts.onFocus(d); };
|
|
174
|
+
const activate = function (ev) { ev.preventDefault(); opts.onSelect && opts.onSelect(d); };
|
|
175
|
+
group.addEventListener("click", activate);
|
|
176
|
+
group.addEventListener("mouseenter", focus);
|
|
177
|
+
group.addEventListener("focus", focus);
|
|
178
|
+
group.addEventListener("keydown", function (ev) {
|
|
179
|
+
if (ev.key === "Enter" || ev.key === " ") activate(ev);
|
|
180
|
+
});
|
|
181
|
+
svg.appendChild(group);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const entry of pending) fitLabel(entry);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
global.Treemap = { render: render, squarify: squarify };
|
|
188
|
+
})(window);
|