Star history for tangled repositories tangled-stars.com
atproto git tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

Add pointer scrubbing, per-day star curves, and a taller phone chart

author
juprodh.me
date (Jul 22, 2026, 11:29 AM +0800) commit db94b683 parent 14d38840 change-id wxzuwzwo
+83 -95
+8 -18
shared/common/api.ts
··· 460 460 return counts.reduce((a, b) => a + b, 0) 461 461 } 462 462 463 - async function getRepoStarRecords( 464 - repo: string, 465 - maxRequestAmount: number, 466 - ): Promise<{ date: string; count: number }[]> { 463 + async function getRepoStarRecords(repo: string): Promise<{ date: string; count: number }[]> { 467 464 if (useBackend()) { 468 465 const data = await fetchRepoFromBackend(repo) 469 466 // Match the direct path: no stars throws empty-data so the UI shows "no star history". ··· 496 493 throw { status: 200, data: [] } 497 494 } 498 495 499 - const totalStars = rkeys.length 496 + // Per-day cumulative curve, same as the backend's buildStarRecords — the two data paths must 497 + // draw identical charts. (This path used to sample down to ~15 points: a vestige of star-history's 498 + // paginated GitHub fetches. Constellation already gave us every rkey; sampling only lost resolution.) 500 499 const starRecordsMap = new Map<string, number>() 501 - 502 - if (totalStars <= maxRequestAmount) { 503 - rkeys.forEach((rkey, i) => { 504 - starRecordsMap.set(utils.getDateString(decodeTidTimestamp(rkey)), i + 1) 505 - }) 506 - } else { 507 - for (let i = 0; i < maxRequestAmount; i++) { 508 - const idx = Math.round((i * totalStars) / maxRequestAmount) 509 - starRecordsMap.set(utils.getDateString(decodeTidTimestamp(rkeys[idx])), idx + 1) 510 - } 511 - } 512 - 513 - starRecordsMap.set(utils.getDateString(Date.now()), totalStars) 500 + rkeys.forEach((rkey, i) => { 501 + starRecordsMap.set(utils.getDateString(decodeTidTimestamp(rkey)), i + 1) 502 + }) 503 + starRecordsMap.set(utils.getDateString(Date.now()), rkeys.length) 514 504 515 505 const starRecords: { date: string; count: number }[] = [] 516 506 starRecordsMap.forEach((count, date) => {
+2 -7
shared/common/chart.tsx
··· 9 9 insertZeroPoint?: boolean 10 10 } 11 11 12 - export const DEFAULT_MAX_REQUEST_AMOUNT = 15 13 - 14 12 export interface RepoError { 15 13 message: string 16 14 status: number ··· 39 37 // Each repo is fetched independently, so one bad repo in a multi-repo comparison can never 40 38 // evict the others' already-fetched data — a batch that shares a single try/catch (and returns 41 39 // or rejects on the first failure) silently drops every repo fetched before the one that failed. 42 - export const getRepoData = async ( 43 - repos: string[], 44 - maxRequestAmount = DEFAULT_MAX_REQUEST_AMOUNT, 45 - ): Promise<RepoFetchResult> => { 40 + export const getRepoData = async (repos: string[]): Promise<RepoFetchResult> => { 46 41 const results = await Promise.all( 47 42 repos.map(async (repo): Promise<{ data?: RepoData; error?: RepoError }> => { 48 43 try { 49 44 const [starRecords, logo] = await Promise.all([ 50 - api.getRepoStarRecords(repo, maxRequestAmount), 45 + api.getRepoStarRecords(repo), 51 46 api.getRepoLogoUrl(repo), 52 47 ]) 53 48 return { data: { repo, starRecords, logoUrl: logo } }
+73 -70
shared/packages/xy-chart.tsx
··· 1 1 // Adapted from star-history (https://github.com/star-history/star-history), 2 2 // MIT-licensed. Original notice retained in /LICENSE-MIT. 3 3 import { scaleLinear, scaleSymlog, scaleTime } from "d3-scale" 4 - import { select } from "d3-selection" 4 + import { pointer, select } from "d3-selection" 5 5 import { curveMonotoneX, line } from "d3-shape" 6 6 import { AxisScale } from "d3-axis" 7 7 import dayjs from "dayjs" ··· 147 147 // collide and the legend box covers the line. Below the floor the viewBox shrinks the whole drawing 148 148 // (smaller text) to fit the container; at/above it the chart renders at native size. The node/embed path 149 149 // is never floored: it honours the exact requested size (getChartWidthWithSize owns those widths). 150 - const clientWidth = options.envType === "browser" ? Math.max(measuredWidth, 520) : measuredWidth 151 - const clientHeight = (clientWidth * 2) / 3 150 + const WIDTH_FLOOR = 520 151 + const clientWidth = options.envType === "browser" ? Math.max(measuredWidth, WIDTH_FLOOR) : measuredWidth 152 + // Below the floor, raise the aspect continuously from 3:2 toward square (reached at ≤380px): scaled 153 + // down to a phone, strict 3:2 leaves a ~230px-tall letterbox. Artifacts (node /svg, exports, the 154 + // ≥600px iframe embed) all measure at/above the floor, so they keep the canonical 3:2. 155 + let aspect = 2 / 3 156 + if (options.envType === "browser" && measuredWidth < WIDTH_FLOOR) { 157 + aspect += Math.min(1, (WIDTH_FLOOR - measuredWidth) / 140) / 3 158 + } 159 + const clientHeight = Math.round(clientWidth * aspect) 152 160 153 161 const d3Selection = select(svg) 154 162 .style("stroke-width", 3) ··· 339 347 .attr("r", dotInitSize) 340 348 .attr("cx", (d) => xScale(d.x) || 0) 341 349 .attr("cy", (d) => yScale(d.y) || 0) 342 - .attr("pointer-events", "all") 343 - // Series + point index, so the keyboard cursor below can address a specific dot 344 - // directly instead of re-deriving position from a DOM-order lookup each time. 350 + // The cursor rect below owns all pointing; dots are the highlight/tooltip anchors only. 351 + // They used to carry their own mouseover, but an invisible ~2px target is unhittable on 352 + // touch and a pixel-hunt with a mouse. 353 + .attr("pointer-events", "none") 354 + // Series + point index, so the cursor logic can address a specific dot directly 355 + // instead of re-deriving position from a DOM-order lookup each time. 345 356 .attr("data-series-index", (_, i, nodes) => Number(select(nodes[i].parentElement).attr("xy-group-index"))) 346 357 .attr("data-point-index", (_, i) => i) 347 358 // Invisible at rest so the interactive chart reads as a clean line — matching the 348 - // PNG/SVG export and the embed SVG. `pointer-events: all` keeps it hoverable, and 349 - // the dot fades in on hover as the tooltip's anchor. 359 + // PNG/SVG export and the embed SVG. The focused dot fades in as the tooltip's anchor. 350 360 .style("opacity", 0) 351 - .on("mouseover", (event, d) => { 352 - if (typeof window === "undefined") return 353 - const nodes = event.currentTarget.parentNode.childNodes || [] 354 - const i = [...nodes].indexOf(event.target) 355 - const xyGroupIndex = Number(select(nodes[i].parentElement).attr("xy-group-index")) 356 - select(nodes[i]).attr("r", dotHoverSize).style("opacity", 1) 357 361 358 - const tipX = (xScale(d.x) || 0) + margin.left + 5 359 - const tipY = (yScale(d.y) || 0) + margin.top + 5 360 - let tooltipPositionType: Position = "down_right" 361 - if (tipX > chartWidth / 2 && tipY < chartHeight / 2) { 362 - tooltipPositionType = "down_left" 363 - } else if (tipX > chartWidth / 2 && tipY > chartHeight / 2) { 364 - tooltipPositionType = "up_left" 365 - } else if (tipX < chartWidth / 2 && tipY > chartHeight / 2) { 366 - tooltipPositionType = "up_right" 367 - } 368 - 369 - let title = dayjs(data.datasets[xyGroupIndex].data[i].x).format(options.dateFormat) 370 - if (options.xTickLabelType === "Number") { 371 - const type = getTimestampFormatUnit( 372 - Number(data.datasets[xyGroupIndex].data[1].x || data.datasets[xyGroupIndex].data[i].x), 373 - ) 374 - title = getFormatTimeline(Number(data.datasets[xyGroupIndex].data[i].x), type) 375 - } 376 - 377 - tooltip.update({ 378 - title, 379 - items: [ 380 - { 381 - color: options.dataColors[xyGroupIndex], 382 - text: `${data.datasets[xyGroupIndex].label || ""}: ${d.y}`, 383 - }, 384 - ], 385 - position: { 386 - x: tipX, 387 - y: tipY, 388 - type: tooltipPositionType, 389 - }, 390 - selection: d3Selection, 391 - backgroundColor: options.backgroundColor, 392 - strokeColor: options.strokeColor, 393 - }) 394 - 395 - tooltip.show() 396 - }) 397 - .on("mouseout", (event, _d) => { 398 - const nodes = event.currentTarget.parentNode.childNodes || [] 399 - if (!nodes.length) return 400 - const i = [...nodes].indexOf(event.target) 401 - select(nodes[i]).attr("r", dotInitSize).style("opacity", 0) 402 - tooltip.hide() 403 - }) 404 - 405 - // Keyboard access to individual points: mouseover/mouseout alone leave a keyboard user 406 - // with only the coarse sr-only per-series summary and no way to reach one data point. 407 - // One focusable "cursor" over the whole plot (not one stop per point — a repo with a 408 - // year of daily stars would turn Tab into a trap) plus arrow keys to step through it. 362 + // One interaction "cursor" over the whole plot, shared by keyboard and pointer: Tab + arrow 363 + // keys step through points (one stop per point would turn Tab into a trap), pointer moves and 364 + // taps snap to the nearest point. 409 365 if (options.envType === "browser" && data.datasets.length > 0) { 410 366 let focusedSeriesIndex = 0 411 367 let focusedPointIndex = 0 ··· 424 380 return `${dataset.label || "Series"}: ${point.y.toLocaleString()} stars on ${label}` 425 381 } 426 382 427 - const focusPoint = (seriesIndex: number, pointIndex: number) => { 383 + // `announce` mirrors the point into the caller's aria-live region — keyboard steps only. 384 + // A pointer scrub would flood the live region with per-frame announcements. 385 + const focusPoint = (seriesIndex: number, pointIndex: number, announce = true) => { 428 386 const dataset = data.datasets[seriesIndex] 429 387 if (!dataset || dataset.data.length === 0) return 430 388 const clampedPoint = clamp(pointIndex, dataset.data.length - 1) 389 + // Already on this point (pointermove repeats it every frame) — skip the D3 churn. 390 + if (activeDot && seriesIndex === focusedSeriesIndex && clampedPoint === focusedPointIndex) return 431 391 focusedSeriesIndex = seriesIndex 432 392 focusedPointIndex = clampedPoint 433 393 ··· 468 428 }) 469 429 tooltip.show() 470 430 471 - options.onPointFocus?.(describePoint(seriesIndex, clampedPoint)) 431 + if (announce) options.onPointFocus?.(describePoint(seriesIndex, clampedPoint)) 472 432 } 473 433 474 434 const blurPoint = () => { ··· 478 438 options.onPointFocus?.(null) 479 439 } 480 440 441 + // Cursor-x picks each series' nearest point in time, cursor-y picks the series — so the 442 + // tooltip tracks under a horizontal sweep anywhere in the plot, instead of demanding 443 + // proximity to a point (2D-nearest anchored the tooltip at a far-away point, which read 444 + // as "nothing happened" until the pointer was almost on the line). 445 + const findNearest = (px: number, py: number): { seriesIndex: number; pointIndex: number } | null => { 446 + let nearest: { seriesIndex: number; pointIndex: number } | null = null 447 + let nearestYDist = Infinity 448 + for (let seriesIndex = 0; seriesIndex < data.datasets.length; seriesIndex++) { 449 + const points = data.datasets[seriesIndex].data 450 + let xNearest = -1 451 + let xNearestDist = Infinity 452 + for (let pointIndex = 0; pointIndex < points.length; pointIndex++) { 453 + const xDist = Math.abs((xScale(points[pointIndex].x) || 0) - px) 454 + if (xDist < xNearestDist) { 455 + xNearestDist = xDist 456 + xNearest = pointIndex 457 + } 458 + } 459 + if (xNearest === -1) continue 460 + const yDist = Math.abs((yScale(points[xNearest].y) || 0) - py) 461 + if (yDist < nearestYDist) { 462 + nearestYDist = yDist 463 + nearest = { seriesIndex, pointIndex: xNearest } 464 + } 465 + } 466 + return nearest 467 + } 468 + 481 469 svgChart 482 470 .append("rect") 483 471 .attr("class", "xkcd-chart-keyboard-cursor") ··· 486 474 .attr("width", chartWidth) 487 475 .attr("height", chartHeight) 488 476 .attr("fill", "transparent") 489 - // Keyboard-only: the mouse already reaches every dot directly, so this stays out 490 - // of the way of pointer/hover interaction and exists purely as a Tab stop. 491 - .attr("pointer-events", "none") 477 + .attr("pointer-events", "all") 478 + // pan-y hands vertical touch drags to the page scroll (the chart must not trap 479 + // scrolling); horizontal drags stay ours and scrub the chart. 480 + .style("touch-action", "pan-y pinch-zoom") 492 481 .attr("tabindex", 0) 493 482 .attr("aria-label", "Chart data. Use arrow keys to move between points, Home and End for the ends.") 494 483 .on("focus", () => focusPoint(focusedSeriesIndex, focusedPointIndex)) 495 484 .on("blur", blurPoint) 485 + .on("pointermove pointerdown", (event: PointerEvent) => { 486 + // d3.pointer maps client coords through the inverse CTM, so this stays correct 487 + // when the viewBox scales the drawing down on a phone. 488 + const [px, py] = pointer(event, svgChart.node()) 489 + const nearest = findNearest(px, py) 490 + if (nearest) focusPoint(nearest.seriesIndex, nearest.pointIndex, false) 491 + }) 492 + .on("pointerleave", (event: PointerEvent) => { 493 + // Mouse only: a tap's pointer always "leaves" on finger lift, which would kill 494 + // the tooltip the tap just opened. Touch clears via blur (tap-away) instead — 495 + // the tap focused the rect — or via pointercancel when a scroll takes over. 496 + if (event.pointerType === "mouse") blurPoint() 497 + }) 498 + .on("pointercancel", blurPoint) 496 499 .on("keydown", (event: KeyboardEvent) => { 497 500 const seriesCount = data.datasets.length 498 501 switch (event.key) {