- desktop: Growth chart, Health and portfolio sparklines and the rollout bar drawn with visx (responsive width, crosshair tooltip with real values, arrow keys, theme tokens, no animation); icons from lucide-react behind the existing Icon names; empty states are lucide icons on token tiles; store marks are generic lucide icons; stars are lucide; the brand mark moves to ui/brand.tsx as the one allowed inline SVG - fixtures: app icons and store art stand-ins are PNGs rendered by tools/fixture-art.ts (lucide on gradients) - site: icons are lucide's own files in site/img/icons (tools/site-icons.ts, with the ISC licence) shown as CSS masks; the journey diagram is rendered from docs/diagrams/journey.mmd by tools/diagrams.ts (Mermaid CLI), with a tall variant for phones - tests/no-handmade-svg.test.ts fails on new inline SVG drawing in apps/desktop/src and site/*.html - desktop bundle: 598.7 -> 695.5 kB minified, 174.6 -> 206.0 kB gzip
114 lines
7 KiB
TypeScript
114 lines
7 KiB
TypeScript
// 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 (
|
|
<div className="lchart">
|
|
<div ref={parentRef} className="lchart-plot" style={{ height }}>
|
|
{width > 0 && (
|
|
<svg width={width} height={height} role="img" aria-label={`${label} (${indexLabel})`} tabIndex={0} {...h.handlers}>
|
|
<LinearGradient id={gid} vertical from="var(--accent-solid)" to="var(--accent-solid)" fromOpacity={0.18} toOpacity={0} />
|
|
<Group className="grid-l">
|
|
{ticks.map(v => <Line key={v} from={{ x: M.left, y: y(v) }} to={{ x: width - M.right, y: y(v) }} />)}
|
|
</Group>
|
|
<AxisLeft scale={y} left={M.left} tickValues={ticks} hideAxisLine hideTicks tickClassName="ax" tickLabelProps={{ className: 'ax', textAnchor: 'end', dx: -4, dy: 4 }} />
|
|
<AxisBottom scale={x} top={height - M.bottom} tickValues={xs} tickFormat={v => date(dates[Number(v)])} hideAxisLine hideTicks
|
|
tickLabelProps={(_, i) => ({ className: 'ax', textAnchor: i === 0 ? 'start' : i === xs.length - 1 ? 'end' : 'middle', dy: 4 })} />
|
|
<Line className="base" from={{ x: M.left, y: y(100) }} to={{ x: width - M.right, y: y(100) }} />
|
|
{events.map((e, i) => {
|
|
const k = dates.indexOf(e.date);
|
|
if (k < 0) return null;
|
|
return (
|
|
<Group key={i} className="ev">
|
|
<Line from={{ x: x(k), y: M.top }} to={{ x: x(k), y: height - M.bottom }} />
|
|
<Circle cx={x(k)} cy={M.top + 4} r={4}><title>{`${date(e.date)} · ${e.subject}`}</title></Circle>
|
|
</Group>
|
|
);
|
|
})}
|
|
{plotted[0] && <AreaClosed data={pts(plotted[0])} defined={defined} x={d => x(d[0])} y={d => y(d[1] ?? 0)} yScale={y} curve={curveMonotoneX} fill={`url(#${gid})`} className="area" />}
|
|
{lines.map((l, k) => <LinePath key={l.label} className={`ln ${l.cls}`} data={pts(plotted[k]!)} defined={defined} x={d => x(d[0])} y={d => y(d[1] ?? 0)} curve={curveMonotoneX} />)}
|
|
{hv !== null && (
|
|
<Group className="hov">
|
|
<Line from={{ x: x(hv), y: M.top }} to={{ x: x(hv), y: height - M.bottom }} />
|
|
{lines.map((l, k) => (plotted[k]![hv] === null ? null : <Circle key={l.label} className={l.cls} cx={x(hv)} cy={y(plotted[k]![hv]!)} r={3.5} />))}
|
|
</Group>
|
|
)}
|
|
<Bar x={M.left} y={M.top} width={Math.max(0, width - M.left - M.right)} height={height - M.top - M.bottom} fill="transparent" />
|
|
</svg>
|
|
)}
|
|
{hv !== null && width > 0 && (
|
|
<ChartTip left={x(hv)} top={h.tooltipTop ?? M.top}>
|
|
<b>{date(dates[hv])}</b>
|
|
{lines.map(l => <span key={l.label} className="vx-row"><i className={`sw ${l.cls}`} />{l.label}<b className="num">{fmt(l, l.values[hv] ?? null)}</b></span>)}
|
|
{onDay(hv).map((e, i) => <span key={i} className="vx-row ev"><i className="sw ev" />{e.subject}</span>)}
|
|
</ChartTip>
|
|
)}
|
|
</div>
|
|
<div className="lchart-legend" aria-live="polite">
|
|
<span className="lg idx">{indexLabel}</span>
|
|
{hv !== null && <b className="num">{date(dates[hv])}</b>}
|
|
{lines.map(l => <span key={l.label} className="lg"><span className={`sw ${l.cls}`} />{l.label}{hv !== null && <b className="num">{fmt(l, l.values[hv] ?? null)}</b>}</span>)}
|
|
{events.length > 0 && <span className="lg"><span className="sw ev" />{eventsLabel}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|