diff --git a/apps/docs/components/ProblemBadges.astro b/apps/docs/components/ProblemBadges.astro index 88d7c64..3f8ca76 100644 --- a/apps/docs/components/ProblemBadges.astro +++ b/apps/docs/components/ProblemBadges.astro @@ -11,10 +11,15 @@ * * - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own * topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that - * topic's landing page and reads accent; any other tag links to its guide - * page, `/guides/` (sync creates one per such tag), and reads neutral. + * topic's landing page and is filled; any other tag links to its guide + * page, `/guides/` (sync creates one per such tag), and is outlined. * `tagTarget()` picks the route (and between two landings, e.g. DFS); the - * `PageFooter` backlinks list reads the same answer. + * `PageFooter` backlinks list reads the same answer. Each tag badge is a + * pill carrying the icon its target shows in the sidebar (the topic's + * `meta.ts` icon, the guide's `icon:`) and the tag's own colour + * (`lib/tag-colors.ts`), assigned over every tag the site has a page for, + * so no two tags share one. A tag with no page (the LeetCode-list fallback) + * stays gray and iconless. * - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode * walkthrough's YouTube id, the red "Explanation" badge at the end of the row. * - `list:` (guide pages, written by sync on create) is the tag's LeetCode @@ -24,7 +29,8 @@ import { getEntry } from "astro:content"; import data from "blume:data"; import Badge from "blume/components/content/Badge.astro"; import { stripBase, withBase } from "blume/components/islands/base-path.ts"; -import { tagTarget, type TopicTag } from "../topic-landings.ts"; +import { tagColors } from "../lib/tag-colors.ts"; +import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts"; // `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build) // -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`. @@ -40,10 +46,39 @@ const list: string | undefined = entry?.data.list; const served = new Set(data.routes.map((r: { path: string }) => r.path)); +// route → the icon its sidebar row shows: a page's own, or a topic group's +// (from its `meta.ts`) for the landing page the group row links to. +interface NavNode { + route?: string; + icon?: string; + children?: NavNode[]; +} +const icons = new Map(); +const walk = (nodes: NavNode[]) => { + for (const node of nodes) { + if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon); + if (node.children) walk(node.children); + } +}; +walk(data.navigation.sidebar); + +// Every tag with a page: the ones a landing teaches, plus every served guide. +const colors = tagColors([ + ...Object.keys(TOPIC_LANDINGS), + ...[...served].flatMap((path) => path.match(/^\/guides\/([^/]+)$/u)?.slice(1) ?? []), +]); + const badges = tags.map((tag) => { const target = tagTarget(tag, { route, tags }, served); const external = target.kind === "external"; - return { name: tag.name, href: external ? target.href : withBase(target.href), accent: target.kind === "landing", external }; + return { + name: tag.name, + href: external ? target.href : withBase(target.href), + external, + icon: external ? undefined : icons.get(target.href), + color: external ? undefined : colors.get(tag.slug), + stroke: target.kind !== "landing", + }; }); const linkClass = "no-underline transition-opacity hover:opacity-80"; @@ -59,19 +94,21 @@ const linkClass = "no-underline transition-opacity hover:opacity-80"; rel={b.external ? "noopener" : undefined} class={linkClass} > - {b.name} + + {b.name} + ))} {video && ( - + Explanation )} {list && ( - + LeetCode list diff --git a/apps/docs/lib/tag-colors.ts b/apps/docs/lib/tag-colors.ts new file mode 100644 index 0000000..fa4b383 --- /dev/null +++ b/apps/docs/lib/tag-colors.ts @@ -0,0 +1,80 @@ +/** + * One colour per topic tag, unique across the site: the problem-page badge + * row (`components/ProblemBadges.astro`) paints each tag with it, so a tag + * reads the same on every page and no two tags share a colour. + * + * Every colour sits at one OKLCH lightness, so each badge has the same + * contrast whatever its hue; only the hue varies. A tag's hue is seeded by a + * hash of its slug (FNV-1a) and walked forward past any hue already taken + * within `gap` degrees, in slug order. Seeding by slug rather than by position + * keeps colours stable as tags come and go: a new tag moves only itself, + * unless it lands on a hue a later slug's seed wanted. + * + * Blume's Badge takes a custom colour only as hex, and uses that one value + * for both themes, so the lightness is the midpoint that stays legible as text + * on the light and the dark background alike. + */ + +const LIGHTNESS = 0.6; +const CHROMA = 0.15; + +/** slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`. */ +export function tagColors(slugs: Iterable): Map { + const sorted = [...new Set(slugs)].sort(); + // Each taken hue blocks 2·gap−1 whole degrees, so n·(2·gap−1) < 360 always + // leaves a free one; past 180 tags the hues can only be distinct. + const gap = Math.max(1, Math.floor(360 / (2 * sorted.length))); + const taken: number[] = []; + const colors = new Map(); + const used = new Set(); + for (const slug of sorted) { + let hue = fnv1a(slug) % 360; + for (let step = 0; step < 360; step++, hue = (hue + 1) % 360) { + if (taken.some((t) => Math.min(Math.abs(t - hue), 360 - Math.abs(t - hue)) < gap)) continue; + if (!used.has(oklchHex(hue))) break; + } + taken.push(hue); + const hex = oklchHex(hue); + used.add(hex); + colors.set(slug, hex); + } + return colors; +} + +function fnv1a(text: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** + * `oklch(LIGHTNESS CHROMA hue)` as sRGB hex. Hues sRGB cannot reach at full + * chroma (cyans) give up chroma until they fit, rather than being clipped + * per channel, which would shift the hue. + */ +function oklchHex(hue: number): string { + const rad = (hue * Math.PI) / 180; + for (let chroma = CHROMA; ; chroma -= 0.005) { + const a = chroma * Math.cos(rad); + const b = chroma * Math.sin(rad); + const l = (LIGHTNESS + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (LIGHTNESS - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (LIGHTNESS - 0.0894841775 * a - 1.291485548 * b) ** 3; + const rgb = [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614681 * s, + ]; + if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue; + return `#${rgb + .map((c) => { + const v = Math.min(1, Math.max(0, c)); + const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; + return Math.round(srgb * 255).toString(16).padStart(2, "0"); + }) + .join("")}`; + } +}