|
|
… |
8 unchanged lines |
| 9 |
9 |
|
|
| 10 |
10 |
|
use std::f32::consts::TAU; |
| 11 |
11 |
|
|
|
12 |
+ |
/// The scale of marks and orbits: the sky drawn 40% closer than first |
|
13 |
+ |
/// built (Andy, 2026-10-13). |
|
14 |
+ |
const ZOOM: f32 = 1.4; |
| 12 |
15 |
|
/// How far the cursor lights marks, in CSS pixels (§4). |
| 13 |
|
- |
pub const LIGHT_RADIUS: f32 = 140.0; |
|
16 |
+ |
pub const LIGHT_RADIUS: f32 = 170.0; |
| 14 |
17 |
|
/// Seconds a mark takes to light, and to fade. |
| 15 |
18 |
|
const LIT_IN: f32 = 0.15; |
| 16 |
19 |
|
const LIT_OUT: f32 = 0.6; |
|
|
… |
4 unchanged lines |
| 21 |
24 |
|
/// The longest link: only marks that happened one after another and sit |
| 22 |
25 |
|
/// near each other are joined, so a busy repository reads as a |
| 23 |
26 |
|
/// constellation, not a scribble. |
| 24 |
|
- |
const LINK_MAX: f32 = 70.0; |
|
27 |
+ |
const LINK_MAX: f32 = 85.0; |
| 25 |
28 |
|
/// Links fade when the cursor has rested this long, in seconds. |
| 26 |
29 |
|
const LINK_REST: f32 = 2.0; |
| 27 |
30 |
|
/// A resting cursor this near a cluster's core names it, after this long. |
| 28 |
|
- |
const LABEL_NEAR: f32 = 90.0; |
|
31 |
+ |
const LABEL_NEAR: f32 = 110.0; |
| 29 |
32 |
|
const LABEL_REST: f32 = 0.4; |
| 30 |
33 |
|
/// Events older than this many days are not drawn. |
| 31 |
34 |
|
pub const DAYS: f32 = 30.0; |
| 32 |
35 |
|
/// The most marks one push makes (one a change), and in all. |
| 33 |
36 |
|
const PER_PUSH: u32 = 40; |
| 34 |
37 |
|
const MAX_MARKS: usize = 3000; |
|
38 |
+ |
/// A side margin narrower than this holds no clusters: the sky falls back |
|
39 |
+ |
/// to a ring around the page. |
|
40 |
+ |
const MIN_MARGIN: f32 = 180.0; |
|
41 |
+ |
/// The width a cluster gets in a margin's grid. |
|
42 |
+ |
const CELL: f32 = 260.0; |
| 35 |
43 |
|
|
| 36 |
44 |
|
/// What a mark stands for, which decides its shape. |
| 37 |
45 |
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
|
… |
116 unchanged lines |
| 154 |
162 |
|
/// Still (reduced motion, or the viewer's choice): no drift, no |
| 155 |
163 |
|
/// parallax, and marks light at once. |
| 156 |
164 |
|
pub still: bool, |
|
165 |
+ |
/// The page's content column, left and right, in CSS pixels: clusters |
|
166 |
+ |
/// go in the margins beside it, where they can be seen. |
|
167 |
+ |
avoid: Option<(f32, f32)>, |
| 157 |
168 |
|
parallax: [(f32, f32); 3], |
| 158 |
169 |
|
link_fade: f32, |
| 159 |
170 |
|
} |
|
|
… |
30 unchanged lines |
| 190 |
201 |
|
for (pos, &i) in rank.iter().enumerate() { |
| 191 |
202 |
|
layer[i] = (pos * 3 / repos.len().max(1)).min(2); |
| 192 |
203 |
|
} |
| 193 |
|
- |
let clusters: Vec<Cluster> = repos |
| 194 |
|
- |
.iter() |
| 195 |
|
- |
.enumerate() |
| 196 |
|
- |
.map(|(i, r)| { |
| 197 |
|
- |
let h = hash(&r.name); |
| 198 |
|
- |
// Around the page, mostly in its margins: a ring from a |
| 199 |
|
- |
// quarter to just past half of the view out. |
| 200 |
|
- |
let a = unit(h, 0) * TAU; |
| 201 |
|
- |
let d = 0.25 + 0.3 * unit(h, 16).sqrt(); |
| 202 |
|
- |
Cluster { |
| 203 |
|
- |
name: r.name.clone(), |
| 204 |
|
- |
fx: (0.5 + d * a.cos()).clamp(0.03, 0.97), |
| 205 |
|
- |
fy: (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95), |
| 206 |
|
- |
layer: layer[i], |
| 207 |
|
- |
} |
| 208 |
|
- |
}) |
| 209 |
|
- |
.collect(); |
|
204 |
+ |
let clusters: Vec<Cluster> = |
|
205 |
+ |
repos.iter().enumerate().map(|(i, r)| Cluster { name: r.name.clone(), fx: 0.5, fy: 0.5, layer: layer[i] }).collect(); |
| 210 |
206 |
|
let mut marks: Vec<Mark> = Vec::new(); |
| 211 |
207 |
|
for i in 0..clusters.len() { |
| 212 |
208 |
|
marks.push(Mark { cluster: i, kind: Kind::Repo, order: 0, radius: 0.0, angle: 0.0, speed: 0.0, bright: 0.9, lit: 0.0 }); |
|
|
… |
19 unchanged lines |
| 232 |
228 |
|
// Each repository turns one way; its marks keep their lanes, so |
| 233 |
229 |
|
// two clusters' streams cross without touching. |
| 234 |
230 |
|
let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 }; |
| 235 |
|
- |
let radius = 20.0 + (k % lanes) as f32 * 9.0 + unit(h, 0) * 6.0; |
|
231 |
+ |
// Lanes close together: each repository a tight knot. |
|
232 |
+ |
let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM; |
| 236 |
233 |
|
let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16)); |
| 237 |
234 |
|
marks.push(Mark { |
| 238 |
235 |
|
cluster: e.repo, |
|
|
… |
6 unchanged lines |
| 245 |
242 |
|
lit: 0.0, |
| 246 |
243 |
|
}); |
| 247 |
244 |
|
} |
| 248 |
|
- |
Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, parallax: [(0.0, 0.0); 3], link_fade: 0.0 } |
|
245 |
+ |
let mut sky = |
|
246 |
+ |
Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, avoid: None, parallax: [(0.0, 0.0); 3], link_fade: 0.0 }; |
|
247 |
+ |
sky.layout(); |
|
248 |
+ |
sky |
| 249 |
249 |
|
} |
| 250 |
250 |
|
|
| 251 |
|
- |
pub fn resize(&mut self, w: f32, h: f32) { |
|
251 |
+ |
/// The view is now `w` by `h`, its content column between `avoid`'s |
|
252 |
+ |
/// left and right. |
|
253 |
+ |
pub fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) { |
| 252 |
254 |
|
self.w = w; |
| 253 |
255 |
|
self.h = h; |
|
256 |
+ |
self.avoid = avoid; |
|
257 |
+ |
self.layout(); |
|
258 |
+ |
} |
|
259 |
+ |
|
|
260 |
+ |
/// Place the clusters. In the margins beside the content when both are |
|
261 |
+ |
/// wide enough: repositories in the order of their names' hashes, split |
|
262 |
+ |
/// between the sides by their widths, each side a staggered grid from |
|
263 |
+ |
/// top to bottom, so the sky fills the space the page leaves. Otherwise |
|
264 |
+ |
/// a ring around the middle. The same repositories in the same view |
|
265 |
+ |
/// always land in the same places. |
|
266 |
+ |
fn layout(&mut self) { |
|
267 |
+ |
let n = self.clusters.len(); |
|
268 |
+ |
let mut order: Vec<usize> = (0..n).collect(); |
|
269 |
+ |
order.sort_by_key(|&i| hash(&self.clusters[i].name)); |
|
270 |
+ |
let margins = self.avoid.and_then(|(x0, x1)| { |
|
271 |
+ |
let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0)); |
|
272 |
+ |
(l >= MIN_MARGIN && r >= MIN_MARGIN).then_some((l, r, x1)) |
|
273 |
+ |
}); |
|
274 |
+ |
match margins { |
|
275 |
+ |
Some((l, r, x1)) => { |
|
276 |
+ |
let left = (((n as f32) * l / (l + r)).round() as usize).min(n); |
|
277 |
+ |
let (a, b) = order.split_at(left); |
|
278 |
+ |
self.grid(a, 0.0, l); |
|
279 |
+ |
self.grid(b, x1, self.w); |
|
280 |
+ |
} |
|
281 |
+ |
None => { |
|
282 |
+ |
for c in &mut self.clusters { |
|
283 |
+ |
let h = hash(&c.name); |
|
284 |
+ |
let a = unit(h, 0) * TAU; |
|
285 |
+ |
let d = 0.25 + 0.3 * unit(h, 16).sqrt(); |
|
286 |
+ |
c.fx = (0.5 + d * a.cos()).clamp(0.03, 0.97); |
|
287 |
+ |
c.fy = (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95); |
|
288 |
+ |
} |
|
289 |
+ |
} |
|
290 |
+ |
} |
| 254 |
291 |
|
} |
| 255 |
292 |
|
|
|
293 |
+ |
/// Lay `ids` out in a staggered grid between `x0` and `x1`. |
|
294 |
+ |
fn grid(&mut self, ids: &[usize], x0: f32, x1: f32) { |
|
295 |
+ |
if ids.is_empty() || self.w <= 0.0 || self.h <= 0.0 { |
|
296 |
+ |
return; |
|
297 |
+ |
} |
|
298 |
+ |
let (top, bottom) = (self.h * 0.1, self.h * 0.97); |
|
299 |
+ |
let width = x1 - x0; |
|
300 |
+ |
let cols = ((width / CELL) as usize).clamp(1, 3); |
|
301 |
+ |
let rows = ids.len().div_ceil(cols); |
|
302 |
+ |
let (cw, ch) = (width / cols as f32, (bottom - top) / rows as f32); |
|
303 |
+ |
for (j, &i) in ids.iter().enumerate() { |
|
304 |
+ |
let (col, row) = (j % cols, j / cols); |
|
305 |
+ |
let h = hash(&self.clusters[i].name); |
|
306 |
+ |
let stagger = if cols > 1 && row % 2 == 1 { cw * 0.25 } else { 0.0 }; |
|
307 |
+ |
let cx = x0 + (col as f32 + 0.5) * cw + stagger + (unit(h, 0) - 0.5) * 0.3 * cw; |
|
308 |
+ |
let cy = top + (row as f32 + 0.5) * ch + (unit(h, 16) - 0.5) * 0.3 * ch; |
|
309 |
+ |
self.clusters[i].fx = cx.clamp(x0 + 24.0, x1 - 24.0) / self.w; |
|
310 |
+ |
self.clusters[i].fy = cy / self.h; |
|
311 |
+ |
} |
|
312 |
+ |
} |
|
313 |
+ |
|
| 256 |
314 |
|
/// The cursor moved to `(x, y)`, or left the page. |
| 257 |
315 |
|
pub fn point(&mut self, at: Option<(f32, f32)>) { |
| 258 |
316 |
|
self.cursor = at; |
|
|
… |
80 unchanged lines |
| 339 |
397 |
|
let (x, y) = self.position(m); |
| 340 |
398 |
|
let rest = [c.mark[0], c.mark[1], c.mark[2], c.mark[3] * m.bright]; |
| 341 |
399 |
|
let col = mix(rest, c.lit, m.lit); |
| 342 |
|
- |
let size = m.kind.size() * (1.0 + 0.35 * m.lit); |
|
400 |
+ |
let size = m.kind.size() * ZOOM * (1.0 + 0.35 * m.lit); |
| 343 |
401 |
|
out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]); |
| 344 |
402 |
|
} |
| 345 |
403 |
|
} |
|
|
… |
157 unchanged lines |
| 503 |
561 |
|
s.step(0.05); |
| 504 |
562 |
|
} |
| 505 |
563 |
|
assert_eq!(s.label().map(|l| l.0), Some("andy/oxygen")); |
|
564 |
+ |
} |
|
565 |
+ |
|
|
566 |
+ |
#[test] |
|
567 |
+ |
fn clusters_fill_the_margins_beside_the_content() { |
|
568 |
+ |
let repos: Vec<Repo> = (0..17).map(|i| Repo { name: format!("owner/repo{i}") }).collect(); |
|
569 |
+ |
let mut s = Sky::new(&repos, &[], 2560.0, 1270.0, false); |
|
570 |
+ |
s.resize(2560.0, 1270.0, Some((707.0, 1838.0))); |
|
571 |
+ |
let xs: Vec<f32> = (0..17).map(|i| s.core(i).0).collect(); |
|
572 |
+ |
assert!(xs.iter().all(|&x| x < 707.0 || x > 1838.0), "none behind the content: {xs:?}"); |
|
573 |
+ |
let left = xs.iter().filter(|&&x| x < 707.0).count(); |
|
574 |
+ |
assert!((7..=10).contains(&left), "both sides used: {left} on the left"); |
|
575 |
+ |
let ys: Vec<f32> = (0..17).map(|i| s.core(i).1).collect(); |
|
576 |
+ |
assert!(ys.iter().any(|&y| y < 400.0) && ys.iter().any(|&y| y > 900.0), "top to bottom: {ys:?}"); |
|
577 |
+ |
// The same view lays the same sky out. |
|
578 |
+ |
let mut again = Sky::new(&repos, &[], 2560.0, 1270.0, false); |
|
579 |
+ |
again.resize(2560.0, 1270.0, Some((707.0, 1838.0))); |
|
580 |
+ |
assert_eq!(xs, (0..17).map(|i| again.core(i).0).collect::<Vec<_>>()); |
|
581 |
+ |
// Narrow margins: back to the ring. |
|
582 |
+ |
s.resize(1200.0, 800.0, Some((100.0, 1100.0))); |
|
583 |
+ |
assert!((0..17).any(|i| (s.core(i).0 - 600.0).abs() < 400.0)); |
| 506 |
584 |
|
} |
| 507 |
585 |
|
|
| 508 |
586 |
|
#[test] |
|
|
… |
8 unchanged lines |