Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/app/components/chronicle/ChroniclePanels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ describe("ChroniclePlaybackPanel", () => {
onGifSizeChange={noop}
gifStampDay={false}
onGifStampDayChange={noop}
gifWatermark
onGifWatermarkChange={noop}
gifDiscordLink
onGifDiscordLinkChange={noop}
onExportGif={noop}
gifStatus={null}
gifError={null}
Expand All @@ -138,6 +142,9 @@ describe("ChroniclePlaybackPanel", () => {
/>
);
expect(container.textContent).toContain("2026-08-01");
expect(container.textContent).toContain("Watermark");
expect(container.textContent).toContain("Discord link");
expect(container.textContent).toContain("Stamp the date");
});
});

Expand Down
75 changes: 59 additions & 16 deletions frontend/app/components/chronicle/ChroniclePanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,37 @@ export function ChronicleBuildPanel({

export const CHRONICLE_SPEEDS = [1, 2, 4, 8, 16] as const;

function ExportCheck({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`mt-2 flex items-start gap-2 text-sm ${
disabled
? "cursor-not-allowed text-[var(--tfmc-stone)] opacity-60"
: "cursor-pointer text-[var(--tfmc-cream)]"
}`}
>
<input
type="checkbox"
className="mt-1 accent-[var(--tfmc-accent)]"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span>{label}</span>
</label>
);
}

export function ChroniclePlaybackPanel({
days,
activeIndex,
Expand All @@ -378,6 +409,10 @@ export function ChroniclePlaybackPanel({
onGifSizeChange,
gifStampDay,
onGifStampDayChange,
gifWatermark,
onGifWatermarkChange,
gifDiscordLink,
onGifDiscordLinkChange,
onExportGif,
gifStatus,
gifError,
Expand Down Expand Up @@ -413,6 +448,12 @@ export function ChroniclePlaybackPanel({
*/
gifStampDay: boolean;
onGifStampDayChange: (stamp: boolean) => void;
/** The TFMC logo in the corner of the file. Independent of the Discord line. */
gifWatermark: boolean;
onGifWatermarkChange: (watermark: boolean) => void;
/** The discord.gg/tfmc line. Independent of the logo. */
gifDiscordLink: boolean;
onGifDiscordLinkChange: (discordLink: boolean) => void;
onExportGif: () => void;
/**
* What the export is doing right now, or null when idle. Non-null is also
Expand Down Expand Up @@ -535,22 +576,24 @@ export function ChroniclePlaybackPanel({
))}
</select>
</label>
<label
className={`mt-2 flex items-start gap-2 text-sm ${
exporting
? "cursor-not-allowed text-[var(--tfmc-stone)] opacity-60"
: "cursor-pointer text-[var(--tfmc-cream)]"
}`}
>
<input
type="checkbox"
className="mt-1 accent-[var(--tfmc-accent)]"
checked={gifStampDay}
disabled={exporting}
onChange={(e) => onGifStampDayChange(e.target.checked)}
/>
<span>Stamp the date</span>
</label>
<ExportCheck
label="Watermark"
checked={gifWatermark}
disabled={exporting}
onChange={onGifWatermarkChange}
/>
<ExportCheck
label="Discord link"
checked={gifDiscordLink}
disabled={exporting}
onChange={onGifDiscordLinkChange}
/>
<ExportCheck
label="Stamp the date"
checked={gifStampDay}
disabled={exporting}
onChange={onGifStampDayChange}
/>
<button
type="button"
className={`${primaryButtonClass} mt-2 w-full`}
Expand Down
11 changes: 11 additions & 0 deletions frontend/app/components/chronicle/ChronicleStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ export default function ChronicleStudio({ mapId }: { mapId: MapId }) {
* dates are carried unless the user deliberately strips them.
*/
const [gifStampDay, setGifStampDay] = useState(true);
/** Logo and Discord line both travel with the file unless the user strips them. */
const [gifWatermark, setGifWatermark] = useState(true);
const [gifDiscordLink, setGifDiscordLink] = useState(true);
const [gifStatus, setGifStatus] = useState<string | null>(null);
const [gifError, setGifError] = useState<string | null>(null);
const [gifNotice, setGifNotice] = useState<string | null>(null);
Expand Down Expand Up @@ -1612,6 +1615,8 @@ export default function ChronicleStudio({ mapId }: { mapId: MapId }) {
loop,
centroids: geometry.centroids,
stampDay: gifStampDay,
watermark: gifWatermark,
discordLink: gifDiscordLink,
signal: controller.signal,
onProgress: (progress) => {
setGifStatus(
Expand Down Expand Up @@ -1648,6 +1653,8 @@ export default function ChronicleStudio({ mapId }: { mapId: MapId }) {
}, [
gifSize,
gifStampDay,
gifWatermark,
gifDiscordLink,
mapSize,
speed,
loop,
Expand Down Expand Up @@ -1917,6 +1924,10 @@ export default function ChronicleStudio({ mapId }: { mapId: MapId }) {
onGifSizeChange={setGifSize}
gifStampDay={gifStampDay}
onGifStampDayChange={setGifStampDay}
gifWatermark={gifWatermark}
onGifWatermarkChange={setGifWatermark}
gifDiscordLink={gifDiscordLink}
onGifDiscordLinkChange={setGifDiscordLink}
onExportGif={() => void exportGif()}
gifStatus={gifStatus}
gifError={gifError}
Expand Down
75 changes: 57 additions & 18 deletions frontend/app/components/chronicle/chronicleGifExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
chronicleGifTransform,
chronicleWatermarkLayout,
type ChronicleGifTransform,
type ChronicleWatermarkParts,
} from "../../lib/map/chronicleGifFrame";
import { LABEL_INK, type ProvinceCentroids } from "../../lib/mapLabels";
import { resolveMarkerImageSrc, type MapMarker } from "../../lib/mapMarkers";
Expand Down Expand Up @@ -125,6 +126,16 @@ export type ChronicleGifExportOptions = {
* the day, and painting it twice would be noise.
*/
stampDay: boolean;
/**
* The TFMC logo in the corner. Defaults to shown. Independent of the Discord
* line and of the date stamp.
*/
watermark?: boolean;
/**
* The discord.gg/tfmc line. Defaults to shown. Independent of the logo and of
* the date stamp.
*/
discordLink?: boolean;
onProgress?: (progress: ChronicleGifProgress) => void;
signal?: AbortSignal;
};
Expand Down Expand Up @@ -441,7 +452,8 @@ function drawNationLabels(

/**
* The mark, bottom-left, and — when `day` is a usable string — the frame's
* date directly beneath the link in the same scrim box.
* date in the same scrim box. The logo and the Discord line are each optional;
* with both off and no date, nothing is drawn.
*
* The date is drawn exactly as the chronicle stores it — `YYYY-MM-DD`, the
* key the day file is filed under. It is sortable, unambiguous and the same
Expand All @@ -450,33 +462,42 @@ function drawNationLabels(
* other way round.
*
* `day` is null when the "stamp the date" option is off, or when the frame's
* day is not a usable string — either way the box comes out exactly as the
* link-only layout, with no reserved space for a line that is not drawn.
* day is not a usable string. The box then has no reserved space for a line
* that is not drawn. Dropping the logo or the Discord line does the same for
* that piece.
*/
function drawWatermark(
ctx: AnyCanvasContext,
size: number,
logo: HTMLImageElement | null,
sansStack: string,
day: string | null
day: string | null,
parts: ChronicleWatermarkParts
): void {
const dateText = typeof day === "string" ? day.trim() : "";
const hasDate = dateText.length > 0;
const showLogo = parts.logo !== false;
const showLink = parts.link !== false;
if (!showLogo && !showLink && !hasDate) return;

// Measured before the layout is computed: the scrim has to know how wide
// each line turned out, and only the context can say.
const probe = chronicleWatermarkLayout(size, 0, hasDate ? 0 : null);
const layoutParts = { logo: showLogo, link: showLink };
const probe = chronicleWatermarkLayout(size, 0, hasDate ? 0 : null, layoutParts);
ctx.save();
ctx.font = `600 ${probe.fontSize}px ${sansStack}`;
const textWidth = ctx.measureText(CHRONICLE_WATERMARK_TEXT).width;
let textWidth = 0;
if (showLink) {
ctx.font = `600 ${probe.fontSize}px ${sansStack}`;
textWidth = ctx.measureText(CHRONICLE_WATERMARK_TEXT).width;
}

let dateWidth: number | null = null;
if (hasDate) {
ctx.font = `600 ${probe.date!.fontSize}px ${sansStack}`;
dateWidth = ctx.measureText(dateText).width;
}

const layout = chronicleWatermarkLayout(size, textWidth, dateWidth);
const layout = chronicleWatermarkLayout(size, textWidth, dateWidth, layoutParts);

const { scrim } = layout;
if (scrim.width > 0 && scrim.height > 0) {
Expand All @@ -492,7 +513,7 @@ function drawWatermark(
ctx.fill();
}

if (logo) {
if (showLogo && logo && layout.logoSize > 0) {
ctx.imageSmoothingEnabled = true;
ctx.drawImage(
logo,
Expand All @@ -508,12 +529,14 @@ function drawWatermark(
ctx.lineJoin = "round";
ctx.miterLimit = 2;

ctx.font = `600 ${layout.fontSize}px ${sansStack}`;
ctx.strokeStyle = MARKER_HALO;
ctx.lineWidth = layout.haloWidth;
ctx.strokeText(CHRONICLE_WATERMARK_TEXT, layout.textX, layout.textBaselineY);
ctx.fillStyle = CREAM;
ctx.fillText(CHRONICLE_WATERMARK_TEXT, layout.textX, layout.textBaselineY);
if (showLink) {
ctx.font = `600 ${layout.fontSize}px ${sansStack}`;
ctx.strokeStyle = MARKER_HALO;
ctx.lineWidth = layout.haloWidth;
ctx.strokeText(CHRONICLE_WATERMARK_TEXT, layout.textX, layout.textBaselineY);
ctx.fillStyle = CREAM;
ctx.fillText(CHRONICLE_WATERMARK_TEXT, layout.textX, layout.textBaselineY);
}

if (layout.date && hasDate) {
ctx.font = `600 ${layout.date.fontSize}px ${sansStack}`;
Expand Down Expand Up @@ -589,9 +612,17 @@ export async function exportChronicleGif(
loop,
centroids,
stampDay,
watermark,
discordLink,
onProgress,
signal,
} = options;
const showWatermark = watermark !== false;
const showDiscordLink = discordLink !== false;
const watermarkParts: ChronicleWatermarkParts = {
logo: showWatermark,
link: showDiscordLink,
};

if (!frames.length) throw new Error("There are no built frames to export.");

Expand Down Expand Up @@ -622,14 +653,15 @@ export async function exportChronicleGif(

const { serif, sans } = fontStacks();

const iconSources = new Set<string>([LOGO_SRC]);
const iconSources = new Set<string>();
if (showWatermark) iconSources.add(LOGO_SRC);
for (const frame of frames) {
for (const marker of frame.layers.markers) {
iconSources.add(resolveMarkerImageSrc(marker.kind, marker.markerSize));
}
}
const images = await loadImages(Array.from(iconSources));
const logo = images.get(LOGO_SRC) ?? null;
const logo = showWatermark ? (images.get(LOGO_SRC) ?? null) : null;
// Custom faces are still swapping in on a cold load; measuring or drawing
// before they settle bakes the fallback metrics into the file.
try {
Expand Down Expand Up @@ -707,7 +739,14 @@ export async function exportChronicleGif(
drawWarLines(ctx, frame.layers.wars, centroids, transform);
drawMarkers(ctx, frame.layers.markers, images, transform, sans);
drawNationLabels(ctx, frame.layers, transform, serif);
drawWatermark(ctx, edge, logo, sans, stampDay ? frame.day : null);
drawWatermark(
ctx,
edge,
logo,
sans,
stampDay ? frame.day : null,
watermarkParts
);

let pixels: ImageData;
try {
Expand Down
Loading