feat(constellations) : add infographic image export
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
# This is NOT the Next.js you know
|
# 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.
|
||||||
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="relative isolate size-[1500px] overflow-hidden bg-[#070b20] font-heading text-white">
|
||||||
|
<Image
|
||||||
|
src={background}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
loading="eager"
|
||||||
|
sizes="1500px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-[#050817]/65" />
|
||||||
|
|
||||||
|
<div className="absolute inset-x-20 top-7 z-10 flex items-start justify-center gap-5">
|
||||||
|
<Image
|
||||||
|
src="/icon/nav/Constellations.webp"
|
||||||
|
alt=""
|
||||||
|
width={112}
|
||||||
|
height={112}
|
||||||
|
loading="eager"
|
||||||
|
className="size-28 shrink-0 object-contain drop-shadow-[0_5px_4px_rgba(0,0,0,0.8)]"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center text-center drop-shadow-[0_5px_4px_rgba(0,0,0,0.8)]">
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap leading-none font-bold",
|
||||||
|
guide.character.name.length > 16
|
||||||
|
? "text-[60px]"
|
||||||
|
: "text-[76px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{guide.character.name} Constellations
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-[46px] leading-none font-bold text-[#f1badd]">
|
||||||
|
Team Damage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-[190px] right-0 bottom-0 z-10 w-[830px]">
|
||||||
|
<Image
|
||||||
|
src={character}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
loading="eager"
|
||||||
|
sizes="830px"
|
||||||
|
className="object-contain object-right-bottom drop-shadow-[0_10px_8px_rgba(0,0,0,0.55)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-[160px] bottom-[115px] left-[154px] z-20 w-1 bg-black/90" />
|
||||||
|
<div className="absolute inset-x-14 top-[205px] bottom-[145px] z-20 flex flex-col justify-between">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.level}
|
||||||
|
className="grid grid-cols-[82px_1fr] items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="text-right text-[58px] leading-none font-bold drop-shadow-[0_5px_4px_rgba(0,0,0,0.9)]">
|
||||||
|
C{row.level}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-[100px] items-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full shrink-0 rounded-r-xl",
|
||||||
|
row.level === 0
|
||||||
|
? "bg-[#bd91b1]"
|
||||||
|
: row.isGreat
|
||||||
|
? "bg-[#f0c76a]"
|
||||||
|
: "bg-[#ead0e1]",
|
||||||
|
)}
|
||||||
|
style={{ width: `${row.widthPercent * 8.5}px` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-3 text-[42px] leading-none font-bold drop-shadow-[0_4px_3px_rgba(0,0,0,0.9)]",
|
||||||
|
row.isGreat ? "text-[#f0c76a]" : "text-[#f1badd]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{percentage(row.percent)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer ? (
|
||||||
|
<p className="absolute bottom-10 left-[154px] z-30 max-w-[1000px] truncate text-[28px] font-semibold drop-shadow-[0_4px_3px_rgba(0,0,0,0.9)]">
|
||||||
|
{footer}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Image
|
||||||
|
src="/icon/Watermark.png"
|
||||||
|
alt=""
|
||||||
|
width={280}
|
||||||
|
height={158}
|
||||||
|
loading="eager"
|
||||||
|
className="absolute right-4 bottom-0 z-30 h-auto w-[280px] object-contain drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const EXPORT_WIDTH = 1200;
|
const EXPORT_WIDTH = 1200;
|
||||||
|
|
||||||
@@ -17,9 +18,15 @@ function downloadFileName(value: string): string {
|
|||||||
export function GuideImageExport({
|
export function GuideImageExport({
|
||||||
children,
|
children,
|
||||||
fileName,
|
fileName,
|
||||||
|
width = EXPORT_WIDTH,
|
||||||
|
height,
|
||||||
|
captureClassName,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
captureClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const captureRef = useRef<HTMLDivElement>(null);
|
const captureRef = useRef<HTMLDivElement>(null);
|
||||||
const [capturing, setCapturing] = useState(false);
|
const [capturing, setCapturing] = useState(false);
|
||||||
@@ -39,7 +46,8 @@ export function GuideImageExport({
|
|||||||
const blob = await domToBlob(node, {
|
const blob = await domToBlob(node, {
|
||||||
backgroundColor: getComputedStyle(node).backgroundColor,
|
backgroundColor: getComputedStyle(node).backgroundColor,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
width: EXPORT_WIDTH,
|
width,
|
||||||
|
height,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -62,7 +70,7 @@ export function GuideImageExport({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [capturing, fileName]);
|
}, [capturing, fileName, height, width]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -88,7 +96,11 @@ export function GuideImageExport({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={captureRef}
|
ref={captureRef}
|
||||||
className="w-[1200px] bg-background p-10 text-foreground"
|
className={cn(
|
||||||
|
"bg-background p-10 text-foreground",
|
||||||
|
captureClassName,
|
||||||
|
)}
|
||||||
|
style={{ width, height }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SiteHeader } from "@/components/public/site-header";
|
|||||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||||
import { TeamSearch } from "@/components/public/team-search";
|
import { TeamSearch } from "@/components/public/team-search";
|
||||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||||
|
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||||
import { LiveRefresh } from "@/components/live-refresh";
|
import { LiveRefresh } from "@/components/live-refresh";
|
||||||
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
||||||
import type { PublicGuide } from "@/lib/guides/queries";
|
import type { PublicGuide } from "@/lib/guides/queries";
|
||||||
@@ -900,6 +901,20 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
|||||||
<div key={group.id} className="flex flex-col gap-4">
|
<div key={group.id} className="flex flex-col gap-4">
|
||||||
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">Condition {groupIndex + 1}</h2> : null}
|
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">Condition {groupIndex + 1}</h2> : null}
|
||||||
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
|
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
|
||||||
|
<div>
|
||||||
|
<GuideImageExport
|
||||||
|
fileName={`${guide.name}-Constellations${groups.length > 1 ? `-Condition-${groupIndex + 1}` : ""}.png`}
|
||||||
|
width={1500}
|
||||||
|
height={1500}
|
||||||
|
captureClassName="p-0"
|
||||||
|
>
|
||||||
|
<ConstellationImage
|
||||||
|
guide={guide}
|
||||||
|
group={group}
|
||||||
|
sectionNote={note}
|
||||||
|
/>
|
||||||
|
</GuideImageExport>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{group.rows.map((row) => (
|
{group.rows.map((row) => (
|
||||||
<Card key={row.level}>
|
<Card key={row.level}>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("guide image downloads", () => {
|
||||||
it("provides a safe ASCII fallback and a UTF-8 filename", () => {
|
it("provides a safe ASCII fallback and a UTF-8 filename", () => {
|
||||||
@@ -14,4 +17,33 @@ describe("guide image downloads", () => {
|
|||||||
'filename="Arlecchino-R5.png"',
|
'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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,3 +10,33 @@ export function imageContentDisposition(displayName: string, fallbackName: strin
|
|||||||
);
|
);
|
||||||
return `attachment; filename="${fallback}.png"; filename*=UTF-8''${encoded}`;
|
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)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user