From 44a0bf1879629d816c47fca54eac4295d3880460 Mon Sep 17 00:00:00 2001 From: Arham Musheer Date: Sun, 21 Jun 2026 20:17:24 +0530 Subject: [PATCH 1/2] Add poster style templates --- src/core/config.ts | 2 +- src/features/export/application/useExport.ts | 2 + .../infrastructure/layeredSvgExporter.ts | 24 +- .../poster/application/posterReducer.ts | 2 + .../poster/application/useFormHandlers.ts | 13 + .../poster/domain/posterStyleTemplates.ts | 211 ++++++++++++++ src/features/poster/domain/textLayout.ts | 5 +- src/features/poster/domain/types.ts | 3 + .../poster/infrastructure/renderer/index.ts | 13 +- .../poster/infrastructure/renderer/layers.ts | 99 ++++++- .../infrastructure/renderer/typography.ts | 127 ++++++--- src/features/poster/ui/GradientFades.tsx | 53 +++- src/features/poster/ui/PosterContext.tsx | 2 + src/features/poster/ui/PosterTextOverlay.tsx | 137 ++++++--- src/features/poster/ui/PreviewPanel.tsx | 8 +- src/features/poster/ui/SettingsPanel.tsx | 3 + src/features/poster/ui/TypographySection.tsx | 54 +++- src/styles/forms.css | 263 ++++++++++++++++++ src/styles/preview.css | 52 +++- 19 files changed, 955 insertions(+), 118 deletions(-) create mode 100644 src/features/poster/domain/posterStyleTemplates.ts diff --git a/src/core/config.ts b/src/core/config.ts index c56b4113..427dfdfd 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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 = 60; export const DEFAULT_POSTER_WIDTH_CM = 20; export const DEFAULT_POSTER_HEIGHT_CM = 30; export const LAYOUT_MATCH_TOLERANCE_CM = 0.01; diff --git a/src/features/export/application/useExport.ts b/src/features/export/application/useExport.ts index 1b2c9126..4aa30930 100644 --- a/src/features/export/application/useExport.ts +++ b/src/features/export/application/useExport.ts @@ -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) @@ -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) diff --git a/src/features/export/infrastructure/layeredSvgExporter.ts b/src/features/export/infrastructure/layeredSvgExporter.ts index 6fdaca97..82c9f986 100644 --- a/src/features/export/infrastructure/layeredSvgExporter.ts +++ b/src/features/export/infrastructure/layeredSvgExporter.ts @@ -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, @@ -29,6 +36,7 @@ interface LayeredSvgOptions { showPosterText: boolean; showOverlay: boolean; includeCredits: boolean; + posterStyleTemplate?: PosterStyleTemplateId; markers: MarkerItem[]; markerIcons: MarkerIconDefinition[]; routes?: Route[]; @@ -84,11 +92,13 @@ export async function createLayeredSvgBlobFromMap({ showPosterText, showOverlay, includeCredits, + posterStyleTemplate, markers, markerIcons, routes = [], }: LayeredSvgOptions): Promise { await waitForMapIdle(map); + const template = getPosterStyleTemplate(posterStyleTemplate); const { center: mapCenter, @@ -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); }), }); } @@ -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) => { @@ -257,6 +276,7 @@ export async function createLayeredSvgBlobFromMap({ showPosterText, showOverlay, includeCredits, + template.id, ); }), }); diff --git a/src/features/poster/application/posterReducer.ts b/src/features/poster/application/posterReducer.ts index 5442750f..55dfd77a 100644 --- a/src/features/poster/application/posterReducer.ts +++ b/src/features/poster/application/posterReducer.ts @@ -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 ────── */ @@ -34,6 +35,7 @@ export interface PosterForm { displayCountry: string; displayContinent: string; fontFamily: string; + posterStyleTemplate: PosterStyleTemplateId; showPosterText: boolean; includeCredits: boolean; includeLandcover: boolean; diff --git a/src/features/poster/application/useFormHandlers.ts b/src/features/poster/application/useFormHandlers.ts index 4abaebc5..d2b3a739 100644 --- a/src/features/poster/application/useFormHandlers.ts +++ b/src/features/poster/application/useFormHandlers.ts @@ -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, @@ -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, @@ -193,5 +205,6 @@ export function useFormHandlers() { handleClearLocation, setLocationFocused, handleCreditsChange, + handlePosterStyleTemplateChange, }; } diff --git a/src/features/poster/domain/posterStyleTemplates.ts b/src/features/poster/domain/posterStyleTemplates.ts new file mode 100644 index 00000000..506380bb --- /dev/null +++ b/src/features/poster/domain/posterStyleTemplates.ts @@ -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] + ); +} diff --git a/src/features/poster/domain/textLayout.ts b/src/features/poster/domain/textLayout.ts index 13dab3d6..004be27e 100644 --- a/src/features/poster/domain/textLayout.ts +++ b/src/features/poster/domain/textLayout.ts @@ -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; } diff --git a/src/features/poster/domain/types.ts b/src/features/poster/domain/types.ts index 97cf7f16..b1c21a60 100644 --- a/src/features/poster/domain/types.ts +++ b/src/features/poster/domain/types.ts @@ -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; @@ -26,6 +27,7 @@ export interface ExportOptions { fontFamily: string; showPosterText: boolean; showOverlay?: boolean; + posterStyleTemplate?: PosterStyleTemplateId; includeCredits?: boolean; markers?: MarkerItem[]; markerIcons?: MarkerIconDefinition[]; @@ -42,5 +44,6 @@ export interface Typography { displayContinent?: string; fontFamily: string; showPosterText: boolean; + posterStyleTemplate?: PosterStyleTemplateId; includeCredits?: boolean; } diff --git a/src/features/poster/infrastructure/renderer/index.ts b/src/features/poster/infrastructure/renderer/index.ts index 72d6ac16..2738c36f 100644 --- a/src/features/poster/infrastructure/renderer/index.ts +++ b/src/features/poster/infrastructure/renderer/index.ts @@ -1,9 +1,10 @@ -import { applyFades } from "./layers"; +import { applyFades, drawTemplateFrame } from "./layers"; import { drawPosterText } from "./typography"; import { drawMarkersOnCanvas } from "@/features/markers/infrastructure/rendering"; import { drawRoutesOnCanvas } from "@/features/routes/infrastructure/rendering"; import { routeEndpointMarkerItems } from "@/features/routes/infrastructure/helpers"; import type { ExportOptions, CanvasSize } from "../../domain/types"; +import { getPosterStyleTemplate } from "@/features/poster/domain/posterStyleTemplates"; /** * Composites a final poster from a MapLibre snapshot canvas. @@ -28,6 +29,7 @@ export async function compositeExport( fontFamily, showPosterText = true, showOverlay = true, + posterStyleTemplate, includeCredits = true, markers = [], markerIcons = [], @@ -40,6 +42,7 @@ export async function compositeExport( const width = mapCanvas.width; const height = mapCanvas.height; + const template = getPosterStyleTemplate(posterStyleTemplate); const canvas = document.createElement("canvas"); canvas.width = width; @@ -53,7 +56,7 @@ export async function compositeExport( // 2. Gradient fades if (showOverlay) { - applyFades(ctx, width, height, theme.ui.bg); + applyFades(ctx, width, height, theme.ui.bg, template); } // 3. Routes (below markers) @@ -97,7 +100,10 @@ export async function compositeExport( ); } - // 6. Poster text + // 6. Template frame + drawTemplateFrame(ctx, width, height, theme.ui.text, template); + + // 7. Poster text drawPosterText( ctx, width, @@ -110,6 +116,7 @@ export async function compositeExport( showPosterText, showOverlay, includeCredits, + posterStyleTemplate, ); const size: CanvasSize = { diff --git a/src/features/poster/infrastructure/renderer/layers.ts b/src/features/poster/infrastructure/renderer/layers.ts index 6078b2e3..15071f94 100644 --- a/src/features/poster/infrastructure/renderer/layers.ts +++ b/src/features/poster/infrastructure/renderer/layers.ts @@ -1,20 +1,97 @@ import { withAlpha } from "@/shared/utils/color"; +import type { PosterStyleTemplate } from "@/features/poster/domain/posterStyleTemplates"; export function applyFades( ctx: CanvasRenderingContext2D, width: number, height: number, color: string, + template: PosterStyleTemplate, ): void { - const topGradient = ctx.createLinearGradient(0, 0, 0, height * 0.25); - topGradient.addColorStop(0, withAlpha(color, 1)); - topGradient.addColorStop(1, withAlpha(color, 0)); - ctx.fillStyle = topGradient; - ctx.fillRect(0, 0, width, height * 0.25); - - const bottomGradient = ctx.createLinearGradient(0, height, 0, height * 0.75); - bottomGradient.addColorStop(0, withAlpha(color, 1)); - bottomGradient.addColorStop(1, withAlpha(color, 0)); - ctx.fillStyle = bottomGradient; - ctx.fillRect(0, height * 0.75, width, height * 0.25); + const { fade } = template; + + if (fade.top) { + const topHeight = height * fade.top; + const topGradient = ctx.createLinearGradient(0, 0, 0, topHeight); + topGradient.addColorStop(0, withAlpha(color, 1)); + topGradient.addColorStop(1, withAlpha(color, 0)); + ctx.fillStyle = topGradient; + ctx.fillRect(0, 0, width, topHeight); + } + + if (fade.bottom) { + const bottomHeight = height * fade.bottom; + const bottomGradient = ctx.createLinearGradient( + 0, + height, + 0, + height - bottomHeight, + ); + bottomGradient.addColorStop(0, withAlpha(color, 1)); + bottomGradient.addColorStop(1, withAlpha(color, 0)); + ctx.fillStyle = bottomGradient; + ctx.fillRect(0, height - bottomHeight, width, bottomHeight); + } + + if (fade.left) { + const leftWidth = width * fade.left; + const leftGradient = ctx.createLinearGradient(0, 0, leftWidth, 0); + leftGradient.addColorStop(0, withAlpha(color, 1)); + leftGradient.addColorStop(1, withAlpha(color, 0)); + ctx.fillStyle = leftGradient; + ctx.fillRect(0, 0, leftWidth, height); + } + + if (fade.right) { + const rightWidth = width * fade.right; + const rightGradient = ctx.createLinearGradient( + width, + 0, + width - rightWidth, + 0, + ); + rightGradient.addColorStop(0, withAlpha(color, 1)); + rightGradient.addColorStop(1, withAlpha(color, 0)); + ctx.fillStyle = rightGradient; + ctx.fillRect(width - rightWidth, 0, rightWidth, height); + } +} + +export function drawTemplateFrame( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + color: string, + template: PosterStyleTemplate, +): void { + const { frame } = template; + if (frame.type === "none") return; + + const inset = Math.min(width, height) * frame.inset; + const lineWidth = + frame.type === "mat" ? Math.max(2, inset * 0.18) : Math.max(1, inset * 0.08); + + ctx.save(); + ctx.strokeStyle = withAlpha(color, frame.alpha); + ctx.lineWidth = lineWidth; + ctx.strokeRect( + inset + lineWidth / 2, + inset + lineWidth / 2, + width - inset * 2 - lineWidth, + height - inset * 2 - lineWidth, + ); + + if (frame.type === "mat") { + const innerInset = inset + lineWidth * 2.4; + ctx.globalAlpha = 0.4; + ctx.lineWidth = Math.max(1, lineWidth * 0.35); + ctx.strokeRect( + innerInset, + innerInset, + width - innerInset * 2, + height - innerInset * 2, + ); + } + + ctx.restore(); } diff --git a/src/features/poster/infrastructure/renderer/typography.ts b/src/features/poster/infrastructure/renderer/typography.ts index e8f6827c..4e023c9c 100644 --- a/src/features/poster/infrastructure/renderer/typography.ts +++ b/src/features/poster/infrastructure/renderer/typography.ts @@ -1,28 +1,29 @@ import { formatCoordinates } from "@/shared/geo/posterBounds"; import type { Coordinate } from "@/shared/geo/types"; import { APP_CREDIT_URL } from "@/core/config"; +import { withAlpha } from "@/shared/utils/color"; +import type { ResolvedTheme } from "@/features/theme/domain/types"; import { TEXT_DIMENSION_REFERENCE_PX, - TEXT_CITY_Y_RATIO, - TEXT_DIVIDER_Y_RATIO, - TEXT_COUNTRY_Y_RATIO, - TEXT_COORDS_Y_RATIO, - TEXT_EDGE_MARGIN_RATIO, CITY_FONT_BASE_PX, COUNTRY_FONT_BASE_PX, COORDS_FONT_BASE_PX, ATTRIBUTION_FONT_BASE_PX, - isLatinScript, formatCityLabel, computeCityFontScale, computeAttributionColor, } from "@/features/poster/domain/textLayout"; +import { + DEFAULT_POSTER_STYLE_TEMPLATE_ID, + getPosterStyleTemplate, + type PosterStyleTemplateId, +} from "@/features/poster/domain/posterStyleTemplates"; export function drawPosterText( ctx: CanvasRenderingContext2D, width: number, height: number, - theme: { ui?: { text?: string } }, + theme: ResolvedTheme, center: Coordinate, city: string, country: string, @@ -30,7 +31,10 @@ export function drawPosterText( showPosterText: boolean, showOverlay: boolean, includeCredits: boolean = true, + templateId: PosterStyleTemplateId = DEFAULT_POSTER_STYLE_TEMPLATE_ID, ): void { + const template = getPosterStyleTemplate(templateId); + const text = template.text; const textColor = theme.ui?.text || "#111111"; const landColor = theme.map?.land || "#808080"; const attributionColor = computeAttributionColor(textColor, landColor, showOverlay); @@ -48,66 +52,113 @@ export function drawPosterText( ); const attributionFontSize = ATTRIBUTION_FONT_BASE_PX * dimScale; + ctx.save(); + if (showPosterText) { - const cityLabel = formatCityLabel(city); - const cityFontSize = CITY_FONT_BASE_PX * dimScale * computeCityFontScale(city); + const cityLabel = formatCityLabel(city, text.letterSpacedCity); + const cityFontSize = + CITY_FONT_BASE_PX * + dimScale * + computeCityFontScale(city) * + text.cityScale; + const countryFontSize = COUNTRY_FONT_BASE_PX * dimScale * text.countryScale; + const coordinateFontSize = COORDS_FONT_BASE_PX * dimScale * text.coordsScale; + const cityY = height * text.cityY; + const lineY = height * (text.dividerY ?? text.countryY); + const countryY = height * text.countryY; + const coordinatesY = height * text.coordsY; + const anchorX = width * text.x; + const maxTextWidth = width * text.width; - const countryFontSize = COUNTRY_FONT_BASE_PX * dimScale; - const coordinateFontSize = COORDS_FONT_BASE_PX * dimScale; - const cityY = height * TEXT_CITY_Y_RATIO; - const lineY = height * TEXT_DIVIDER_Y_RATIO; - const countryY = height * TEXT_COUNTRY_Y_RATIO; - const coordinatesY = height * TEXT_COORDS_Y_RATIO; + if (text.panel) { + ctx.fillStyle = withAlpha(theme.ui.bg, text.panel.alpha); + ctx.fillRect( + width * text.panel.x, + height * text.panel.y, + width * text.panel.width, + height * text.panel.height, + ); + ctx.strokeStyle = withAlpha(textColor, 0.22); + ctx.lineWidth = Math.max(1, dimScale * 1.5); + ctx.strokeRect( + width * text.panel.x, + height * text.panel.y, + width * text.panel.width, + height * text.panel.height, + ); + } ctx.fillStyle = textColor; - ctx.textAlign = "center"; + ctx.textAlign = text.align; ctx.textBaseline = "middle"; ctx.font = `700 ${cityFontSize}px ${titleFontFamily}`; - ctx.fillText(cityLabel, width * 0.5, cityY); + ctx.fillText(cityLabel, anchorX, cityY, maxTextWidth); - ctx.strokeStyle = textColor; - ctx.lineWidth = 3 * dimScale; - ctx.beginPath(); - ctx.moveTo(width * 0.4, lineY); - ctx.lineTo(width * 0.6, lineY); - ctx.stroke(); + if (text.showDivider) { + ctx.strokeStyle = textColor; + ctx.lineWidth = 3 * dimScale; + ctx.beginPath(); + ctx.moveTo(width * (text.dividerStart ?? 0.4), lineY); + ctx.lineTo(width * (text.dividerEnd ?? 0.6), lineY); + ctx.stroke(); + } ctx.font = `300 ${countryFontSize}px ${titleFontFamily}`; - ctx.fillText(country.toUpperCase(), width * 0.5, countryY); + ctx.fillText(country.toUpperCase(), anchorX, countryY, maxTextWidth); ctx.globalAlpha = 0.75; ctx.font = `400 ${coordinateFontSize}px ${bodyFontFamily}`; ctx.fillText( formatCoordinates(center.lat, center.lon), - width * 0.5, + anchorX, coordinatesY, + maxTextWidth, ); ctx.globalAlpha = 1; } + const margin = template.attribution.margin; ctx.fillStyle = attributionColor; ctx.globalAlpha = attributionAlpha; - ctx.textAlign = "right"; ctx.textBaseline = "bottom"; ctx.font = `300 ${attributionFontSize}px ${bodyFontFamily}`; + + if (template.attribution.side === "stacked-left") { + ctx.textAlign = "left"; + ctx.fillText( + "\u00a9 OpenStreetMap contributors", + width * margin, + height * (1 - margin), + ); + if (includeCredits) { + ctx.fillText( + `\u00a9 ${APP_CREDIT_URL}`, + width * margin, + height * (1 - margin) - attributionFontSize * 1.25, + ); + } + ctx.restore(); + return; + } + + ctx.textAlign = "right"; ctx.fillText( "\u00a9 OpenStreetMap contributors", - width * (1 - TEXT_EDGE_MARGIN_RATIO), - height * (1 - TEXT_EDGE_MARGIN_RATIO), + width * (1 - margin), + height * (1 - margin), ); - ctx.globalAlpha = 1; if (includeCredits) { - ctx.fillStyle = attributionColor; - ctx.globalAlpha = attributionAlpha; - ctx.textAlign = "left"; - ctx.textBaseline = "bottom"; - ctx.font = `300 ${attributionFontSize}px ${bodyFontFamily}`; + const isRightStack = template.attribution.side === "right"; + ctx.textAlign = isRightStack ? "right" : "left"; ctx.fillText( - `© ${APP_CREDIT_URL}`, - width * TEXT_EDGE_MARGIN_RATIO, - height * (1 - TEXT_EDGE_MARGIN_RATIO), + `\u00a9 ${APP_CREDIT_URL}`, + isRightStack ? width * (1 - margin) : width * margin, + isRightStack + ? height * (1 - margin) - attributionFontSize * 1.25 + : height * (1 - margin), ); - ctx.globalAlpha = 1; } + + ctx.restore(); } diff --git a/src/features/poster/ui/GradientFades.tsx b/src/features/poster/ui/GradientFades.tsx index d3eb33b0..ec790209 100644 --- a/src/features/poster/ui/GradientFades.tsx +++ b/src/features/poster/ui/GradientFades.tsx @@ -1,31 +1,58 @@ import { withAlpha } from "@/shared/utils/color"; +import type { PosterStyleTemplate } from "@/features/poster/domain/posterStyleTemplates"; interface GradientFadesProps { color: string; + template: PosterStyleTemplate; } /** * CSS gradient overlays that fade the top and bottom of the poster frame. * Matches the canvas-based `applyFades()` behaviour but runs on the GPU. */ -export default function GradientFades({ color }: GradientFadesProps) { +export default function GradientFades({ color, template }: GradientFadesProps) { const solid = withAlpha(color, 1); const transparent = withAlpha(color, 0); + const { fade } = template; return ( <> -
-
+ {fade.top ? ( +
+ ) : null} + {fade.bottom ? ( +
+ ) : null} + {fade.left ? ( +
+ ) : null} + {fade.right ? ( +
+ ) : null} ); } diff --git a/src/features/poster/ui/PosterContext.tsx b/src/features/poster/ui/PosterContext.tsx index 85f33722..c6b45f2b 100644 --- a/src/features/poster/ui/PosterContext.tsx +++ b/src/features/poster/ui/PosterContext.tsx @@ -26,6 +26,7 @@ import { saveCustomMarkerIcons, } from "@/features/markers/infrastructure/customIconStorage"; import { createDefaultRouteSettings } from "@/features/routes/infrastructure/helpers"; +import { DEFAULT_POSTER_STYLE_TEMPLATE_ID } from "@/features/poster/domain/posterStyleTemplates"; /* ────── Default form (moved from appConfig) ────── */ @@ -65,6 +66,7 @@ export const DEFAULT_FORM: PosterForm = { displayCountry: "Germany", displayContinent: "Europe", fontFamily: "", + posterStyleTemplate: DEFAULT_POSTER_STYLE_TEMPLATE_ID, showPosterText: true, includeCredits: true, includeLandcover: true, diff --git a/src/features/poster/ui/PosterTextOverlay.tsx b/src/features/poster/ui/PosterTextOverlay.tsx index 10532427..c68b56f0 100644 --- a/src/features/poster/ui/PosterTextOverlay.tsx +++ b/src/features/poster/ui/PosterTextOverlay.tsx @@ -1,12 +1,8 @@ import { formatCoordinates } from "@/shared/geo/posterBounds"; import { APP_CREDIT_URL } from "@/core/config"; +import { withAlpha } from "@/shared/utils/color"; import { TEXT_DIMENSION_REFERENCE_PX, - TEXT_CITY_Y_RATIO, - TEXT_DIVIDER_Y_RATIO, - TEXT_COUNTRY_Y_RATIO, - TEXT_COORDS_Y_RATIO, - TEXT_EDGE_MARGIN_RATIO, CITY_FONT_BASE_PX, COUNTRY_FONT_BASE_PX, COORDS_FONT_BASE_PX, @@ -15,6 +11,7 @@ import { computeCityFontScale, computeAttributionColor, } from "@/features/poster/domain/textLayout"; +import type { PosterStyleTemplate } from "@/features/poster/domain/posterStyleTemplates"; interface PosterTextOverlayProps { city: string; @@ -27,13 +24,9 @@ interface PosterTextOverlayProps { showPosterText: boolean; includeCredits: boolean; showOverlay: boolean; + template: PosterStyleTemplate; } -/** - * DOM-based poster text overlay (sharp at any resolution, GPU-composited). - * Renders city name, divider, country, coordinates, and attribution - * positioned to match the canvas export layout exactly. - */ export default function PosterTextOverlay({ city, country, @@ -45,8 +38,10 @@ export default function PosterTextOverlay({ showPosterText, includeCredits, showOverlay, + template, }: PosterTextOverlayProps) { const toCqMin = (px: number) => (px / TEXT_DIMENSION_REFERENCE_PX) * 100; + const text = template.text; const titleFont = fontFamily ? `"${fontFamily}", "Space Grotesk", sans-serif` @@ -55,51 +50,126 @@ export default function PosterTextOverlay({ ? `"${fontFamily}", "IBM Plex Mono", monospace` : '"IBM Plex Mono", monospace'; - const cityLabel = formatCityLabel(city); - const cityFontSize = `${toCqMin(CITY_FONT_BASE_PX) * computeCityFontScale(city)}cqmin`; - const countryFontSize = `${toCqMin(COUNTRY_FONT_BASE_PX)}cqmin`; - const coordsFontSize = `${toCqMin(COORDS_FONT_BASE_PX)}cqmin`; + const cityLabel = formatCityLabel(city, text.letterSpacedCity); + const cityFontSize = `${ + toCqMin(CITY_FONT_BASE_PX) * + computeCityFontScale(city) * + text.cityScale + }cqmin`; + const countryFontSize = `${ + toCqMin(COUNTRY_FONT_BASE_PX) * text.countryScale + }cqmin`; + const coordsFontSize = `${toCqMin(COORDS_FONT_BASE_PX) * text.coordsScale}cqmin`; const attributionFontSize = `${toCqMin(ATTRIBUTION_FONT_BASE_PX)}cqmin`; const attributionColor = computeAttributionColor(textColor, landColor, showOverlay); const attributionOpacity = showOverlay ? 0.55 : 0.9; + const margin = template.attribution.margin * 100; + const attributionBaseStyle = { + fontFamily: bodyFont, + color: attributionColor, + opacity: attributionOpacity, + fontSize: attributionFontSize, + }; + const textBoxStyle = { + left: + text.align === "center" + ? `${(text.x - text.width / 2) * 100}%` + : text.align === "right" + ? `${(text.x - text.width) * 100}%` + : `${text.x * 100}%`, + width: `${text.width * 100}%`, + textAlign: text.align, + } as const; + const creditPosition = + template.attribution.side === "right" + ? { + right: `${margin}%`, + bottom: `calc(${margin}% + ${toCqMin(ATTRIBUTION_FONT_BASE_PX) * 1.25}cqmin)`, + textAlign: "right" as const, + } + : template.attribution.side === "stacked-left" + ? { + left: `${margin}%`, + bottom: `calc(${margin}% + ${toCqMin(ATTRIBUTION_FONT_BASE_PX) * 1.25}cqmin)`, + textAlign: "left" as const, + } + : { + left: `${margin}%`, + bottom: `${margin}%`, + textAlign: "left" as const, + }; return (
+ {template.frame.type !== "none" ? ( +
+ ) : null} + {showPosterText && ( <> + {text.panel ? ( +
+ ) : null} +

{cityLabel}

-
+ + {text.showDivider ? ( +
+ ) : null} +

{country.toUpperCase()}

+

{formatCoordinates(lat, lon)} @@ -110,12 +180,9 @@ export default function PosterTextOverlay({ © OpenStreetMap contributors @@ -125,15 +192,11 @@ export default function PosterTextOverlay({ - © {APP_CREDIT_URL} + © {APP_CREDIT_URL} )}

diff --git a/src/features/poster/ui/PreviewPanel.tsx b/src/features/poster/ui/PreviewPanel.tsx index ff829f48..0ce1a984 100644 --- a/src/features/poster/ui/PreviewPanel.tsx +++ b/src/features/poster/ui/PreviewPanel.tsx @@ -43,6 +43,7 @@ import { formatLayoutDimensions, getLayoutOption, } from "@/features/layout/infrastructure/layoutRepository"; +import { getPosterStyleTemplate } from "@/features/poster/domain/posterStyleTemplates"; const LOCKED_HINT = "Map is locked to prevent unintended movement."; const UNLOCK_HINT = `${LOCKED_HINT}\nClick to unlock map editing.`; @@ -168,6 +169,7 @@ export default function PreviewPanel() { const widthCm = Number(form.width) || DEFAULT_POSTER_WIDTH_CM; const heightCm = Number(form.height) || DEFAULT_POSTER_HEIGHT_CM; const aspect = widthCm / heightCm; + const posterStyleTemplate = getPosterStyleTemplate(form.posterStyleTemplate); const formLat = Number(form.latitude) || 0; const formLon = Number(form.longitude) || 0; const layoutOption = @@ -414,7 +416,10 @@ export default function PreviewPanel() { onMoveEnd={handleMoveEnd} /> {form.showMarkers ? ( - + ) : null}
diff --git a/src/features/poster/ui/SettingsPanel.tsx b/src/features/poster/ui/SettingsPanel.tsx index 6790f80d..b305fd71 100644 --- a/src/features/poster/ui/SettingsPanel.tsx +++ b/src/features/poster/ui/SettingsPanel.tsx @@ -72,6 +72,7 @@ export default function SettingsPanel({ handleClearLocation, setLocationFocused, handleCreditsChange, + handlePosterStyleTemplateChange, } = useFormHandlers(); const { locationSuggestions, isLocationSearching, searchNow } = useLocationAutocomplete( state.form.location, @@ -301,6 +302,8 @@ export default function SettingsPanel({ ) : null} diff --git a/src/features/poster/ui/TypographySection.tsx b/src/features/poster/ui/TypographySection.tsx index b3cce5bb..48bb2c6f 100644 --- a/src/features/poster/ui/TypographySection.tsx +++ b/src/features/poster/ui/TypographySection.tsx @@ -6,19 +6,26 @@ import { PLACEHOLDER_EXAMPLE_CITY, PLACEHOLDER_EXAMPLE_COUNTRY, } from "@/features/location/ui/constants"; +import { + POSTER_STYLE_TEMPLATES, + type PosterStyleTemplateId, +} from "@/features/poster/domain/posterStyleTemplates"; interface TypographySectionProps { form: PosterForm; onChange: ( event: React.ChangeEvent, ) => void; + onCreditsChange: (value: boolean) => void; + onPosterStyleTemplateChange: (templateId: PosterStyleTemplateId) => void; fontOptions: FontOption[]; } - export default function TypographySection({ form, onChange, + onCreditsChange, + onPosterStyleTemplateChange, fontOptions, }: TypographySectionProps) { useEffect(() => { @@ -33,6 +40,38 @@ export default function TypographySection({ <>

STYLE

+
+ {POSTER_STYLE_TEMPLATES.map((template) => { + const isSelected = form.posterStyleTemplate === template.id; + return ( + + ); + })} +
+ +
-
); diff --git a/src/styles/forms.css b/src/styles/forms.css index ba1ca68d..6dd63b4c 100644 --- a/src/styles/forms.css +++ b/src/styles/forms.css @@ -983,6 +983,269 @@ button.color-editor-target:focus-visible { text-transform: uppercase; } +.poster-style-template-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +button.poster-style-template-card { + margin: 0; + min-width: 0; + min-height: 154px; + padding: 8px; + border: 1px solid rgba(151, 183, 207, 0.26); + border-radius: 8px; + background: rgba(9, 18, 27, 0.74); + color: #dbeaf5; + display: grid; + grid-template-rows: 88px minmax(0, 1fr); + gap: 8px; + text-align: left; +} + +button.poster-style-template-card:not(:disabled):hover, +button.poster-style-template-card:focus-visible { + transform: none; + filter: none; + border-color: rgba(112, 184, 222, 0.72); + background: rgba(18, 36, 51, 0.92); +} + +button.poster-style-template-card.is-selected { + border-color: rgba(125, 216, 240, 0.9); + box-shadow: + 0 0 0 1px rgba(125, 216, 240, 0.34), + 0 10px 26px rgba(0, 0, 0, 0.22); +} + +.poster-style-template-preview { + position: relative; + width: 100%; + height: 88px; + overflow: hidden; + border-radius: 6px; + border: 1px solid rgba(214, 233, 247, 0.14); + background: + linear-gradient(135deg, rgba(55, 97, 122, 0.72), rgba(21, 42, 56, 0.9)), + linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px); + background-size: auto, 13px 13px, 13px 13px; +} + +.poster-style-template-map { + position: absolute; + inset: 0; + background: + linear-gradient(24deg, transparent 0 42%, rgba(174, 219, 175, 0.22) 43% 49%, transparent 50%), + linear-gradient(148deg, transparent 0 54%, rgba(97, 165, 193, 0.28) 55% 61%, transparent 62%); +} + +.poster-style-template-frame, +.poster-style-template-panel, +.poster-style-template-title, +.poster-style-template-line, +.poster-style-template-meta { + position: absolute; + display: block; +} + +.poster-style-template-title, +.poster-style-template-line, +.poster-style-template-meta { + background: rgba(238, 248, 255, 0.88); +} + +.poster-style-template-title { + height: 7px; + border-radius: 999px; +} + +.poster-style-template-line { + height: 1px; + border-radius: 999px; + opacity: 0.84; +} + +.poster-style-template-meta { + height: 3px; + border-radius: 999px; + opacity: 0.68; +} + +.poster-style-template-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.poster-style-template-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #edf7ff; +} + +.poster-style-template-description { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: 0.68rem; + line-height: 1.35; + font-weight: 500; + color: rgba(185, 207, 222, 0.86); +} + +/* Horizon — grand centered title seated on a soft bottom horizon. */ +.poster-style-template-card--classic .poster-style-template-preview { + background: + linear-gradient(to top, rgba(8, 17, 25, 0.94), transparent 46%), + linear-gradient(135deg, rgba(55, 97, 122, 0.72), rgba(21, 42, 56, 0.9)); +} + +.poster-style-template-card--classic .poster-style-template-title { + left: 22%; + right: 22%; + bottom: 24px; +} + +.poster-style-template-card--classic .poster-style-template-line { + left: 43%; + right: 43%; + bottom: 18px; +} + +.poster-style-template-card--classic .poster-style-template-meta { + left: 36%; + right: 36%; + bottom: 11px; +} + +/* Compass — poised right-aligned lockup, region eyebrow above the city. */ +.poster-style-template-card--minimal .poster-style-template-preview { + background: + linear-gradient(to top, rgba(8, 17, 25, 0.94), transparent 46%), + linear-gradient(135deg, rgba(48, 86, 108, 0.7), rgba(18, 38, 51, 0.9)); +} + +.poster-style-template-card--minimal .poster-style-template-frame { + display: none; +} + +.poster-style-template-card--minimal .poster-style-template-meta { + right: 11%; + width: 24%; + bottom: 40px; +} + +.poster-style-template-card--minimal .poster-style-template-line { + right: 11%; + width: 20%; + bottom: 33px; +} + +.poster-style-template-card--minimal .poster-style-template-title { + right: 11%; + width: 48%; + bottom: 18px; +} + +/* Editorial — bold left-aligned masthead, short kicker rule. */ +.poster-style-template-card--editorial .poster-style-template-preview { + background: + linear-gradient(to top, rgba(8, 17, 25, 0.96), transparent 50%), + linear-gradient(135deg, rgba(72, 89, 113, 0.9), rgba(18, 44, 54, 0.9)); +} + +.poster-style-template-card--editorial .poster-style-template-frame { + display: none; +} + +.poster-style-template-card--editorial .poster-style-template-title { + left: 11%; + width: 52%; + height: 9px; + bottom: 24px; +} + +.poster-style-template-card--editorial .poster-style-template-line { + left: 11%; + width: 18%; + bottom: 17px; +} + +.poster-style-template-card--editorial .poster-style-template-meta { + left: 11%; + width: 34%; + bottom: 10px; +} + +/* Gallery — matted fine-art frame with a delicate centered caption. */ +.poster-style-template-card--caption .poster-style-template-panel { + display: none; +} + +.poster-style-template-card--caption .poster-style-template-preview { + background: + linear-gradient(to bottom, rgba(8, 17, 25, 0.82), transparent 42%), + linear-gradient(135deg, rgba(55, 97, 122, 0.72), rgba(21, 42, 56, 0.9)); +} + +.poster-style-template-card--caption .poster-style-template-frame { + inset: 7px; + border: 1px solid rgba(238, 248, 255, 0.5); + box-shadow: inset 0 0 0 3px rgba(238, 248, 255, 0.16); +} + +.poster-style-template-card--caption .poster-style-template-title { + left: 16%; + width: 40%; + top: 16px; +} + +.poster-style-template-card--caption .poster-style-template-line { + left: 16%; + width: 20%; + top: 29px; +} + +.poster-style-template-card--caption .poster-style-template-meta { + left: 16%; + width: 30%; + top: 38px; +} + +/* Masthead — cinematic title banded across the top. */ +.poster-style-template-card--masthead .poster-style-template-preview { + background: + linear-gradient(to bottom, rgba(8, 17, 25, 0.94), transparent 46%), + linear-gradient(135deg, rgba(42, 93, 116, 0.78), rgba(21, 42, 56, 0.9)); +} + +.poster-style-template-card--masthead .poster-style-template-title { + left: 22%; + right: 22%; + top: 16px; +} + +.poster-style-template-card--masthead .poster-style-template-line { + left: 43%; + right: 43%; + top: 30px; +} + +.poster-style-template-card--masthead .poster-style-template-meta { + left: 36%; + right: 36%; + top: 40px; +} + .layout-custom-editor { margin-bottom: 10px; } diff --git a/src/styles/preview.css b/src/styles/preview.css index 1a61488c..abce268a 100644 --- a/src/styles/preview.css +++ b/src/styles/preview.css @@ -87,6 +87,23 @@ bottom: 0; } +.poster-fade--left, +.poster-fade--right { + top: 0; + bottom: 0; + height: auto; +} + +.poster-fade--left { + left: 0; + right: auto; +} + +.poster-fade--right { + left: auto; + right: 0; +} + /* ── Text overlay ── */ .poster-text-overlay { @@ -150,17 +167,36 @@ box-shadow: 0 0 0 1px rgba(7, 16, 24, 0.68); } +.poster-template-frame { + position: absolute; + pointer-events: none; + z-index: 1; + border: 1px solid currentcolor; +} + +.poster-template-frame--mat { + border-width: max(2px, 0.45cqmin); + box-shadow: inset 0 0 0 max(1px, 0.12cqmin) rgba(255, 255, 255, 0.18); +} + +.poster-template-panel { + position: absolute; + pointer-events: none; + z-index: 1; + border: 1px solid currentcolor; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22); +} + .poster-city { position: absolute; - left: 0; - right: 0; + right: auto; margin: 0; font-weight: 700; text-transform: uppercase; - text-align: center; line-height: 1.1; transform: translateY(-50%); white-space: pre-wrap; + z-index: 2; } .poster-divider { @@ -176,28 +212,26 @@ .poster-country { position: absolute; - left: 0; - right: 0; + right: auto; margin: 0; font-weight: 300; font-size: 2.6cqh; text-transform: uppercase; - text-align: center; line-height: 1.2; transform: translateY(-50%); + z-index: 2; } .poster-coords { position: absolute; - left: 0; - right: 0; + right: auto; margin: 0; font-family: "Spline Sans Mono", monospace; font-weight: 400; font-size: 1.6cqh; - text-align: center; opacity: 0.75; transform: translateY(-50%); + z-index: 2; } .poster-attribution { From 7d8d7c43fa769139b3faff55dd8683ae017c131f Mon Sep 17 00:00:00 2001 From: Arham Musheer Date: Sun, 21 Jun 2026 20:31:18 +0530 Subject: [PATCH 2/2] Restore config to original --- src/core/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/config.ts b/src/core/config.ts index 427dfdfd..ca5f6ef7 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -4,7 +4,7 @@ export const CM_PER_INCH = 2.54; export const MIN_POSTER_CM = 4; -export const MAX_POSTER_CM = 60; +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;