// Daily store analytics as lines, drawn with visx (scales, axes, shapes, gradient, tooltip, responsive width). Reach // (impressions, visitors), installs and conversion differ by one to three orders of magnitude, so on one count axis // installs lay flat at the bottom. The chart therefore plots each line as an index (100 = that line's own average over // the days shown, views.ts indexSeries) on one shared axis with a dashed 100 baseline: every line uses the full height // and a listing change shows as lines moving apart. A crosshair tooltip (pointer, or arrow keys when focused) shows the // real values of one day and the store changes from git made that day; changes are dashed markers. import { AxisBottom, AxisLeft } from '@visx/axis'; import { curveMonotoneX } from '@visx/curve'; import { LinearGradient } from '@visx/gradient'; import { Group } from '@visx/group'; import { useParentSize } from '@visx/responsive'; import { scaleLinear } from '@visx/scale'; import { AreaClosed, Bar, Circle, Line, LinePath } from '@visx/shape'; import { useId } from 'react'; import { date, num, pct } from '../lib/format'; import { indexSeries } from '../lib/views'; import { ChartTip, useIndexHover } from './charts'; export interface ChartLine { label: string; /** Real daily values (counts, or a 0-1 rate with unit "rate"); plotted as an index, shown as they are on hover. */ values: (number | null)[]; cls: 'l1' | 'l2' | 'l3'; unit: 'count' | 'rate'; } const M = { top: 14, right: 18, bottom: 26, left: 46 }; /** Index axis: a round top above the highest point (at least 150 so 100 sits in the lower two thirds) and 4 ticks. */ export function indexAxis(lines: (number | null)[][]): { max: number; ticks: number[] } { const hi = Math.max(150, ...lines.flat().filter((v): v is number => v !== null)); const step = hi <= 200 ? 50 : hi <= 400 ? 100 : Math.ceil(hi / 4 / 100) * 100; const max = Math.ceil(hi / step) * step; return { max, ticks: Array.from({ length: max / step + 1 }, (_, i) => i * step) }; } /** Up to `most` evenly spread day indexes, always the first and the last. */ export function dateTicks(n: number, most: number): number[] { if (n <= 1) return [0]; const k = Math.max(2, Math.min(n, most)); return [...new Set(Array.from({ length: k }, (_, i) => Math.round((i * (n - 1)) / (k - 1))))]; } export function LineChart({ dates, lines, events, label, eventsLabel, indexLabel }: { dates: string[]; lines: ChartLine[]; events: { date: string; subject: string }[]; label: string; eventsLabel: string; indexLabel: string; }) { const gid = `lg${useId().replace(/:/g, '')}`; const { parentRef, width } = useParentSize({ debounceTime: 50 }); const n = dates.length; const h = useIndexHover(n, width, M.left, M.right); if (n < 2) return null; // responsive: the height follows the width between 180 and 260 px const height = Math.round(Math.max(180, Math.min(260, width * 0.3))); const plotted = lines.map(l => indexSeries(l.values)); const { max, ticks } = indexAxis(plotted); const x = scaleLinear({ domain: [0, n - 1], range: [M.left, Math.max(M.left + 1, width - M.right)] }); const y = scaleLinear({ domain: [0, max], range: [height - M.bottom, M.top] }); const pts = (vs: (number | null)[]) => vs.map((v, i) => [i, v] as const); const defined = (d: readonly [number, number | null]) => d[1] !== null; const xs = dateTicks(n, Math.max(2, Math.floor(width / 110))); const onDay = (i: number) => events.filter(e => e.date === dates[i]); const fmt = (l: ChartLine, v: number | null) => (v === null ? '–' : l.unit === 'rate' ? pct(v, 1) : num(v)); const hv = h.hover; return (