Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions apps/web/design-preview/kw-grouped-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Rankings table has them, with their widths), the numbers of a set of keywords for the devices chosen, the filters,
* and the two export formats.
*/
import { UNGROUPED, groupNames, series, store, usedCountries } from './kw-grouped-data.js';
import { UNGROUPED, groupNames, store, usedCountries } from './kw-grouped-data.js';

export const GROUP_LABELS = { order: 'Order of the groups' };
export const ALL_COUNTRIES = 'All countries';
Expand Down Expand Up @@ -152,7 +152,6 @@ export function measures(items) {
const kds = items.map((keyword) => keyword.kd).filter((kd) => kd !== null);
const cpcs = items.map((keyword) => keyword.cpc).filter((cpc) => cpc > 0);
const moves = items.flatMap((keyword) => devices.map((device) => gained(keyword, device)));
const ranked = ranks.filter(isRank);
return {
count: new Set(items.map((keyword) => keyword.keyword)).size,
countries: new Set(items.map((keyword) => keyword.country)).size,
Expand All @@ -171,10 +170,28 @@ export function measures(items) {
up: moves.filter((move) => move > 0).length,
down: moves.filter((move) => move < 0).length,
cited: items.filter((keyword) => derived(keyword).aio.kind === 'cited').length,
trend: series(ranked.length === 0 ? 40 : sum(ranked) / ranked.length, items.length),
// The months of the group's search volume: the sum of its keywords' months
trend: Array.from({ length: MONTHS }, (_, month) => sum(items.map((keyword) => monthly(keyword)[month]))),
};
}

const MONTHS = 12;

/**
* The last twelve months of a keyword's search volume, oldest first, averaging its volume: a gentle slope up or
* down and, for about a quarter of the keywords, a season. Derived from the text, so it is the same on every visit.
*/
export function monthly(keyword) {
const seed = hash(keyword.keyword) / 4294967295;
const slope = (seed - 0.5) * 0.04;
const swing = seed > 0.75 ? 0.6 : 0.12;
const peak = Math.floor(seed * 1000) % MONTHS;
return Array.from({ length: MONTHS }, (_, month) => {
const season = 1 + swing * Math.cos(((month - peak) / MONTHS) * 2 * Math.PI);
return Math.max(0, Math.round(keyword.volume * season * (1 + slope * (month - (MONTHS - 1) / 2))));
});
}

// -------------------------------------------------------------------------------------------------
// Which keywords the page shows

Expand Down Expand Up @@ -273,14 +290,19 @@ export function csvText(rows) {
return `${String.fromCharCode(0xfeff)}${rows.map((row) => row.map(field).join(',')).join('\r\n')}\r\n`;
}

/** A line of a series, drawn so that up is better (a smaller rank is higher). */
export function sparkline(points, label, className = 'w-18 h-5.5 text-primary') {
/**
* A line of a series on a scale of its own lowest and highest point. By default up is better (a smaller rank is
* higher); with `higherIsUp` a larger value is higher, as for a search volume.
*/
export function sparkline(points, label, className = 'w-18 h-5.5 text-primary', higherIsUp = false) {
const width = 72;
const height = 22;
const high = Math.max(...points);
const low = Math.min(...points);
const span = high - low || 1;
const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${((index / (points.length - 1)) * width).toFixed(1)} ${(((point - low) / span) * (height - 4) + 2).toFixed(1)}`).join(' ');
// A series that never moves sits in the middle
const share = (point) => (high === low ? 0.5 : (higherIsUp ? high - point : point - low) / span);
const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${((index / (points.length - 1)) * width).toFixed(1)} ${(share(point) * (height - 4) + 2).toFixed(1)}`).join(' ');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.setAttribute('class', className);
Expand Down
17 changes: 9 additions & 8 deletions apps/web/design-preview/kw-grouped.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
* selection with bulk actions, the menus of rows and groups, and the drawers. A reload starts from the fixtures
* again. Reached at #/keywords, the page the sidebar's Keywords links to.
*/
import { UNGROUPED, archiveKeywords, deleteKeywords, series, store, usedCountries } from './kw-grouped-data.js';
import { UNGROUPED, archiveKeywords, deleteKeywords, store, usedCountries } from './kw-grouped-data.js';
import { BUTTON_SM, icon } from './perf-ui.js';
import { confirmDialog } from './kw-dialog.js';
import { el } from './prompts-parts.js';
import { ALL_COUNTRIES, addScope, best, columnsFor, clicks, impressions, derived, gained, groupOf, isRank, measures, plural, rankText, refresh, setRenderer, shown, sparkline, state, volumeText } from './kw-grouped-model.js';
import { ALL_COUNTRIES, addScope, best, columnsFor, clicks, impressions, derived, gained, groupOf, isRank, measures, monthly, plural, rankText, refresh, setRenderer, shown, sparkline, state, volumeText } from './kw-grouped-model.js';
import { openAdd, openMove } from './kw-grouped-dialogs.js';
import { openGroup, openKeyword, redrawDrawer } from './kw-grouped-drawer.js';
import { bindPages, closePages, pagesOf } from './kw-grouped-pages.js';
Expand Down Expand Up @@ -72,15 +72,16 @@ function featuresCell(info) {
return row;
}

/** The keyword's search volume over the last 12 months, whatever its rank: the column is about demand, not position. */
function trendCell(keyword) {
const top = best(keyword);
if (pending(keyword)) return muted('n/a');
if (top === Infinity) {
if (keyword.volume === 0) {
const line = el('span', 'block h-px w-16 border-t border-dashed border-border-strong');
line.setAttribute('aria-label', 'Over 100 for 30 days');
line.setAttribute('aria-label', 'Search volume trend: no monthly volumes yet');
return line;
}
return sparkline(series(top, keyword.keyword.length + (state.device === 'mobile' ? 7 : 0)), `Rank over 30 days, now ${top}`);
const months = monthly(keyword);
const text = (value) => value.toLocaleString('en-US');
return sparkline(months, `Search volume trend, last 12 months: from ${text(months[0])} to ${text(months.at(-1))}, peak ${text(Math.max(...months))}`, undefined, true);
}

function menuButton(label, attach) {
Expand Down Expand Up @@ -194,7 +195,7 @@ function groupRow(group, items, columns) {
intent: () => td(),
features: () => td(),
aio: () => td(m.cited > 0 ? twoLine(String(m.cited), 'cited') : muted('n/a')),
trend: () => td(sparkline(m.trend, `Average rank of ${group.name} over 30 days`)),
trend: () => td(sparkline(m.trend, `Search volume of ${group.name}, last 12 months`, undefined, true)),
priority: () => td(),
group: () => td(),
tags: () => td(),
Expand Down
11 changes: 4 additions & 7 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2414,13 +2414,10 @@
},
"trend": {
"column": "Trend",
"label": "Rank trend, last {checks, plural, one {check} other {# checks}}: from {first} to {last}, best {best}",
"labelBeyond": "{count, plural, one {1 check} other {# checks}} beyond the depth checked",
"labelNone": "Rank trend: not ranked within the depth in the last {checks, plural, one {check} other {# checks}}",
"labelEmpty": "Rank trend: no checks yet",
"labelFailed": "Rank trend: checks failed",
"tipTitle": "Last checks",
"tipPoint": "{date}: {rank}"
"label": "Search volume trend, last {months, plural, one {month} other {# months}}: from {first} to {last}, peak {peak}",
"labelEmpty": "Search volume trend: no monthly volumes yet",
"tipTitle": "Monthly search volume",
"tipPoint": "{month}: {volume}"
},
"strip": {
"label": "Summary for the filtered keywords",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/app/data-table/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ export const col = {
return rich(id, 'aiOverview', sortValue, options);
},

/** Rank trend sparkline: canvas, left aligned (15-data-visualization.md section 9). Never sorts. */
/** Search volume trend sparkline: canvas, left aligned (15-data-visualization.md section 9). Never sorts. */
sparkline<TRow>(id: string, options: RichOptions<TRow>): DataTableColumn<TRow> {
return rich(id, 'sparkline', () => null, { ...options, sortable: false });
},
Expand Down
49 changes: 14 additions & 35 deletions apps/web/src/components/app/sparkline/sparkline-cell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { SparklineCell } from './sparkline-cell';
import type { SparklinePoint } from '@/lib/api/types';

describe('SparklineCell', () => {
const contextSpies = {
Expand All @@ -27,12 +26,8 @@ describe('SparklineCell', () => {
});

it('renders a canvas with role="img", aria-label, and scaled pixel dimensions', () => {
const points: SparklinePoint[] = [
{ t: '2026-10-01T00:00:00Z', rank: 3, depth: 20, state: 'ranked' },
{ t: '2026-10-02T00:00:00Z', rank: 4, depth: 20, state: 'ranked' },
];
render(<SparklineCell points={points} label="Rank trend: from 3 to 4, best 3" />);
const canvas = screen.getByRole('img', { name: 'Rank trend: from 3 to 4, best 3' });
render(<SparklineCell values={[1300, 1900]} label="Search volume trend: from 1,300 to 1,900" />);
const canvas = screen.getByRole('img', { name: 'Search volume trend: from 1,300 to 1,900' });
expect(canvas.tagName.toLowerCase()).toBe('canvas');
expect(canvas).toHaveAttribute('width', String(64 * (window.devicePixelRatio || 1)));
expect(canvas).toHaveAttribute('height', String(20 * (window.devicePixelRatio || 1)));
Expand All @@ -41,46 +36,30 @@ describe('SparklineCell', () => {
expect(contextSpies.arc).toHaveBeenCalledTimes(1);
});

it('breaks into multiple multi-point segments with no arc if the last check was beyond depth', () => {
const points: SparklinePoint[] = [
{ t: '2026-10-01T00:00:00Z', rank: 3, depth: 20, state: 'ranked' },
{ t: '2026-10-02T00:00:00Z', rank: 4, depth: 20, state: 'ranked' },
{ t: '2026-10-03T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' },
];
render(<SparklineCell points={points} label="Rank trend" />);
it('breaks the line at a month without a volume, and puts no dot on a last month that has none', () => {
render(<SparklineCell values={[300, 400, null]} label="Search volume trend" />);
expect(contextSpies.moveTo).toHaveBeenCalledTimes(1);
expect(contextSpies.lineTo).toHaveBeenCalledTimes(1);
expect(contextSpies.arc).not.toHaveBeenCalled();
});

it('draws a dot for a one-point segment in [beyond, ranked, beyond]', () => {
const points: SparklinePoint[] = [
{ t: '2026-10-01T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' },
{ t: '2026-10-02T00:00:00Z', rank: 7, depth: 20, state: 'ranked' },
{ t: '2026-10-03T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' },
];
render(<SparklineCell points={points} label="Rank trend" />);
it('draws a dot for a run of one month in [none, volume, none]', () => {
render(<SparklineCell values={[null, 700, null]} label="Search volume trend" />);
expect(contextSpies.arc).toHaveBeenCalledTimes(1);
expect(contextSpies.fill).toHaveBeenCalledTimes(1);
});

it('renders dashed treatment when there are no segments [beyond, beyond]', () => {
const points: SparklinePoint[] = [
{ t: '2026-10-01T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' },
{ t: '2026-10-02T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' },
];
const { container } = render(<SparklineCell points={points} label="Rank trend: not ranked within the depth" />);
const emptyWrapper = screen.getByRole('img', { name: 'Rank trend: not ranked within the depth' });
it('renders the dashed treatment when no month has a volume', () => {
const { container } = render(<SparklineCell values={[null, null]} label="Search volume trend: none" />);
const emptyWrapper = screen.getByRole('img', { name: 'Search volume trend: none' });
expect(emptyWrapper.tagName.toLowerCase()).toBe('span');
const line = container.querySelector('.border-border');
expect(line).toBeInTheDocument();
expect(container.querySelector('.border-border')).toBeInTheDocument();
});

it('renders empty state when there are no points', () => {
const { container } = render(<SparklineCell points={[]} label="Rank trend: no checks yet" />);
const emptyWrapper = screen.getByRole('img', { name: 'Rank trend: no checks yet' });
it('renders the dashed treatment when there are no months', () => {
const { container } = render(<SparklineCell values={[]} label="Search volume trend: no monthly volumes yet" />);
const emptyWrapper = screen.getByRole('img', { name: 'Search volume trend: no monthly volumes yet' });
expect(emptyWrapper.tagName.toLowerCase()).toBe('span');
const line = container.querySelector('.border-border');
expect(line).toBeInTheDocument();
expect(container.querySelector('.border-border')).toBeInTheDocument();
});
});
12 changes: 6 additions & 6 deletions apps/web/src/components/app/sparkline/sparkline-cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@

import { useEffect, useMemo, useRef, type ReactElement } from 'react';

import type { SparklinePoint } from '@/lib/api/types';
import { SPARK_HEIGHT, SPARK_WIDTH, sparklineGeometry } from '@/lib/charts/sparkline';
import { SPARK_HEIGHT, SPARK_WIDTH, sparklineGeometry, type SparkValue } from '@/lib/charts/sparkline';
import { readColorToken } from '@/lib/design/read-tokens';
import { cn } from '@/lib/utils';

export interface SparklineCellProps {
points: readonly SparklinePoint[];
/** The monthly search volumes, oldest first; `null` for a month without one. */
values: readonly SparkValue[];
label: string;
className?: string | undefined;
}

/**
* A rank trend sparkline in a table cell (15-data-visualization.md section 9).
* A search volume trend sparkline in a table cell (15-data-visualization.md section 9).
* Draws on a 64 x 20 canvas with role="img" and accessible label.
* An empty sparkline renders a 1 px dashed line across the width.
*/
export function SparklineCell({ points, label, className }: SparklineCellProps): ReactElement {
export function SparklineCell({ values, label, className }: SparklineCellProps): ReactElement {
const canvasRef = useRef<HTMLCanvasElement | null>(null);

const geo = useMemo(() => sparklineGeometry(points, SPARK_WIDTH, SPARK_HEIGHT), [points]);
const geo = useMemo(() => sparklineGeometry(values, SPARK_WIDTH, SPARK_HEIGHT), [values]);
const hasSegments = geo.segments.length > 0;

useEffect(() => {
Expand Down
Loading