feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words
This commit is contained in:
1 parent
025f2dbc3f
commit
c6401f1114
27 files changed
+1619
-381
No files matched your search
@@ -0,0 +1,205 @@
|
||||
// A small force layout: rings keep the structure legible, springs and
|
||||
// repulsion keep neighbours apart, a breath keeps it alive between touches.
|
||||
|
||||
export interface Body {
|
||||
fixed: boolean;
|
||||
id: string;
|
||||
ring: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
a: Body;
|
||||
b: Body;
|
||||
}
|
||||
|
||||
const RING_RADIUS = [0, 92, 168, 224];
|
||||
const LINK_STRENGTH = 0.06;
|
||||
const LINK_REST = 70;
|
||||
const CHARGE = 1600;
|
||||
const CHARGE_REACH = 320;
|
||||
const RADIAL = 0.05;
|
||||
const HUB_HOLD = 0.4;
|
||||
const DECAY = 0.55;
|
||||
const ALPHA_EASE = 0.025;
|
||||
const BREATH = 0.35;
|
||||
const MIN_D2 = 36;
|
||||
const TAU = Math.PI * 2;
|
||||
const HASH_MOD = 1_000_003;
|
||||
const HASH_BASE = 31;
|
||||
|
||||
function hash(id: string): number {
|
||||
let h = 0;
|
||||
for (const ch of id) {
|
||||
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export function ringRadius(ring: number): number {
|
||||
return RING_RADIUS[Math.min(ring, RING_RADIUS.length - 1)] ?? 0;
|
||||
}
|
||||
|
||||
export class Force {
|
||||
bodies: Body[] = [];
|
||||
alpha = 1;
|
||||
alphaTarget = 0;
|
||||
private links: Link[] = [];
|
||||
private readonly index = new Map<string, Body>();
|
||||
private clock = 0;
|
||||
|
||||
// Keep what is already placed, seat newcomers on their ring near a
|
||||
// neighbour when they have one, forget what is gone.
|
||||
sync(
|
||||
nodes: { id: string; ring: number }[],
|
||||
edges: { from: string; to: string }[]
|
||||
): void {
|
||||
const keep = new Set<string>();
|
||||
const fresh: Body[] = [];
|
||||
for (const node of nodes) {
|
||||
keep.add(node.id);
|
||||
const known = this.index.get(node.id);
|
||||
if (known) {
|
||||
known.ring = node.ring;
|
||||
continue;
|
||||
}
|
||||
const angle = (hash(node.id) % 360) * (TAU / 360);
|
||||
const r = ringRadius(node.ring);
|
||||
const body: Body = {
|
||||
fixed: false,
|
||||
id: node.id,
|
||||
ring: node.ring,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
};
|
||||
this.index.set(node.id, body);
|
||||
fresh.push(body);
|
||||
}
|
||||
for (const id of [...this.index.keys()]) {
|
||||
if (!keep.has(id)) {
|
||||
this.index.delete(id);
|
||||
}
|
||||
}
|
||||
this.bodies = nodes
|
||||
.map((node) => this.index.get(node.id))
|
||||
.filter((body): body is Body => body !== undefined);
|
||||
this.links = [];
|
||||
for (const edge of edges) {
|
||||
const a = this.index.get(edge.from);
|
||||
const b = this.index.get(edge.to);
|
||||
if (a && b && a !== b) {
|
||||
this.links.push({ a, b });
|
||||
}
|
||||
}
|
||||
for (const body of fresh) {
|
||||
const link = this.links.find((l) => l.a === body || l.b === body);
|
||||
if (link) {
|
||||
const other = link.a === body ? link.b : link.a;
|
||||
const r = ringRadius(body.ring);
|
||||
const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09;
|
||||
body.x = Math.cos(angle) * r;
|
||||
body.y = Math.sin(angle) * r;
|
||||
}
|
||||
}
|
||||
if (fresh.length > 0) {
|
||||
this.reheat(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
body(id: string): Body | undefined {
|
||||
return this.index.get(id);
|
||||
}
|
||||
|
||||
reheat(to = 0.4): void {
|
||||
this.alpha = Math.max(this.alpha, to);
|
||||
}
|
||||
|
||||
get settled(): boolean {
|
||||
return this.alpha < 0.004 && this.alphaTarget === 0;
|
||||
}
|
||||
|
||||
// One tick; ``breathe`` adds the slow drift of a graph nobody is touching.
|
||||
step(breathe: boolean): void {
|
||||
this.clock += 1 / 60;
|
||||
const { alpha, bodies } = this;
|
||||
for (const link of this.links) {
|
||||
const dx = link.b.x - link.a.x;
|
||||
const dy = link.b.y - link.a.y;
|
||||
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const rest = LINK_REST + Math.abs(link.a.ring - link.b.ring) * 10;
|
||||
const f = ((d - rest) / d) * LINK_STRENGTH * alpha;
|
||||
link.a.vx += dx * f;
|
||||
link.a.vy += dy * f;
|
||||
link.b.vx -= dx * f;
|
||||
link.b.vy -= dy * f;
|
||||
}
|
||||
for (let i = 0; i < bodies.length; i += 1) {
|
||||
const a = bodies[i];
|
||||
for (let j = i + 1; j < bodies.length; j += 1) {
|
||||
const b = bodies[j];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const d2 = Math.max(dx * dx + dy * dy, MIN_D2);
|
||||
if (d2 > CHARGE_REACH * CHARGE_REACH) {
|
||||
continue;
|
||||
}
|
||||
const f = (CHARGE / d2) * alpha;
|
||||
const d = Math.sqrt(d2);
|
||||
const fx = (dx / d) * f;
|
||||
const fy = (dy / d) * f;
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
b.vx += fx;
|
||||
b.vy += fy;
|
||||
}
|
||||
}
|
||||
for (const body of bodies) {
|
||||
if (body.fixed) {
|
||||
body.vx = 0;
|
||||
body.vy = 0;
|
||||
continue;
|
||||
}
|
||||
if (body.ring === 0) {
|
||||
body.vx -= body.x * HUB_HOLD * alpha;
|
||||
body.vy -= body.y * HUB_HOLD * alpha;
|
||||
} else {
|
||||
const r = Math.max(Math.sqrt(body.x * body.x + body.y * body.y), 1);
|
||||
const pull = ((ringRadius(body.ring) - r) / r) * RADIAL * alpha;
|
||||
body.vx += body.x * pull;
|
||||
body.vy += body.y * pull;
|
||||
}
|
||||
if (breathe) {
|
||||
const seed = hash(body.id) % 11;
|
||||
body.vx += Math.sin(this.clock * 0.6 + seed) * BREATH * 0.02;
|
||||
body.vy += Math.cos(this.clock * 0.5 + seed * 1.3) * BREATH * 0.02;
|
||||
}
|
||||
body.vx *= DECAY;
|
||||
body.vy *= DECAY;
|
||||
body.x += body.vx;
|
||||
body.y += body.vy;
|
||||
}
|
||||
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_EASE;
|
||||
}
|
||||
|
||||
bounds(): { x: number; y: number; w: number; h: number } {
|
||||
if (this.bodies.length === 0) {
|
||||
return { h: 1, w: 1, x: 0, y: 0 };
|
||||
}
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
for (const body of this.bodies) {
|
||||
minX = Math.min(minX, body.x);
|
||||
minY = Math.min(minY, body.y);
|
||||
maxX = Math.max(maxX, body.x);
|
||||
maxY = Math.max(maxY, body.y);
|
||||
}
|
||||
return { h: maxY - minY || 1, w: maxX - minX || 1, x: minX, y: minY };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user