- aso competitors: top apps per keyword from the iTunes Search API (name, developer, rating count, release / update dates, localized genre), coverage gaps (words of 2+ developers), new entrants vs the last snapshot (.storebound/aso/competitors/) - aso suggest: candidates from own text, competitor names, localized category names and Apple Ads keyword suggestions (POST /v1/suggestions/keywords/query, relative 0-100 popularity, only with credentials); 100-byte keyword field per locale as a diff, --write marks it a draft that check --aso and aso audit report - aso audit: check --aso, free keyword room, rank trend arrows, gaps, Play short description / density, App Store conversion, PPO / Play experiment ideas, next 3 actions - MCP: read-only aso_competitors, aso_suggest, aso_audit; desktop ASO card (visx rank chart) - docs: README, aso-basics, store-pitfalls (Search API order, no Play search API), CHANGELOG; demo-app listing files; tests on recorded Search API and mocked Ads answers
73 lines
4.5 KiB
TypeScript
73 lines
4.5 KiB
TypeScript
// Keyword ranks over the `aso rank` snapshots, drawn with visx (scales, axes, shapes, tooltip, responsive width).
|
|
// Rank 1 is the top of the chart (the y axis runs from the worst rank shown down to 1); a keyword the app was not in
|
|
// the first results for has no point that day. Reuses the line chart's styles (.lchart) and crosshair tooltip.
|
|
import { AxisBottom, AxisLeft } from '@visx/axis';
|
|
import { curveMonotoneX } from '@visx/curve';
|
|
import { Group } from '@visx/group';
|
|
import { useParentSize } from '@visx/responsive';
|
|
import { scaleLinear } from '@visx/scale';
|
|
import { Bar, Circle, Line, LinePath } from '@visx/shape';
|
|
import { date } from '../lib/format';
|
|
import { ChartTip, useIndexHover } from './charts';
|
|
import { dateTicks } from './LineChart';
|
|
|
|
export interface RankLine { label: string; ranks: (number | null)[]; cls: 'l1' | 'l2' | 'l3' }
|
|
|
|
const M = { top: 12, right: 18, bottom: 26, left: 40 };
|
|
|
|
/** The y domain: rank 1 at the top, a round worst rank at the bottom (at least 10) and up to 5 ticks. */
|
|
export function rankAxis(lines: (number | null)[][]): { worst: number; ticks: number[] } {
|
|
const hi = Math.max(10, ...lines.flat().filter((v): v is number => v !== null));
|
|
const step = hi <= 10 ? 3 : hi <= 50 ? 10 : hi <= 100 ? 25 : 50;
|
|
const worst = hi <= 10 ? 10 : Math.ceil(hi / step) * step;
|
|
const ticks = [1, ...Array.from({ length: Math.floor(worst / step) }, (_, i) => (i + 1) * step)].filter((v, i, a) => a.indexOf(v) === i && v <= worst);
|
|
return { worst, ticks };
|
|
}
|
|
|
|
export function RankChart({ dates, lines, label, noRank }: { dates: string[]; lines: RankLine[]; label: string; noRank: string }) {
|
|
const { parentRef, width } = useParentSize({ debounceTime: 50 });
|
|
const n = dates.length;
|
|
const h = useIndexHover(n, width, M.left, M.right);
|
|
if (n < 2 || !lines.length) return null;
|
|
const height = Math.round(Math.max(170, Math.min(230, width * 0.28)));
|
|
const { worst, ticks } = rankAxis(lines.map(l => l.ranks));
|
|
const x = scaleLinear({ domain: [0, n - 1], range: [M.left, Math.max(M.left + 1, width - M.right)] });
|
|
const y = scaleLinear({ domain: [1, worst], range: [M.top, height - M.bottom] });
|
|
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 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} tabIndex={0} {...h.handlers}>
|
|
<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} tickFormat={v => `#${Number(v)}`} 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 })} />
|
|
{lines.map(l => <LinePath key={l.label} className={`ln ${l.cls}`} data={pts(l.ranks)} defined={defined} x={d => x(d[0])} y={d => y(d[1] ?? worst)} curve={curveMonotoneX} />)}
|
|
{lines.map(l => l.ranks.map((v, i) => (v === null ? null : <Circle key={`${l.label}${i}`} className={l.cls} cx={x(i)} cy={y(v)} r={2.5} />)))}
|
|
{hv !== null && (
|
|
<Group className="hov">
|
|
<Line from={{ x: x(hv), y: M.top }} to={{ x: x(hv), y: height - M.bottom }} />
|
|
</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">{l.ranks[hv] == null ? noRank : `#${l.ranks[hv]}`}</b></span>)}
|
|
</ChartTip>
|
|
)}
|
|
</div>
|
|
<div className="lchart-legend" aria-live="polite">
|
|
{lines.map(l => <span key={l.label} className="lg"><span className={`sw ${l.cls}`} />{l.label}</span>)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|