···11// Adapted from star-history (https://github.com/star-history/star-history),
22// MIT-licensed. Original notice retained in /LICENSE-MIT.
33import { scaleLinear, scaleSymlog, scaleTime } from "d3-scale"
44-import { select } from "d3-selection"
44+import { pointer, select } from "d3-selection"
55import { curveMonotoneX, line } from "d3-shape"
66import { AxisScale } from "d3-axis"
77import dayjs from "dayjs"
···147147 // collide and the legend box covers the line. Below the floor the viewBox shrinks the whole drawing
148148 // (smaller text) to fit the container; at/above it the chart renders at native size. The node/embed path
149149 // is never floored: it honours the exact requested size (getChartWidthWithSize owns those widths).
150150- const clientWidth = options.envType === "browser" ? Math.max(measuredWidth, 520) : measuredWidth
151151- const clientHeight = (clientWidth * 2) / 3
150150+ const WIDTH_FLOOR = 520
151151+ const clientWidth = options.envType === "browser" ? Math.max(measuredWidth, WIDTH_FLOOR) : measuredWidth
152152+ // Below the floor, raise the aspect continuously from 3:2 toward square (reached at ≤380px): scaled
153153+ // down to a phone, strict 3:2 leaves a ~230px-tall letterbox. Artifacts (node /svg, exports, the
154154+ // ≥600px iframe embed) all measure at/above the floor, so they keep the canonical 3:2.
155155+ let aspect = 2 / 3
156156+ if (options.envType === "browser" && measuredWidth < WIDTH_FLOOR) {
157157+ aspect += Math.min(1, (WIDTH_FLOOR - measuredWidth) / 140) / 3
158158+ }
159159+ const clientHeight = Math.round(clientWidth * aspect)
152160153161 const d3Selection = select(svg)
154162 .style("stroke-width", 3)
···339347 .attr("r", dotInitSize)
340348 .attr("cx", (d) => xScale(d.x) || 0)
341349 .attr("cy", (d) => yScale(d.y) || 0)
342342- .attr("pointer-events", "all")
343343- // Series + point index, so the keyboard cursor below can address a specific dot
344344- // directly instead of re-deriving position from a DOM-order lookup each time.
350350+ // The cursor rect below owns all pointing; dots are the highlight/tooltip anchors only.
351351+ // They used to carry their own mouseover, but an invisible ~2px target is unhittable on
352352+ // touch and a pixel-hunt with a mouse.
353353+ .attr("pointer-events", "none")
354354+ // Series + point index, so the cursor logic can address a specific dot directly
355355+ // instead of re-deriving position from a DOM-order lookup each time.
345356 .attr("data-series-index", (_, i, nodes) => Number(select(nodes[i].parentElement).attr("xy-group-index")))
346357 .attr("data-point-index", (_, i) => i)
347358 // Invisible at rest so the interactive chart reads as a clean line — matching the
348348- // PNG/SVG export and the embed SVG. `pointer-events: all` keeps it hoverable, and
349349- // the dot fades in on hover as the tooltip's anchor.
359359+ // PNG/SVG export and the embed SVG. The focused dot fades in as the tooltip's anchor.
350360 .style("opacity", 0)
351351- .on("mouseover", (event, d) => {
352352- if (typeof window === "undefined") return
353353- const nodes = event.currentTarget.parentNode.childNodes || []
354354- const i = [...nodes].indexOf(event.target)
355355- const xyGroupIndex = Number(select(nodes[i].parentElement).attr("xy-group-index"))
356356- select(nodes[i]).attr("r", dotHoverSize).style("opacity", 1)
357361358358- const tipX = (xScale(d.x) || 0) + margin.left + 5
359359- const tipY = (yScale(d.y) || 0) + margin.top + 5
360360- let tooltipPositionType: Position = "down_right"
361361- if (tipX > chartWidth / 2 && tipY < chartHeight / 2) {
362362- tooltipPositionType = "down_left"
363363- } else if (tipX > chartWidth / 2 && tipY > chartHeight / 2) {
364364- tooltipPositionType = "up_left"
365365- } else if (tipX < chartWidth / 2 && tipY > chartHeight / 2) {
366366- tooltipPositionType = "up_right"
367367- }
368368-369369- let title = dayjs(data.datasets[xyGroupIndex].data[i].x).format(options.dateFormat)
370370- if (options.xTickLabelType === "Number") {
371371- const type = getTimestampFormatUnit(
372372- Number(data.datasets[xyGroupIndex].data[1].x || data.datasets[xyGroupIndex].data[i].x),
373373- )
374374- title = getFormatTimeline(Number(data.datasets[xyGroupIndex].data[i].x), type)
375375- }
376376-377377- tooltip.update({
378378- title,
379379- items: [
380380- {
381381- color: options.dataColors[xyGroupIndex],
382382- text: `${data.datasets[xyGroupIndex].label || ""}: ${d.y}`,
383383- },
384384- ],
385385- position: {
386386- x: tipX,
387387- y: tipY,
388388- type: tooltipPositionType,
389389- },
390390- selection: d3Selection,
391391- backgroundColor: options.backgroundColor,
392392- strokeColor: options.strokeColor,
393393- })
394394-395395- tooltip.show()
396396- })
397397- .on("mouseout", (event, _d) => {
398398- const nodes = event.currentTarget.parentNode.childNodes || []
399399- if (!nodes.length) return
400400- const i = [...nodes].indexOf(event.target)
401401- select(nodes[i]).attr("r", dotInitSize).style("opacity", 0)
402402- tooltip.hide()
403403- })
404404-405405- // Keyboard access to individual points: mouseover/mouseout alone leave a keyboard user
406406- // with only the coarse sr-only per-series summary and no way to reach one data point.
407407- // One focusable "cursor" over the whole plot (not one stop per point — a repo with a
408408- // year of daily stars would turn Tab into a trap) plus arrow keys to step through it.
362362+ // One interaction "cursor" over the whole plot, shared by keyboard and pointer: Tab + arrow
363363+ // keys step through points (one stop per point would turn Tab into a trap), pointer moves and
364364+ // taps snap to the nearest point.
409365 if (options.envType === "browser" && data.datasets.length > 0) {
410366 let focusedSeriesIndex = 0
411367 let focusedPointIndex = 0
···424380 return `${dataset.label || "Series"}: ${point.y.toLocaleString()} stars on ${label}`
425381 }
426382427427- const focusPoint = (seriesIndex: number, pointIndex: number) => {
383383+ // `announce` mirrors the point into the caller's aria-live region — keyboard steps only.
384384+ // A pointer scrub would flood the live region with per-frame announcements.
385385+ const focusPoint = (seriesIndex: number, pointIndex: number, announce = true) => {
428386 const dataset = data.datasets[seriesIndex]
429387 if (!dataset || dataset.data.length === 0) return
430388 const clampedPoint = clamp(pointIndex, dataset.data.length - 1)
389389+ // Already on this point (pointermove repeats it every frame) — skip the D3 churn.
390390+ if (activeDot && seriesIndex === focusedSeriesIndex && clampedPoint === focusedPointIndex) return
431391 focusedSeriesIndex = seriesIndex
432392 focusedPointIndex = clampedPoint
433393···468428 })
469429 tooltip.show()
470430471471- options.onPointFocus?.(describePoint(seriesIndex, clampedPoint))
431431+ if (announce) options.onPointFocus?.(describePoint(seriesIndex, clampedPoint))
472432 }
473433474434 const blurPoint = () => {
···478438 options.onPointFocus?.(null)
479439 }
480440441441+ // Cursor-x picks each series' nearest point in time, cursor-y picks the series — so the
442442+ // tooltip tracks under a horizontal sweep anywhere in the plot, instead of demanding
443443+ // proximity to a point (2D-nearest anchored the tooltip at a far-away point, which read
444444+ // as "nothing happened" until the pointer was almost on the line).
445445+ const findNearest = (px: number, py: number): { seriesIndex: number; pointIndex: number } | null => {
446446+ let nearest: { seriesIndex: number; pointIndex: number } | null = null
447447+ let nearestYDist = Infinity
448448+ for (let seriesIndex = 0; seriesIndex < data.datasets.length; seriesIndex++) {
449449+ const points = data.datasets[seriesIndex].data
450450+ let xNearest = -1
451451+ let xNearestDist = Infinity
452452+ for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
453453+ const xDist = Math.abs((xScale(points[pointIndex].x) || 0) - px)
454454+ if (xDist < xNearestDist) {
455455+ xNearestDist = xDist
456456+ xNearest = pointIndex
457457+ }
458458+ }
459459+ if (xNearest === -1) continue
460460+ const yDist = Math.abs((yScale(points[xNearest].y) || 0) - py)
461461+ if (yDist < nearestYDist) {
462462+ nearestYDist = yDist
463463+ nearest = { seriesIndex, pointIndex: xNearest }
464464+ }
465465+ }
466466+ return nearest
467467+ }
468468+481469 svgChart
482470 .append("rect")
483471 .attr("class", "xkcd-chart-keyboard-cursor")
···486474 .attr("width", chartWidth)
487475 .attr("height", chartHeight)
488476 .attr("fill", "transparent")
489489- // Keyboard-only: the mouse already reaches every dot directly, so this stays out
490490- // of the way of pointer/hover interaction and exists purely as a Tab stop.
491491- .attr("pointer-events", "none")
477477+ .attr("pointer-events", "all")
478478+ // pan-y hands vertical touch drags to the page scroll (the chart must not trap
479479+ // scrolling); horizontal drags stay ours and scrub the chart.
480480+ .style("touch-action", "pan-y pinch-zoom")
492481 .attr("tabindex", 0)
493482 .attr("aria-label", "Chart data. Use arrow keys to move between points, Home and End for the ends.")
494483 .on("focus", () => focusPoint(focusedSeriesIndex, focusedPointIndex))
495484 .on("blur", blurPoint)
485485+ .on("pointermove pointerdown", (event: PointerEvent) => {
486486+ // d3.pointer maps client coords through the inverse CTM, so this stays correct
487487+ // when the viewBox scales the drawing down on a phone.
488488+ const [px, py] = pointer(event, svgChart.node())
489489+ const nearest = findNearest(px, py)
490490+ if (nearest) focusPoint(nearest.seriesIndex, nearest.pointIndex, false)
491491+ })
492492+ .on("pointerleave", (event: PointerEvent) => {
493493+ // Mouse only: a tap's pointer always "leaves" on finger lift, which would kill
494494+ // the tooltip the tap just opened. Touch clears via blur (tap-away) instead —
495495+ // the tap focused the rect — or via pointercancel when a scroll takes over.
496496+ if (event.pointerType === "mouse") blurPoint()
497497+ })
498498+ .on("pointercancel", blurPoint)
496499 .on("keydown", (event: KeyboardEvent) => {
497500 const seriesCount = data.datasets.length
498501 switch (event.key) {