From fbc4d9d4b2febbcb146384b3ec168f8111c5781d Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 3 Sep 2026 15:07:22 +0700 Subject: [PATCH] feat(constellations) : add infographic image export --- AGENTS.md | 4 +- components/public/constellation-image.tsx | 124 ++++++++++++++++++++++ components/public/guide-image-export.tsx | 18 +++- components/public/structured-guide.tsx | 15 +++ lib/guides/export-image.test.ts | 34 +++++- lib/guides/export-image.ts | 30 ++++++ 6 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 components/public/constellation-image.tsx diff --git a/AGENTS.md b/AGENTS.md index 44a03a0..643577d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ # This is NOT the Next.js you know -This version has breaking changes - APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. -This block is written and re-added by `next dev` - verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. diff --git a/components/public/constellation-image.tsx b/components/public/constellation-image.tsx new file mode 100644 index 0000000..80238be --- /dev/null +++ b/components/public/constellation-image.tsx @@ -0,0 +1,124 @@ +import Image from "next/image"; + +import type { PublicGuide } from "@/lib/guides/queries"; +import { constellationChartRows } from "@/lib/guides/export-image"; +import { cn } from "@/lib/utils"; + +function percentage(value: number): string { + return `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; +} + +export function ConstellationImage({ + guide, + group, + sectionNote, +}: { + guide: PublicGuide; + group: PublicGuide["constellationGroups"][number]; + sectionNote: string | null | undefined; +}) { + const rows = constellationChartRows(group.rows); + const background = guide.character.nameCardImageUrl ?? guide.coverUrl; + const character = guide.character.splashImageUrl ?? guide.character.imageUrl; + const footer = group.note ?? sectionNote; + + return ( +
+ +
+ +
+ +
+

16 + ? "text-[60px]" + : "text-[76px]", + )} + > + {guide.character.name} Constellations +

+

+ Team Damage +

+
+
+ +
+ +
+ +
+
+ {rows.map((row) => ( +
+
+ C{row.level} +
+
+
+ + {percentage(row.percent)} + +
+
+ ))} +
+ + {footer ? ( +

+ {footer} +

+ ) : null} + +
+ ); +} diff --git a/components/public/guide-image-export.tsx b/components/public/guide-image-export.tsx index eef9e07..2fe0c92 100644 --- a/components/public/guide-image-export.tsx +++ b/components/public/guide-image-export.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; const EXPORT_WIDTH = 1200; @@ -17,9 +18,15 @@ function downloadFileName(value: string): string { export function GuideImageExport({ children, fileName, + width = EXPORT_WIDTH, + height, + captureClassName, }: { children: ReactNode; fileName: string; + width?: number; + height?: number; + captureClassName?: string; }) { const captureRef = useRef(null); const [capturing, setCapturing] = useState(false); @@ -39,7 +46,8 @@ export function GuideImageExport({ const blob = await domToBlob(node, { backgroundColor: getComputedStyle(node).backgroundColor, scale: 1, - width: EXPORT_WIDTH, + width, + height, }); if (cancelled) return; @@ -62,7 +70,7 @@ export function GuideImageExport({ return () => { cancelled = true; }; - }, [capturing, fileName]); + }, [capturing, fileName, height, width]); return ( <> @@ -88,7 +96,11 @@ export function GuideImageExport({ >
{children}
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 6eb97af..0509a39 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -17,6 +17,7 @@ import { SiteHeader } from "@/components/public/site-header"; import { ScrollableNav } from "@/components/public/scrollable-nav"; import { TeamSearch } from "@/components/public/team-search"; import { GuideImageExport } from "@/components/public/guide-image-export"; +import { ConstellationImage } from "@/components/public/constellation-image"; import { LiveRefresh } from "@/components/live-refresh"; import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity"; import type { PublicGuide } from "@/lib/guides/queries"; @@ -900,6 +901,20 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
{groups.length > 1 ?

Condition {groupIndex + 1}

: null} {group.note ?

{group.note}

: null} +
+ 1 ? `-Condition-${groupIndex + 1}` : ""}.png`} + width={1500} + height={1500} + captureClassName="p-0" + > + + +
{group.rows.map((row) => ( diff --git a/lib/guides/export-image.test.ts b/lib/guides/export-image.test.ts index 4e0159c..af03753 100644 --- a/lib/guides/export-image.test.ts +++ b/lib/guides/export-image.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { imageContentDisposition } from "@/lib/guides/export-image"; +import { + constellationChartRows, + imageContentDisposition, +} from "@/lib/guides/export-image"; describe("guide image downloads", () => { it("provides a safe ASCII fallback and a UTF-8 filename", () => { @@ -14,4 +17,33 @@ describe("guide image downloads", () => { 'filename="Arlecchino-R5.png"', ); }); + + it("orders constellation levels, adds C0, and scales against the maximum", () => { + const rows = constellationChartRows([ + { level: 1, cumulativePercent: "117", isGreat: false }, + { level: 6, cumulativePercent: "234", isGreat: true }, + ]); + + expect(rows.slice(0, 2)).toEqual([ + { level: 6, percent: 234, isGreat: true, widthPercent: 100 }, + { level: 1, percent: 117, isGreat: false, widthPercent: 50 }, + ]); + expect(rows[2]).toMatchObject({ + level: 0, + percent: 100, + isGreat: false, + }); + expect(rows[2].widthPercent).toBeCloseTo(100 / 2.34); + }); + + it("uses 100% as the minimum constellation chart scale", () => { + expect( + constellationChartRows([ + { level: 2, cumulativePercent: "80", isGreat: false }, + ]), + ).toEqual([ + { level: 2, percent: 80, isGreat: false, widthPercent: 80 }, + { level: 0, percent: 100, isGreat: false, widthPercent: 100 }, + ]); + }); }); diff --git a/lib/guides/export-image.ts b/lib/guides/export-image.ts index a5e3144..9fa6d1f 100644 --- a/lib/guides/export-image.ts +++ b/lib/guides/export-image.ts @@ -10,3 +10,33 @@ export function imageContentDisposition(displayName: string, fallbackName: strin ); return `attachment; filename="${fallback}.png"; filename*=UTF-8''${encoded}`; } + +export interface ConstellationChartRow { + level: number; + percent: number; + isGreat: boolean; + widthPercent: number; +} + +export function constellationChartRows( + rows: ReadonlyArray<{ + level: number; + cumulativePercent: string; + isGreat: boolean; + }>, +): ConstellationChartRow[] { + const values = [ + ...rows.map((row) => ({ + level: row.level, + percent: Number(row.cumulativePercent), + isGreat: row.isGreat, + })), + { level: 0, percent: 100, isGreat: false }, + ].sort((left, right) => right.level - left.level); + const maximum = Math.max(100, ...values.map((row) => row.percent)); + + return values.map((row) => ({ + ...row, + widthPercent: Math.max(0, Math.min(100, (row.percent / maximum) * 100)), + })); +}