Changes to image sort
This commit is contained in:
2906
package-lock.json
generated
2906
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -34,7 +34,11 @@ model PortfolioImage {
|
||||
altText String?
|
||||
description String?
|
||||
month Int?
|
||||
sortKey Int?
|
||||
year Int?
|
||||
okLabL Float?
|
||||
okLabA Float?
|
||||
okLabB Float?
|
||||
creationDate DateTime?
|
||||
|
||||
albumId String?
|
||||
|
||||
219
src/actions/portfolio/getPortfolioImagesPage.ts
Normal file
219
src/actions/portfolio/getPortfolioImagesPage.ts
Normal file
@ -0,0 +1,219 @@
|
||||
// "use server";
|
||||
|
||||
// import type { Prisma } from "@/generated/prisma"; // <-- types, no "any"
|
||||
// import prisma from "@/lib/prisma";
|
||||
|
||||
// export type Cursor = { afterSortKey: number; afterId: string } | null;
|
||||
|
||||
// export type GalleryItem = {
|
||||
// id: string;
|
||||
// url: string;
|
||||
// altText: string;
|
||||
// backgroundColor: string;
|
||||
// width: number;
|
||||
// height: number;
|
||||
// fullUrl: string;
|
||||
// fullWidth: number;
|
||||
// fullHeight: number;
|
||||
// sortKey: number;
|
||||
// fileKey: string;
|
||||
// };
|
||||
|
||||
// export async function getPortfolioImagesPage(args: {
|
||||
// take?: number;
|
||||
// cursor?: Cursor;
|
||||
// albumId?: string | null;
|
||||
// year?: number | string | null;
|
||||
// typeSlug?: string | null;
|
||||
// onlyPublished?: boolean;
|
||||
// }): Promise<{ items: GalleryItem[]; nextCursor: Cursor }> {
|
||||
// const {
|
||||
// take = 60,
|
||||
// cursor = null,
|
||||
// albumId = null,
|
||||
// year = null,
|
||||
// typeSlug = null,
|
||||
// onlyPublished = true,
|
||||
// } = args;
|
||||
|
||||
// const coercedYear =
|
||||
// typeof year === "string" && year.trim() !== "" ? Number(year) :
|
||||
// typeof year === "number" ? year : undefined;
|
||||
|
||||
// const where: Prisma.PortfolioImageWhereInput = {
|
||||
// sortKey: { not: null },
|
||||
// variants: { some: { type: "resized" } },
|
||||
// ...(onlyPublished ? { published: true } : {}),
|
||||
// ...(albumId ? { albumId } : {}),
|
||||
// ...(coercedYear !== undefined && !Number.isNaN(coercedYear) ? { year: coercedYear } : {}),
|
||||
// ...(typeSlug ? { type: { is: { slug: typeSlug } } } : {}),
|
||||
// };
|
||||
|
||||
// if (cursor) {
|
||||
// const sk = Number(cursor.afterSortKey);
|
||||
// (where as Prisma.PortfolioImageWhereInput).OR = [
|
||||
// { sortKey: { gt: sk } },
|
||||
// { AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
|
||||
// ];
|
||||
// }
|
||||
|
||||
// const rows = await prisma.portfolioImage.findMany({
|
||||
// where,
|
||||
// orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||
// take: Math.min(take, 200),
|
||||
// select: {
|
||||
// id: true,
|
||||
// fileKey: true,
|
||||
// altText: true,
|
||||
// name: true,
|
||||
// sortKey: true,
|
||||
// variants: {
|
||||
// select: { type: true, url: true, width: true, height: true },
|
||||
// where: { type: { in: ["resized", "modified"] } },
|
||||
// },
|
||||
// colors: {
|
||||
// where: { type: "Vibrant" },
|
||||
// select: { color: { select: { hex: true } } },
|
||||
// take: 1,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// const items: GalleryItem[] = rows
|
||||
// .map((r): GalleryItem | null => {
|
||||
// const resized = r.variants.find(v => v.type === "resized");
|
||||
// if (!resized?.width || !resized.height) return null;
|
||||
// const full = r.variants.find(v => v.type === "modified");
|
||||
// const bg = r.colors[0]?.color?.hex ?? "#e5e7eb";
|
||||
|
||||
// return {
|
||||
// id: r.id,
|
||||
// fileKey: r.fileKey,
|
||||
// altText: r.altText ?? r.name ?? "",
|
||||
// backgroundColor: bg,
|
||||
// width: resized.width,
|
||||
// height: resized.height,
|
||||
// // use your existing API route for images:
|
||||
// url: resized.url ?? `/api/image/resized/${r.fileKey}.webp`,
|
||||
// fullUrl: full?.url ?? `/api/image/modified/${r.fileKey}.webp`,
|
||||
// fullWidth: full?.width ?? 0,
|
||||
// fullHeight: full?.height ?? 0,
|
||||
// sortKey: r.sortKey ?? 0,
|
||||
// };
|
||||
// })
|
||||
// .filter((x): x is GalleryItem => x !== null);
|
||||
|
||||
// const last = rows[rows.length - 1];
|
||||
// const nextCursor: Cursor =
|
||||
// rows.length < Math.min(take, 200) || !last
|
||||
// ? null
|
||||
// : { afterSortKey: (last.sortKey as number), afterId: last.id };
|
||||
|
||||
// return { items, nextCursor };
|
||||
// }
|
||||
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export type Cursor = { afterSortKey: number; afterId: string } | null;
|
||||
|
||||
export type GalleryItem = {
|
||||
id: string;
|
||||
url: string;
|
||||
altText: string;
|
||||
backgroundColor: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fullUrl: string;
|
||||
fullWidth: number;
|
||||
fullHeight: number;
|
||||
sortKey: number;
|
||||
fileKey: string;
|
||||
};
|
||||
|
||||
type FindManyArgs = Parameters<typeof prisma.portfolioImage.findMany>[0];
|
||||
type WhereInput = NonNullable<FindManyArgs>["where"];
|
||||
|
||||
export async function getPortfolioImagesPage(args: {
|
||||
take?: number;
|
||||
cursor?: Cursor;
|
||||
albumId?: string | null;
|
||||
year?: number | string | null;
|
||||
}): Promise<{ items: GalleryItem[]; nextCursor: Cursor }> {
|
||||
const { take = 60, cursor = null, albumId = null, year = null } = args;
|
||||
|
||||
const coercedYear =
|
||||
typeof year === "string" && year.trim() !== "" ? Number(year) :
|
||||
typeof year === "number" ? year : undefined;
|
||||
|
||||
const where: WhereInput = {
|
||||
sortKey: { not: null },
|
||||
published: true,
|
||||
variants: { some: { type: "resized" } },
|
||||
...(albumId ? { albumId } : {}),
|
||||
...(coercedYear !== undefined && !Number.isNaN(coercedYear) ? { year: coercedYear } : {}),
|
||||
};
|
||||
|
||||
if (cursor) {
|
||||
const sk = Number(cursor.afterSortKey);
|
||||
(where as WhereInput).OR = [
|
||||
{ sortKey: { gt: sk } },
|
||||
{ AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
|
||||
];
|
||||
}
|
||||
|
||||
const rows = await prisma.portfolioImage.findMany({
|
||||
where,
|
||||
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||
take: Math.min(take, 200),
|
||||
select: {
|
||||
id: true,
|
||||
fileKey: true,
|
||||
altText: true,
|
||||
name: true,
|
||||
sortKey: true,
|
||||
variants: {
|
||||
select: { type: true, url: true, width: true, height: true },
|
||||
where: { type: { in: ["resized", "modified"] } },
|
||||
},
|
||||
colors: {
|
||||
where: { type: "Vibrant" },
|
||||
select: { color: { select: { hex: true } } },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const items: GalleryItem[] = rows
|
||||
.map((r): GalleryItem | null => {
|
||||
const resized = r.variants.find(v => v.type === "resized");
|
||||
if (!resized?.width || !resized.height) return null;
|
||||
const full = r.variants.find(v => v.type === "modified");
|
||||
const bg = r.colors[0]?.color?.hex ?? "#e5e7eb";
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
fileKey: r.fileKey,
|
||||
altText: r.altText ?? r.name ?? "",
|
||||
backgroundColor: bg,
|
||||
width: resized.width,
|
||||
height: resized.height,
|
||||
// use your existing API route
|
||||
url: resized.url ?? `/api/image/resized/${r.fileKey}.webp`,
|
||||
fullUrl: full?.url ?? `/api/image/modified/${r.fileKey}.webp`,
|
||||
fullWidth: full?.width ?? 0,
|
||||
fullHeight: full?.height ?? 0,
|
||||
sortKey: r.sortKey ?? 0,
|
||||
};
|
||||
})
|
||||
.filter((x): x is GalleryItem => x !== null);
|
||||
|
||||
const last = rows[rows.length - 1];
|
||||
const nextCursor: Cursor =
|
||||
rows.length < Math.min(take, 200) || !last
|
||||
? null
|
||||
: { afterSortKey: (last.sortKey as number), afterId: last.id };
|
||||
|
||||
return { items, nextCursor };
|
||||
}
|
||||
15
src/app/portfolio/art-v1/page.tsx
Normal file
15
src/app/portfolio/art-v1/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages";
|
||||
import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery";
|
||||
|
||||
export default async function PortfolioArtworksPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
|
||||
const { year, album } = await searchParams;
|
||||
const images = await getJustifiedImages(year, album, "art", false);
|
||||
|
||||
if (!images) return null
|
||||
|
||||
return (
|
||||
<main className="p-2 mx-auto max-w-screen-2xl">
|
||||
<JustifiedGallery images={images} rowHeight={340} gap={12} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,19 @@
|
||||
import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages";
|
||||
import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery";
|
||||
import JustifiedGalleryV2 from "@/components/portfolio/JustifiedGalleryV2";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export default async function PortfolioArtworksPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
|
||||
export default async function ArtPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
|
||||
const { year, album } = await searchParams;
|
||||
const images = await getJustifiedImages(year, album, "art", false);
|
||||
const images = await prisma.portfolioImage.findMany({
|
||||
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||
})
|
||||
|
||||
if (!images) return null
|
||||
|
||||
// console.log(images)
|
||||
|
||||
return (
|
||||
<main className="p-2 mx-auto max-w-screen-2xl">
|
||||
<JustifiedGallery images={images} rowHeight={340} gap={12} />
|
||||
<JustifiedGalleryV2 albumId={album} year={year} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Pacifico } from "next/font/google";
|
||||
import Image from "next/image";
|
||||
|
||||
const pacifico = Pacifico({ weight: "400", subsets: ["latin"] });
|
||||
|
||||
export default async function Banner() {
|
||||
const headerImage = await prisma.portfolioImage.findFirst({
|
||||
where: { setAsHeader: true },
|
||||
@ -28,7 +32,7 @@ export default async function Banner() {
|
||||
/>
|
||||
{/* Overlay Logo / Title */}
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center text-center">
|
||||
<h1 className="text-white text-3xl md:text-5xl font-bold drop-shadow">
|
||||
<h1 className={cn(pacifico.className, "text-shadow text-white text-3xl md:text-5xl font-bold drop-shadow")}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<div>Footer</div>
|
||||
<div>©️ 2025 by gaertan.art | All rights reserved</div>
|
||||
);
|
||||
}
|
||||
269
src/components/portfolio/JustifiedGalleryV2.tsx
Normal file
269
src/components/portfolio/JustifiedGalleryV2.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { Cursor, GalleryItem, getPortfolioImagesPage } from "@/actions/portfolio/getPortfolioImagesPage";
|
||||
import * as React from "react";
|
||||
import { ImageCard } from "./ImageCard";
|
||||
import { Lightbox } from "./Lightbox";
|
||||
|
||||
type JLItem = { id: string; w: number; h: number };
|
||||
type JLBox = JLItem & { renderW: number; renderH: number; row: number };
|
||||
type FillMode = "ragged" | "distribute";
|
||||
|
||||
function justifiedLayout(
|
||||
items: JLItem[],
|
||||
containerWidth: number,
|
||||
targetRowH = 260,
|
||||
gap = 8,
|
||||
maxRowScale = 1.25,
|
||||
fillMode: FillMode = "ragged", // <— new
|
||||
): JLBox[] {
|
||||
const out: JLBox[] = [];
|
||||
if (containerWidth <= 0) return out;
|
||||
|
||||
let row: JLItem[] = [];
|
||||
let sumAspect = 0;
|
||||
let rowIndex = 0;
|
||||
|
||||
const flush = (isLast: boolean) => {
|
||||
if (!row.length) return;
|
||||
|
||||
const gapsTotal = gap * (row.length - 1);
|
||||
// Height that would exactly fit if we justified
|
||||
const rawH = (containerWidth - gapsTotal) / sumAspect;
|
||||
// Normal case: set row height close to target, but never exceed maxRowScale
|
||||
const baseH = Math.min(
|
||||
isLast ? Math.min(targetRowH, rawH) : rawH,
|
||||
targetRowH * maxRowScale
|
||||
);
|
||||
|
||||
const exact = row.map(it => baseH * (it.w / it.h));
|
||||
|
||||
if (fillMode === "distribute" && !isLast) {
|
||||
// Fill the row exactly by distributing rounding error fairly
|
||||
const floor = exact.map(Math.floor);
|
||||
const used = floor.reduce((a, b) => a + b, 0) + gap * (row.length - 1);
|
||||
let remaining = Math.max(0, containerWidth - used);
|
||||
|
||||
const fracs = exact.map((v, i) => ({ i, frac: v - floor[i] }));
|
||||
fracs.sort((a, b) => b.frac - a.frac);
|
||||
const widths = floor.slice();
|
||||
for (let k = 0; k < widths.length && remaining > 0; k++) {
|
||||
widths[fracs[k].i] += 1;
|
||||
remaining--;
|
||||
}
|
||||
widths[widths.length - 1] += remaining; // just in case
|
||||
|
||||
widths.forEach((w, i) => {
|
||||
out.push({ ...row[i], renderW: w, renderH: Math.round(baseH), row: rowIndex });
|
||||
});
|
||||
} else {
|
||||
// RAGGED: do not force the row to fill the width
|
||||
exact.forEach((w, i) => {
|
||||
out.push({ ...row[i], renderW: Math.round(w), renderH: Math.round(baseH), row: rowIndex });
|
||||
});
|
||||
}
|
||||
|
||||
row = [];
|
||||
sumAspect = 0;
|
||||
rowIndex++;
|
||||
};
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
const ar = it.w / it.h;
|
||||
const testSum = sumAspect + ar;
|
||||
const wouldH = (containerWidth - gap * (row.length)) / testSum;
|
||||
|
||||
// If adding this item would drop row height below target, flush current row
|
||||
if (row.length > 0 && wouldH < targetRowH) flush(false);
|
||||
|
||||
row.push(it);
|
||||
sumAspect += ar;
|
||||
}
|
||||
flush(true); // last row (never fully justified in ragged mode)
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function JustifiedGallery({
|
||||
albumId = null,
|
||||
year = null,
|
||||
}: {
|
||||
albumId?: string | null;
|
||||
year?: string | number | null;
|
||||
}) {
|
||||
const [items, setItems] = React.useState<GalleryItem[]>([]);
|
||||
const [cursor, setCursor] = React.useState<Cursor>(null);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
|
||||
|
||||
const inFlight = React.useRef(false);
|
||||
const doneRef = React.useRef(false);
|
||||
doneRef.current = done;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [containerW, setContainerW] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([e]) => setContainerW(Math.floor(e.contentRect.width)));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const GAP = 16;
|
||||
const ROW_HEIGHT = 340;
|
||||
|
||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
const ioRef = React.useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (inFlight.current || doneRef.current) return 0;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
const sentinel = sentinelRef.current;
|
||||
if (sentinel && ioRef.current) ioRef.current.unobserve(sentinel);
|
||||
|
||||
try {
|
||||
const data = await getPortfolioImagesPage({ take: 60, cursor, albumId, year });
|
||||
setItems(prev => prev.concat(data.items));
|
||||
setCursor(data.nextCursor);
|
||||
if (!data.nextCursor) setDone(true);
|
||||
return data.items.length;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
if (!doneRef.current && sentinel && ioRef.current) ioRef.current.observe(sentinel);
|
||||
}
|
||||
}, [cursor, albumId, year]);
|
||||
|
||||
// initial + when filters change
|
||||
React.useEffect(() => {
|
||||
setItems([]); setCursor(null); setDone(false); doneRef.current = false; inFlight.current = false;
|
||||
void loadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [albumId, year]);
|
||||
|
||||
// IO wiring
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
ioRef.current?.disconnect();
|
||||
ioRef.current = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) {
|
||||
ioRef.current?.unobserve(sentinel);
|
||||
void loadMore();
|
||||
}
|
||||
}, { rootMargin: "600px 0px", threshold: 0.01 });
|
||||
ioRef.current.observe(sentinel);
|
||||
return () => ioRef.current?.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
// layout boxes
|
||||
const boxes = React.useMemo(() => {
|
||||
const base: JLItem[] = items.map(i => ({ id: i.id, w: i.width, h: i.height }));
|
||||
return justifiedLayout(base, containerW, ROW_HEIGHT, GAP, 1.25, 'distribute');
|
||||
}, [items, containerW, ROW_HEIGHT, GAP]);
|
||||
|
||||
// quick lookups
|
||||
const idToIndex = React.useMemo(() => new Map(items.map((it, idx) => [it.id, idx])), [items]);
|
||||
const idToMeta = React.useMemo(() => new Map(items.map((it) => [it.id, it])), [items]);
|
||||
|
||||
// open lightbox at the global (items) index
|
||||
const onTileClick = React.useCallback((id: string) => {
|
||||
const idx = idToIndex.get(id);
|
||||
if (idx !== undefined) setSelectedIndex(idx);
|
||||
}, [idToIndex]);
|
||||
|
||||
// modal navigation
|
||||
const hasPrev = selectedIndex !== null && selectedIndex > 0;
|
||||
const hasNextLoaded = selectedIndex !== null && selectedIndex < items.length - 1;
|
||||
const hasNextPotential = !done; // if not done, we might be able to load more
|
||||
|
||||
const onPrev = React.useCallback(() => {
|
||||
setSelectedIndex(i => (i !== null && i > 0 ? i - 1 : i));
|
||||
}, []);
|
||||
|
||||
const onNext = React.useCallback(async () => {
|
||||
// Use a snapshot of the current selected index
|
||||
const current = selectedIndex;
|
||||
if (current === null) return;
|
||||
|
||||
// If next image is already loaded
|
||||
if (current < items.length - 1) {
|
||||
setSelectedIndex(current + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're at the end but more pages exist
|
||||
if (!doneRef.current) {
|
||||
const added = await loadMore();
|
||||
if (added > 0) {
|
||||
setSelectedIndex(current + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No more images
|
||||
}, [selectedIndex, items.length, loadMore]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="w-full flex flex-col" style={{ rowGap: `${GAP}px` }}>
|
||||
{boxes.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-20">No images to display</p>
|
||||
)}
|
||||
|
||||
{/* render by rows */}
|
||||
{(() => {
|
||||
const rows: Record<number, JLBox[]> = {};
|
||||
for (const b of boxes) (rows[b.row] ??= []).push(b);
|
||||
return Object.keys(rows).map((rowKey) => {
|
||||
const row = rows[Number(rowKey)];
|
||||
return (
|
||||
<div key={rowKey} className="flex" style={{ columnGap: `${GAP}px` }}>
|
||||
{row.map((b) => {
|
||||
const meta = idToMeta.get(b.id)!;
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
style={{ width: b.renderW, height: b.renderH }}
|
||||
onClick={() => onTileClick(b.id)}
|
||||
className="cursor-zoom-in"
|
||||
>
|
||||
<ImageCard
|
||||
image={{
|
||||
id: meta.id,
|
||||
altText: meta.altText,
|
||||
url: meta.url,
|
||||
backgroundColor: meta.backgroundColor,
|
||||
}}
|
||||
variant="mosaic"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{!done && <div ref={sentinelRef} style={{ height: 1 }} />}
|
||||
{loading && <p className="text-sm text-muted-foreground mt-2">Loading…</p>}
|
||||
</div>
|
||||
|
||||
{/* Lightbox modal */}
|
||||
{selectedIndex !== null && (
|
||||
<Lightbox
|
||||
image={items[selectedIndex]}
|
||||
onClose={() => setSelectedIndex(null)}
|
||||
hasPrev={hasPrev}
|
||||
hasNext={hasNextLoaded || hasNextPotential}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/utils/justifiedLayout.ts
Normal file
58
src/utils/justifiedLayout.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export type JLItem = { id: string; w: number; h: number }; // original size
|
||||
export type JLBox = JLItem & { renderW: number; renderH: number, row: number };
|
||||
|
||||
export function justifiedLayout(
|
||||
items: JLItem[],
|
||||
containerWidth: number,
|
||||
targetRowH = 260,
|
||||
gap = 8,
|
||||
maxRowScale = 1.25 // how tall a row may scale vs target
|
||||
): JLBox[] {
|
||||
const out: JLBox[] = [];
|
||||
if (containerWidth <= 0) return out;
|
||||
|
||||
let row: JLItem[] = [];
|
||||
let rowAspectSum = 0;
|
||||
let rowIndex = 0;
|
||||
|
||||
const flushRow = (isLast: boolean) => {
|
||||
if (row.length === 0) return;
|
||||
const gaps = gap * (row.length - 1);
|
||||
const rawH = containerWidth / rowAspectSum; // height that would exactly fit the width
|
||||
// For the very last row, keep it near target height to avoid huge upscaling
|
||||
const targetH = isLast ? Math.min(targetRowH, rawH) : rawH;
|
||||
const clampedH = Math.min(targetH, targetRowH * maxRowScale);
|
||||
|
||||
let used = 0;
|
||||
row.forEach((it, i) => {
|
||||
const ar = it.w / it.h;
|
||||
let w = Math.round(clampedH * ar);
|
||||
// Put gap between items; last item stretches to fix rounding drift
|
||||
if (i === row.length - 1) w = containerWidth - used - gaps;
|
||||
out.push({ ...it, renderW: w, renderH: Math.round(clampedH), row: rowIndex });
|
||||
used += w + (i < row.length - 1 ? gap : 0);
|
||||
});
|
||||
|
||||
row = [];
|
||||
rowAspectSum = 0;
|
||||
rowIndex += 1;
|
||||
};
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
const ar = it.w / it.h;
|
||||
const testAspectSum = rowAspectSum + ar;
|
||||
const rawH = containerWidth / testAspectSum;
|
||||
|
||||
// When adding this item would push row height below target, close the row
|
||||
if (row.length > 0 && rawH < targetRowH) {
|
||||
flushRow(false);
|
||||
}
|
||||
|
||||
row.push(it);
|
||||
rowAspectSum += ar;
|
||||
}
|
||||
flushRow(true); // last row
|
||||
|
||||
return out;
|
||||
}
|
||||
@ -38,4 +38,4 @@
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user