Skip to content

Commit 2b75c59

Browse files
committed
add repository distribution chart to dataset stats
1 parent 2c369fe commit 2b75c59

2 files changed

Lines changed: 380 additions & 0 deletions

File tree

src/components/sections/DatasetStatistics.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { getContext } from "svelte";
33
import SectionShell from "$components/sections/SectionShell.svelte";
44
import GrowthHistogram from "$components/sections/dataset-statistics/GrowthHistogram.svelte";
5+
import RepoLongTail from "$components/sections/dataset-statistics/RepoLongTail.svelte";
56
import RepoTreemap from "$components/sections/dataset-statistics/RepoTreemap.svelte";
67
import syncMeta from "$data/sync-metadata.json";
78
@@ -40,6 +41,7 @@
4041
4142
<div class="stack">
4243
<GrowthHistogram />
44+
<RepoLongTail />
4345
<RepoTreemap />
4446
</div>
4547
</div>
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
<script>
2+
import dashboard from "$data/dashboard.json";
3+
4+
const fmt = new Intl.NumberFormat("en-US");
5+
6+
const sorted = dashboard.repository_stars
7+
.slice()
8+
.sort((a, b) => b.stars - a.stars);
9+
const total = sorted.reduce((s, r) => s + r.stars, 0);
10+
const peakCount = sorted[0]?.stars ?? 1;
11+
12+
let cumulative = 0;
13+
const enriched = sorted.map((r, i) => {
14+
cumulative += r.stars;
15+
return {
16+
rank: i + 1,
17+
repository: r.repository,
18+
stars: r.stars,
19+
cumShare: cumulative / total
20+
};
21+
});
22+
23+
function repoToCover(target) {
24+
const hit = enriched.find((r) => r.cumShare >= target);
25+
return hit ? hit.rank : enriched.length;
26+
}
27+
28+
const reposFor50 = repoToCover(0.5);
29+
const reposFor80 = repoToCover(0.8);
30+
31+
function niceStep(peak, targetTicks = 4) {
32+
const rough = peak / targetTicks;
33+
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
34+
const norm = rough / magnitude;
35+
let step;
36+
if (norm < 1.5) step = magnitude;
37+
else if (norm < 3) step = 2 * magnitude;
38+
else if (norm < 7) step = 5 * magnitude;
39+
else step = 10 * magnitude;
40+
return step;
41+
}
42+
43+
const yStep = niceStep(peakCount);
44+
const axisMax = Math.ceil(peakCount / yStep) * yStep;
45+
const yTicks = [];
46+
for (let v = yStep; v < axisMax; v += yStep) yTicks.push(v);
47+
48+
const axisTicks = (() => {
49+
const n = enriched.length;
50+
if (n === 0) return [];
51+
const ranks = [
52+
1,
53+
Math.max(1, Math.round(n * 0.25)),
54+
Math.max(1, Math.round(n * 0.5)),
55+
Math.max(1, Math.round(n * 0.75)),
56+
n
57+
];
58+
const seen = new Set();
59+
return ranks
60+
.filter((r) => {
61+
if (seen.has(r)) return false;
62+
seen.add(r);
63+
return true;
64+
})
65+
.map((r) => ({
66+
rank: r,
67+
leftPct: n === 1 ? 0 : ((r - 1) / (n - 1)) * 100
68+
}));
69+
})();
70+
71+
let wrap;
72+
let hovered = null;
73+
let tipX = 0;
74+
let tipY = 0;
75+
let flipX = false;
76+
77+
function hoverBar(r, ev) {
78+
hovered = r;
79+
const rect = wrap.getBoundingClientRect();
80+
tipX = ev.clientX - rect.left;
81+
tipY = ev.clientY - rect.top;
82+
flipX = tipX > rect.width - 220;
83+
}
84+
85+
function clearHover() {
86+
hovered = null;
87+
}
88+
89+
function shortRepo(name) {
90+
if (!name) return "";
91+
const parts = name.split("/");
92+
return parts.length === 2 ? parts[1] : name;
93+
}
94+
</script>
95+
96+
<div class="longtail-block">
97+
<div class="histogram-wrap" bind:this={wrap}>
98+
<div class="histogram-meta">
99+
<span class="histogram-title">Repository distribution</span>
100+
<span class="histogram-meta-stat">
101+
Top {reposFor50} of {enriched.length} repos → 50% of stars · Top
102+
{reposFor80} → 80%
103+
</span>
104+
</div>
105+
<div class="chart-area">
106+
<div class="y-axis-label" aria-hidden="true">
107+
<span>Popularity</span>
108+
</div>
109+
<div class="y-axis" aria-hidden="true">
110+
{#each yTicks as t}
111+
<span class="y-tick" style="bottom: {(t / axisMax) * 100}%">
112+
{t}
113+
</span>
114+
{/each}
115+
</div>
116+
<div class="plot">
117+
<div class="gridlines" aria-hidden="true">
118+
{#each yTicks as t}
119+
<div class="gridline" style="bottom: {(t / axisMax) * 100}%"></div>
120+
{/each}
121+
</div>
122+
<div
123+
class="histogram"
124+
role="img"
125+
aria-label="Tasks per repository, sorted descending"
126+
on:mouseleave={clearHover}
127+
>
128+
{#each enriched as r, i}
129+
<div
130+
class="bar"
131+
class:active={hovered === r}
132+
style="height: {(r.stars / axisMax) * 100}%; --i: {i}"
133+
aria-label={`${r.repository}: ${r.stars} star${r.stars === 1 ? "" : "s"}`}
134+
on:mouseenter={(e) => hoverBar(r, e)}
135+
on:mousemove={(e) => hoverBar(r, e)}
136+
></div>
137+
{/each}
138+
</div>
139+
<div class="histogram-axis-row">
140+
{#each axisTicks as t, i}
141+
<span
142+
class="tick"
143+
class:tick-start={i === 0}
144+
class:tick-end={i === axisTicks.length - 1}
145+
style="left: {t.leftPct}%"
146+
>
147+
#{t.rank}
148+
</span>
149+
{/each}
150+
</div>
151+
</div>
152+
</div>
153+
<div class="axis-caption">
154+
<span>Repository rank by star count (descending)</span>
155+
</div>
156+
157+
{#if hovered}
158+
<div
159+
class="tooltip"
160+
class:flip-x={flipX}
161+
style="left: {tipX}px; top: {tipY}px;"
162+
role="tooltip"
163+
>
164+
<div class="tip-repo">{shortRepo(hovered.repository)}</div>
165+
<div class="tip-count">
166+
<strong>{fmt.format(hovered.stars)}</strong> star{hovered.stars === 1
167+
? ""
168+
: "s"}
169+
<span class="tip-pct">
170+
(rank #{hovered.rank} · {(
171+
(hovered.stars / total) *
172+
100
173+
).toFixed(1)}%)
174+
</span>
175+
</div>
176+
</div>
177+
{/if}
178+
</div>
179+
</div>
180+
181+
<style>
182+
.longtail-block {
183+
display: flex;
184+
flex-direction: column;
185+
}
186+
187+
.histogram-wrap {
188+
position: relative;
189+
padding: var(--space-md) var(--space-md) var(--space-sm);
190+
background: var(--bg-secondary);
191+
border: 1px solid var(--border-primary);
192+
border-radius: var(--radius);
193+
}
194+
195+
.histogram-meta {
196+
display: flex;
197+
justify-content: space-between;
198+
font-family: var(--sans);
199+
font-size: 0.75rem;
200+
color: var(--text-muted);
201+
margin-bottom: var(--space-sm);
202+
flex-wrap: wrap;
203+
gap: 4px;
204+
}
205+
206+
.histogram-title {
207+
font-weight: 600;
208+
text-transform: uppercase;
209+
letter-spacing: 0.05em;
210+
}
211+
212+
.histogram-meta-stat {
213+
font-family: var(--mono);
214+
font-size: 0.72rem;
215+
}
216+
217+
.chart-area {
218+
display: flex;
219+
gap: 8px;
220+
align-items: stretch;
221+
}
222+
223+
.y-axis-label {
224+
flex-shrink: 0;
225+
position: relative;
226+
width: 16px;
227+
height: 160px;
228+
}
229+
230+
.y-axis-label span {
231+
position: absolute;
232+
top: 50%;
233+
left: 50%;
234+
transform: translate(-50%, -50%) rotate(-90deg);
235+
transform-origin: center;
236+
white-space: nowrap;
237+
font-family: var(--sans);
238+
font-size: 0.7rem;
239+
font-weight: 600;
240+
letter-spacing: 0.04em;
241+
color: var(--text-muted);
242+
text-transform: uppercase;
243+
}
244+
245+
.y-axis {
246+
position: relative;
247+
width: 40px;
248+
flex-shrink: 0;
249+
height: 160px;
250+
font-family: var(--mono);
251+
font-size: 0.65rem;
252+
color: var(--text-muted);
253+
}
254+
255+
.y-tick {
256+
position: absolute;
257+
right: 0;
258+
transform: translateY(50%);
259+
line-height: 1;
260+
text-align: right;
261+
}
262+
263+
.plot {
264+
position: relative;
265+
flex: 1 1 0;
266+
min-width: 0;
267+
}
268+
269+
.gridlines {
270+
position: absolute;
271+
inset: 0 0 auto 0;
272+
height: 160px;
273+
pointer-events: none;
274+
}
275+
276+
.gridline {
277+
position: absolute;
278+
left: 0;
279+
right: 0;
280+
height: 1px;
281+
background: var(--border-primary);
282+
opacity: 0.5;
283+
}
284+
285+
.histogram {
286+
position: relative;
287+
display: flex;
288+
align-items: flex-end;
289+
gap: 1px;
290+
height: 160px;
291+
padding: 4px 0;
292+
border-bottom: 1px solid var(--border-primary);
293+
}
294+
295+
.bar {
296+
flex: 1 1 0;
297+
min-width: 0;
298+
background: var(--brand-red);
299+
border-radius: 1px 1px 0 0;
300+
cursor: default;
301+
transition:
302+
background 120ms,
303+
opacity 120ms;
304+
}
305+
306+
.histogram:hover .bar:not(.active) {
307+
opacity: 0.55;
308+
}
309+
310+
.bar.active,
311+
.bar:hover {
312+
background: var(--brand-red-dark);
313+
}
314+
315+
.tooltip {
316+
position: absolute;
317+
transform: translate(12px, 12px);
318+
pointer-events: none;
319+
background: var(--text-primary);
320+
color: #fff;
321+
padding: 8px 12px;
322+
border-radius: var(--radius-sm);
323+
font-family: var(--sans);
324+
font-size: 0.82rem;
325+
line-height: 1.35;
326+
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.18);
327+
z-index: 5;
328+
white-space: nowrap;
329+
}
330+
331+
.tooltip.flip-x {
332+
transform: translate(calc(-100% - 12px), 12px);
333+
}
334+
335+
.tip-repo {
336+
font-family: var(--mono);
337+
font-weight: 700;
338+
margin-bottom: 2px;
339+
}
340+
341+
.tip-pct {
342+
color: rgba(255, 255, 255, 0.65);
343+
font-size: 0.78rem;
344+
margin-left: 4px;
345+
}
346+
347+
.histogram-axis-row {
348+
position: relative;
349+
height: 1em;
350+
margin-top: 4px;
351+
font-family: var(--mono);
352+
font-size: 0.65rem;
353+
color: var(--text-muted);
354+
}
355+
356+
.tick {
357+
position: absolute;
358+
top: 0;
359+
transform: translateX(-50%);
360+
white-space: nowrap;
361+
}
362+
363+
.tick.tick-start {
364+
transform: translateX(0);
365+
}
366+
367+
.tick.tick-end {
368+
transform: translateX(-100%);
369+
}
370+
371+
.axis-caption {
372+
font-family: var(--sans);
373+
font-size: 0.7rem;
374+
color: var(--text-muted);
375+
text-align: center;
376+
margin-top: 4px;
377+
}
378+
</style>

0 commit comments

Comments
 (0)