Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
export const CM_PER_INCH = 2.54;

export const MIN_POSTER_CM = 4;
export const MAX_POSTER_CM = 45;
export const MAX_POSTER_CM =45;
export const DEFAULT_POSTER_WIDTH_CM = 20;
export const DEFAULT_POSTER_HEIGHT_CM = 30;
export const LAYOUT_MATCH_TOLERANCE_CM = 0.01;
Expand Down
2 changes: 2 additions & 0 deletions src/features/export/application/useExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export function useExport() {
showPosterText: form.showPosterText,
showOverlay: form.showMarkers,
includeCredits: form.includeCredits,
posterStyleTemplate: form.posterStyleTemplate,
markers: hasVisibleMarkers ? state.markers : [],
markerIcons: hasVisibleOverlays
? getAllMarkerIcons(state.customMarkerIcons)
Expand Down Expand Up @@ -199,6 +200,7 @@ export function useExport() {
showPosterText: form.showPosterText,
showOverlay: form.showMarkers,
includeCredits: form.includeCredits,
posterStyleTemplate: form.posterStyleTemplate,
markers: hasVisibleMarkers ? state.markers : [],
markerIcons: hasVisibleOverlays
? getAllMarkerIcons(state.customMarkerIcons)
Expand Down
24 changes: 22 additions & 2 deletions src/features/export/infrastructure/layeredSvgExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@ import { drawMarkersOnCanvas } from "@/features/markers/infrastructure/rendering
import type { Route } from "@/features/routes/domain/types";
import { drawRoutesOnCanvas } from "@/features/routes/infrastructure/rendering";
import { routeEndpointMarkerItems } from "@/features/routes/infrastructure/helpers";
import { applyFades } from "@/features/poster/infrastructure/renderer/layers";
import {
applyFades,
drawTemplateFrame,
} from "@/features/poster/infrastructure/renderer/layers";
import { drawPosterText } from "@/features/poster/infrastructure/renderer/typography";
import type { ResolvedTheme } from "@/features/theme/domain/types";
import {
getPosterStyleTemplate,
type PosterStyleTemplateId,
} from "@/features/poster/domain/posterStyleTemplates";
import {
waitForMapIdle,
createOffscreenContainer,
Expand All @@ -29,6 +36,7 @@ interface LayeredSvgOptions {
showPosterText: boolean;
showOverlay: boolean;
includeCredits: boolean;
posterStyleTemplate?: PosterStyleTemplateId;
markers: MarkerItem[];
markerIcons: MarkerIconDefinition[];
routes?: Route[];
Expand Down Expand Up @@ -84,11 +92,13 @@ export async function createLayeredSvgBlobFromMap({
showPosterText,
showOverlay,
includeCredits,
posterStyleTemplate,
markers,
markerIcons,
routes = [],
}: LayeredSvgOptions): Promise<Blob> {
await waitForMapIdle(map);
const template = getPosterStyleTemplate(posterStyleTemplate);

const {
center: mapCenter,
Expand Down Expand Up @@ -168,7 +178,7 @@ export async function createLayeredSvgBlobFromMap({
overlayLayers.push({
id: "fades",
dataUrl: canvasToDataUrl(exportWidth, exportHeight, (ctx) => {
applyFades(ctx, exportWidth, exportHeight, theme.ui.bg);
applyFades(ctx, exportWidth, exportHeight, theme.ui.bg, template);
}),
});
}
Expand Down Expand Up @@ -242,6 +252,15 @@ export async function createLayeredSvgBlobFromMap({
}
}

if (template.frame.type !== "none") {
overlayLayers.push({
id: "frame",
dataUrl: canvasToDataUrl(exportWidth, exportHeight, (ctx) => {
drawTemplateFrame(ctx, exportWidth, exportHeight, theme.ui.text, template);
}),
});
}

overlayLayers.push({
id: "text",
dataUrl: canvasToDataUrl(exportWidth, exportHeight, (ctx) => {
Expand All @@ -257,6 +276,7 @@ export async function createLayeredSvgBlobFromMap({
showPosterText,
showOverlay,
includeCredits,
template.id,
);
}),
});
Expand Down
2 changes: 2 additions & 0 deletions src/features/poster/application/posterReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MIN_ROUTE_OPACITY,
MIN_ROUTE_STROKE_WIDTH,
} from "@/features/routes/domain/constants";
import type { PosterStyleTemplateId } from "@/features/poster/domain/posterStyleTemplates";

/* ────── Form state ────── */

Expand All @@ -34,6 +35,7 @@ export interface PosterForm {
displayCountry: string;
displayContinent: string;
fontFamily: string;
posterStyleTemplate: PosterStyleTemplateId;
showPosterText: boolean;
includeCredits: boolean;
includeLandcover: boolean;
Expand Down
13 changes: 13 additions & 0 deletions src/features/poster/application/useFormHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MAX_DISTANCE_METERS,
LAYOUT_MATCH_TOLERANCE_CM,
} from "@/core/config";
import type { PosterStyleTemplateId } from "@/features/poster/domain/posterStyleTemplates";

/**
* Provides ready-made event handlers for the settings form,
Expand Down Expand Up @@ -182,6 +183,17 @@ export function useFormHandlers() {
[dispatch],
);

const handlePosterStyleTemplateChange = useCallback(
(templateId: PosterStyleTemplateId) => {
dispatch({
type: "SET_FIELD",
name: "posterStyleTemplate",
value: templateId,
});
},
[dispatch],
);

return {
handleChange,
handleNumericFieldBlur,
Expand All @@ -193,5 +205,6 @@ export function useFormHandlers() {
handleClearLocation,
setLocationFocused,
handleCreditsChange,
handlePosterStyleTemplateChange,
};
}
211 changes: 211 additions & 0 deletions src/features/poster/domain/posterStyleTemplates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
export type PosterStyleTemplateId =
| "classic"
| "minimal"
| "editorial"
| "caption"
| "masthead";

export type PosterTextAlign = "left" | "center" | "right";

export interface PosterFadeSpec {
top?: number;
bottom?: number;
left?: number;
right?: number;
}

export interface PosterFrameSpec {
type: "none" | "hairline" | "mat";
inset: number;
alpha: number;
}

export interface PosterTextPanelSpec {
x: number;
y: number;
width: number;
height: number;
alpha: number;
}

export interface PosterTextLayoutSpec {
align: PosterTextAlign;
x: number;
width: number;
cityY: number;
countryY: number;
coordsY: number;
dividerY?: number;
dividerStart?: number;
dividerEnd?: number;
cityScale: number;
countryScale: number;
coordsScale: number;
letterSpacedCity: boolean;
showDivider: boolean;
panel?: PosterTextPanelSpec;
}

export interface PosterAttributionSpec {
margin: number;
side: "split" | "right" | "stacked-left";
}

export interface PosterStyleTemplate {
id: PosterStyleTemplateId;
name: string;
description: string;
fade: PosterFadeSpec;
frame: PosterFrameSpec;
text: PosterTextLayoutSpec;
attribution: PosterAttributionSpec;
}

/**
* Poster style templates — print-grade typographic lockups designed to read as
* framed wall art rather than web cards.
*
* Design rules baked into these numbers:
* - The big title and any rule/sub-lines never overlap. City text occupies
* roughly `cityScale * 4.6%` of the poster height (centred on `cityY`), so
* every divider/country/coords row is spaced clear of the title's descenders.
* - Fades exist only to *seat* the type, never to mute the map: they reach just
* far enough to carry the lockup and dissolve well before the focal area.
* - Each template has one job. Horizon is the grand centred classic, Compass is
* a poised right-aligned eyebrow lockup, Editorial is the bold left masthead,
* Gallery is the matted fine-art print, and Masthead bands the title
* cinematically up top.
*/
export const POSTER_STYLE_TEMPLATES: PosterStyleTemplate[] = [
{
id: "classic",
name: "Horizon",
description: "The grand centered title, floating on a soft horizon of map.",
fade: { bottom: 0.4 },
frame: { type: "none", inset: 0, alpha: 0 },
text: {
align: "center",
x: 0.5,
width: 0.86,
cityY: 0.838,
dividerY: 0.882,
countryY: 0.907,
coordsY: 0.941,
dividerStart: 0.44,
dividerEnd: 0.56,
cityScale: 0.94,
countryScale: 0.88,
coordsScale: 0.85,
letterSpacedCity: true,
showDivider: true,
},
attribution: { margin: 0.032, side: "split" },
},
{
id: "minimal",
name: "Compass",
description: "A poised right-aligned lockup, region set as an eyebrow above the city.",
fade: { bottom: 0.4 },
frame: { type: "none", inset: 0, alpha: 0 },
text: {
align: "right",
x: 0.915,
width: 0.8,
countryY: 0.808,
dividerY: 0.84,
cityY: 0.878,
coordsY: 0.916,
dividerStart: 0.715,
dividerEnd: 0.915,
cityScale: 0.82,
countryScale: 0.78,
coordsScale: 0.8,
letterSpacedCity: false,
showDivider: true,
},
attribution: { margin: 0.032, side: "split" },
},
{
id: "editorial",
name: "Editorial",
description: "A bold, left-aligned lockup with a magazine cover's confidence.",
fade: { bottom: 0.44 },
frame: { type: "none", inset: 0, alpha: 0 },
text: {
align: "left",
x: 0.085,
width: 0.82,
cityY: 0.828,
dividerY: 0.876,
countryY: 0.903,
coordsY: 0.939,
dividerStart: 0.085,
dividerEnd: 0.26,
cityScale: 1,
countryScale: 0.85,
coordsScale: 0.85,
letterSpacedCity: false,
showDivider: true,
},
attribution: { margin: 0.036, side: "split" },
},
{
id: "caption",
name: "Gallery",
description: "A matted fine-art print with a delicate top-left caption.",
fade: { top: 0.3, bottom: 0.12 },
frame: { type: "mat", inset: 0.055, alpha: 0.5 },
text: {
align: "left",
x: 0.1,
width: 0.74,
cityY: 0.115,
dividerY: 0.158,
countryY: 0.184,
coordsY: 0.215,
dividerStart: 0.1,
dividerEnd: 0.28,
cityScale: 0.6,
countryScale: 0.85,
coordsScale: 0.8,
letterSpacedCity: true,
showDivider: true,
},
attribution: { margin: 0.072, side: "split" },
},
{
id: "masthead",
name: "Masthead",
description: "A cinematic title banded across the crown of the map.",
fade: { top: 0.38, bottom: 0.14 },
frame: { type: "none", inset: 0, alpha: 0 },
text: {
align: "center",
x: 0.5,
width: 0.84,
cityY: 0.135,
dividerY: 0.182,
countryY: 0.207,
coordsY: 0.239,
dividerStart: 0.44,
dividerEnd: 0.56,
cityScale: 0.82,
countryScale: 0.82,
coordsScale: 0.82,
letterSpacedCity: true,
showDivider: true,
},
attribution: { margin: 0.032, side: "split" },
},
];

export const DEFAULT_POSTER_STYLE_TEMPLATE_ID: PosterStyleTemplateId = "classic";

export function getPosterStyleTemplate(
templateId: string | undefined | null,
): PosterStyleTemplate {
return (
POSTER_STYLE_TEMPLATES.find((template) => template.id === templateId) ??
POSTER_STYLE_TEMPLATES[0]
);
}
5 changes: 4 additions & 1 deletion src/features/poster/domain/textLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export function isLatinScript(text: string | undefined | null): boolean {
return latinCount / alphaCount > 0.8;
}

export function formatCityLabel(city: string): string {
export function formatCityLabel(city: string, letterSpaced = true): string {
if (!letterSpaced) {
return city.toUpperCase();
}
return isLatinScript(city) ? city.toUpperCase().split("").join(" ") : city;
}

Expand Down
3 changes: 3 additions & 0 deletions src/features/poster/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
MarkerProjectionInput,
} from "@/features/markers/domain/types";
import type { Route } from "@/features/routes/domain/types";
import type { PosterStyleTemplateId } from "./posterStyleTemplates";

export interface CanvasSize {
width: number;
Expand All @@ -26,6 +27,7 @@ export interface ExportOptions {
fontFamily: string;
showPosterText: boolean;
showOverlay?: boolean;
posterStyleTemplate?: PosterStyleTemplateId;
includeCredits?: boolean;
markers?: MarkerItem[];
markerIcons?: MarkerIconDefinition[];
Expand All @@ -42,5 +44,6 @@ export interface Typography {
displayContinent?: string;
fontFamily: string;
showPosterText: boolean;
posterStyleTemplate?: PosterStyleTemplateId;
includeCredits?: boolean;
}
Loading