mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
docs(docs): add unique tag colours and pill‑styled badges for problem tags.
This commit is contained in:
1 parent
a7737502e1
commit
a8bd4fbcd7
2 files changed
+125
-8
No files matched your search
@@ -11,10 +11,15 @@
|
|||||||
*
|
*
|
||||||
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
|
* - `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 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
|
* topic's landing page and is filled; any other tag links to its guide
|
||||||
* page, `/guides/<slug>` (sync creates one per such tag), and reads neutral.
|
* page, `/guides/<slug>` (sync creates one per such tag), and is outlined.
|
||||||
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
* `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
|
* - `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.
|
* 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
|
* - `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 data from "blume:data";
|
||||||
import Badge from "blume/components/content/Badge.astro";
|
import Badge from "blume/components/content/Badge.astro";
|
||||||
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
|
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)
|
// `/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`.
|
// -> `/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<string>(data.routes.map((r: { path: string }) => r.path));
|
const served = new Set<string>(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<string, string>();
|
||||||
|
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 badges = tags.map((tag) => {
|
||||||
const target = tagTarget(tag, { route, tags }, served);
|
const target = tagTarget(tag, { route, tags }, served);
|
||||||
const external = target.kind === "external";
|
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";
|
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}
|
rel={b.external ? "noopener" : undefined}
|
||||||
class={linkClass}
|
class={linkClass}
|
||||||
>
|
>
|
||||||
<Badge variant={b.accent ? "accent" : "default"}>{b.name}</Badge>
|
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
|
||||||
|
{b.name}
|
||||||
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
{video && (
|
{video && (
|
||||||
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
||||||
<Badge variant="danger" icon="youtube">
|
<Badge variant="danger" icon="youtube" shape="pill">
|
||||||
Explanation
|
Explanation
|
||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{list && (
|
{list && (
|
||||||
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
||||||
<Badge variant="warning" icon="list">
|
<Badge variant="warning" icon="list" shape="pill">
|
||||||
LeetCode list
|
LeetCode list
|
||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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<string>): Map<string, string> {
|
||||||
|
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<string, string>();
|
||||||
|
const used = new Set<string>();
|
||||||
|
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("")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user