// Discover's ranked shelf: one list across the signals the page already // draws in silos — the room you are in, the people your transfers touched, // the server's similar-interests answer, the library overlaps computed on // this device, the roster of the Space you have open. Nothing here reads // the network; every input is a store the page holds already. // // The page's rule holds: every shelf says where its people came from. A // ranked card is not exempt — its `reasons` are the shelves it drew from, // strongest first, and the template names each one. Contacts and friends // are left out: this is for people you have not sorted yet. // // The weights are the whole opinion, in one place so they can be argued // with. Each signal is capped so one huge browse cannot bury everything. export const WEIGHTS = { /** Per album you both own (overlap), up to `CAPS.albums`. */ albumInCommon: 3, /** Per album of theirs by an artist you follow that you lack. */ gap: 1, /** Per artist in common, only when no album is (a weaker overlap). */ artistInCommon: 0.5, /** Per declared like the server says you share. */ like: 2, /** In the room you are looking at right now. */ activeRoom: 2, /** Per other joined room they are in. */ room: 1, /** Per file, either direction, this session. */ transfer: 1, /** A seat beside yours in the open Space. */ spaceMember: 3, }; export const CAPS = { albums: 10, gaps: 10, artists: 10, rooms: 3, transfers: 5 }; const cap = (n, max) => Math.min(Math.max(n ?? 0, 0), max); /** * @param {object} input * @param {string} input.me our own username (never listed) * @param {(u: string) => boolean} [input.skip] contacts, friends — people already sorted * @param {Record} [input.rooms] joined rooms by name * @param {string|null} [input.activeRoom] * @param {{username?: string}[]} [input.downloads] this session's downloads * @param {{username?: string}[]} [input.uploads] this session's uploads * @param {{username: string, rating: number}[]} [input.similar] * @param {{username: string, albums_in_common_count?: number, gaps_count?: number, artists_in_common_count?: number}[]} [input.overlap] * @param {{name: string, members: {username?: string|null, is_me?: boolean}[]}|null} [input.space] the open Space * @param {number} [input.limit] * @returns {{username: string, score: number, reasons: object[]}[]} best first */ export function rankPeople(input) { const { me = "", skip = () => false, limit = 12 } = input; const people = new Map(); const at = (u) => { if (!u || u === me || skip(u)) return null; let p = people.get(u); if (!p) people.set(u, (p = { username: u, score: 0, reasons: [] })); return p; }; const add = (u, points, reason) => { const p = at(u); if (!p || points <= 0) return; p.score += points; p.reasons.push({ ...reason, points }); }; for (const o of input.overlap ?? []) { const albums = cap(o.albums_in_common_count, CAPS.albums); const gaps = cap(o.gaps_count, CAPS.gaps); const artists = albums ? 0 : cap(o.artists_in_common_count, CAPS.artists); const points = albums * WEIGHTS.albumInCommon + gaps * WEIGHTS.gap + artists * WEIGHTS.artistInCommon; add(o.username, points, { kind: "overlap", albums: o.albums_in_common_count ?? 0, gaps: o.gaps_count ?? 0, artists: o.artists_in_common_count ?? 0, }); } for (const su of input.similar ?? []) { add(su.username, (su.rating ?? 0) * WEIGHTS.like, { kind: "likes", n: su.rating ?? 0 }); } // Rooms: the one on screen counts most; the rest count once each. const roomsOf = new Map(); for (const [name, room] of Object.entries(input.rooms ?? {})) { for (const u of room?.users ?? []) { if (!u?.username) continue; if (!roomsOf.has(u.username)) roomsOf.set(u.username, []); roomsOf.get(u.username).push(name); } } for (const [u, names] of roomsOf) { const active = input.activeRoom && names.includes(input.activeRoom) ? input.activeRoom : null; const others = names.filter((n) => n !== active); const points = (active ? WEIGHTS.activeRoom : 0) + cap(others.length, CAPS.rooms) * WEIGHTS.room; add(u, points, { kind: "rooms", active, others }); } const tally = new Map(); const count = (u, side) => { if (!u) return; if (!tally.has(u)) tally.set(u, { from: 0, to: 0 }); tally.get(u)[side] += 1; }; for (const d of input.downloads ?? []) count(d?.username, "from"); for (const x of input.uploads ?? []) count(x?.username, "to"); for (const [u, t] of tally) { add(u, cap(t.from + t.to, CAPS.transfers) * WEIGHTS.transfer, { kind: "transfers", from: t.from, to: t.to }); } const space = input.space; for (const m of space?.members ?? []) { if (!m?.username || m.is_me) continue; add(m.username, WEIGHTS.spaceMember, { kind: "space", name: space.name }); } const out = [...people.values()].filter((p) => p.score > 0); // Reasons strongest first; a tie keeps the order the signals were read // in above, which is the order of how much each one says about taste. for (const p of out) p.reasons.sort((a, b) => b.points - a.points); out.sort((a, b) => b.score - a.score || a.reasons.length - b.reasons.length || a.username.localeCompare(b.username)); return out.slice(0, limit); } /** A reason as the card draws it: segments, the numbers and names bold. * The wording is each source shelf's own, so a reason reads the same here * as where it came from. */ export function reasonParts(r) { const b = (t) => ({ t: String(t), b: true }); const t = (s) => ({ t: s, b: false }); const n = (k, one, many) => [b(k), t(` ${k === 1 ? one : many}`)]; switch (r.kind) { case "overlap": if (r.albums) return [t("shares "), ...n(r.albums, "album", "albums"), t(" you own"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" you lack")] : [])]; if (r.artists) return [...n(r.artists, "artist", "artists"), t(" in common"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" albums you lack")] : [])]; return [t("browsed, nothing in common")]; case "likes": return [t("shares "), b(r.n), t(" of your declared likes")]; case "rooms": { const parts = []; if (r.active) parts.push(t("in "), b(`#${r.active}`), t(" now")); if (r.others.length) { if (parts.length) parts.push(t(" · ")); if (r.others.length === 1) parts.push(t("also in "), b(`#${r.others[0]}`)); else parts.push(t("in "), b(r.others.length), t(" rooms with you")); } return parts; } case "transfers": { const parts = []; if (r.from) parts.push(t("sent you "), ...n(r.from, "file", "files")); if (r.from && r.to) parts.push(t(" · ")); if (r.to) parts.push(t("you sent them "), b(r.to)); return parts; } case "space": return [t("with you in "), b(r.name)]; default: return []; } } /** A reason in words — what a test reads, and what the card says. */ export const reasonText = (r) => reasonParts(r).map((s) => s.t).join("");