import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ChevronRight, Download, Expand, Layers3, LayoutTemplate, LoaderCircle, MapPin, Minus, Move, Palette, Play, Plus, Redo2, Search, SlidersHorizontal, Type, Undo2, X, } from 'lucide-react' import { exportPoster, getStyles, prepareMap, renderPreview, searchPlaces } from './api' import { MapViewport } from './components/MapViewport' import type { BBox, LayerState, LocationResult, PosterRequest, PreviewPhase, StylePreset, Typography } from './types' import './App.css' const DEFAULT_LOCATION: LocationResult = { display_name: 'Beijing Forestry University', latitude: 40.0012, longitude: 116.3482, country: 'China', region: 'Beijing', country_code: 'cn', provider: 'default', } const DEFAULT_BBOX: BBox = { west: 116.292, south: 39.955, east: 116.405, north: 40.047 } const DEFAULT_TYPE: Typography = { title: 'BEIJING FORESTRY UNIVERSITY', subtitle: 'BEIJING', caption: '', coordinates: '', font_family: 'auto', title_size: 46, subtitle_size: 17, caption_size: 13, coordinate_size: 11, letter_spacing: 0.08, line_height: 1.15, alignment: 'center', show_coordinates: true, show_divider: true, } const DEFAULT_LAYERS: LayerState = { motorway: true, primary: true, secondary: true, residential: true, water: true, parks: true, } const PANEL_IDS = ['location', 'style', 'layout', 'type', 'layers', 'size'] as const type PanelId = typeof PANEL_IDS[number] type Orientation = 'portrait' | 'landscape' const SIZE_OPTIONS = [ { id: '3:4', label: '3:4', aspect: 3 / 4, orientation: 'portrait' }, { id: '4:5', label: '4:5', aspect: 4 / 5, orientation: 'portrait' }, { id: '2:3', label: '2:3', aspect: 2 / 3, orientation: 'portrait' }, { id: '9:16', label: '9:16', aspect: 9 / 16, orientation: 'portrait' }, { id: 'A4', label: 'A4', aspect: 210 / 297, orientation: 'portrait' }, { id: 'A3', label: 'A3', aspect: 297 / 420, orientation: 'portrait' }, { id: '4:3', label: '4:3', aspect: 4 / 3, orientation: 'landscape' }, { id: '5:4', label: '5:4', aspect: 5 / 4, orientation: 'landscape' }, { id: '3:2', label: '3:2', aspect: 3 / 2, orientation: 'landscape' }, { id: '16:9', label: '16:9', aspect: 16 / 9, orientation: 'landscape' }, { id: 'A4-landscape', label: 'A4', aspect: 297 / 210, orientation: 'landscape' }, { id: 'A3-landscape', label: 'A3', aspect: 420 / 297, orientation: 'landscape' }, { id: '1:1', label: '1:1', aspect: 1, orientation: 'square' }, ] as const type PosterSizeId = typeof SIZE_OPTIONS[number]['id'] const ORIENTATION_PAIR: Partial> = { '3:4': '4:3', '4:3': '3:4', '4:5': '5:4', '5:4': '4:5', '2:3': '3:2', '3:2': '2:3', '9:16': '16:9', '16:9': '9:16', A4: 'A4-landscape', 'A4-landscape': 'A4', A3: 'A3-landscape', 'A3-landscape': 'A3', } const LAYOUT_OPTIONS = ['classic', 'editorial', 'minimal', 'bottom_left', 'centered'] const panelMeta = { location: ['Location', MapPin], style: ['Style', Palette], layout: ['Layout', LayoutTemplate], type: ['Type', Type], layers: ['Layers', Layers3], size: ['Size', SlidersHorizontal], } as const function extentFor(location: LocationResult): BBox { const latSpan = 0.09 const lonSpan = latSpan / Math.max(Math.cos(location.latitude * Math.PI / 180), 0.25) return { west: Math.max(-179.999, location.longitude - lonSpan / 2), south: Math.max(-89.99, location.latitude - latSpan / 2), east: Math.min(179.999, location.longitude + lonSpan / 2), north: Math.min(89.99, location.latitude + latSpan / 2), } } function Section({ id, title, open, onToggle, children }: { id: PanelId; title: string; open: boolean; onToggle: () => void; children: React.ReactNode }) { return
{open &&
{children}
}
} function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return } function ProgressFeedback({ value, label, elapsed }: { value: number; label: string; elapsed: number }) { const rounded = Math.round(value) return
{label}{rounded}% · {elapsed}s
} function GenerateControl({ phase, stale, disabled, onGenerate }: { phase: PreviewPhase stale: boolean disabled: boolean onGenerate: () => void }) { const busy = phase === 'preparing' || phase === 'rendering' const label = phase === 'preparing' ? 'Fetching map data…' : phase === 'rendering' ? 'Rendering preview…' : stale ? 'Generate preview' : 'Generate again' return
Map data is fetched only after you start generation.
} export default function App() { const [activePanel, setActivePanel] = useState('style') const [openPanels, setOpenPanels] = useState>(new Set(['location', 'style', 'type'])) const [location, setLocation] = useState(DEFAULT_LOCATION) const [bbox, setBBox] = useState(DEFAULT_BBOX) const [mapZoom, setMapZoom] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [searching, setSearching] = useState(false) const [styles, setStyles] = useState([]) const [styleId, setStyleId] = useState('japanese_ink') const [customColors, setCustomColors] = useState>({}) const [typography, setTypography] = useState(DEFAULT_TYPE) const [layout, setLayout] = useState('classic') const [layers, setLayers] = useState(DEFAULT_LAYERS) const [orientation, setOrientation] = useState('portrait') const [size, setSize] = useState('3:4') const [mapDataId, setMapDataId] = useState('') const [preparedSignature, setPreparedSignature] = useState('') const [renderedSignature, setRenderedSignature] = useState('') const [previewUrl, setPreviewUrl] = useState('/sample-poster.webp') const [previewPhase, setPreviewPhase] = useState('idle') const [statusText, setStatusText] = useState('Adjust the settings, then generate a preview.') const [generationProgress, setGenerationProgress] = useState(0) const [generationElapsed, setGenerationElapsed] = useState(0) const [error, setError] = useState('') const [canvasZoom, setCanvasZoom] = useState(82) const [exportOpen, setExportOpen] = useState(false) const [exporting, setExporting] = useState(false) const [exportProgress, setExportProgress] = useState(0) const [exportElapsed, setExportElapsed] = useState(0) const [exportStatus, setExportStatus] = useState('') const previewObjectUrl = useRef(null) const generationController = useRef(null) const exportController = useRef(null) const generationStartedAt = useRef(0) const exportStartedAt = useRef(0) const selectedStyle = styles.find((style) => style.id === styleId) const effectiveColors = useMemo(() => selectedStyle ? { background: selectedStyle.background, text: selectedStyle.text, water: selectedStyle.water, parks: selectedStyle.parks, road_motorway: selectedStyle.road_motorway, road_primary: selectedStyle.road_primary, road_secondary: selectedStyle.road_secondary, road_tertiary: selectedStyle.road_tertiary, road_residential: selectedStyle.road_residential, road_default: selectedStyle.road_default, gradient: selectedStyle.gradient, ...customColors, } : customColors, [customColors, selectedStyle]) const poster = useMemo(() => ({ location, bbox, distance_m: 10_000, zoom: mapZoom, network_type: 'all', style_id: styleId, colors: customColors, typography, layout, layers, size: { preset: size }, }), [bbox, customColors, layers, layout, location, mapZoom, size, styleId, typography]) const posterSignature = useMemo(() => JSON.stringify(poster), [poster]) const dataSignature = useMemo(() => JSON.stringify({ location, bbox, network: poster.network_type }), [bbox, location, poster.network_type]) const prepareRequest = useMemo(() => ({ location, bbox, distance_m: 10_000, zoom: mapZoom, network_type: 'all' as const, style_id: 'japanese_ink', colors: {}, typography: DEFAULT_TYPE, layout, layers: DEFAULT_LAYERS, size: { preset: size }, }), [bbox, layout, location, mapZoom, size]) const sizeOption = SIZE_OPTIONS.find((option) => option.id === size) ?? SIZE_OPTIONS[0] const posterAspect = sizeOption.aspect const visibleSizeOptions = SIZE_OPTIONS.filter((option) => option.orientation === orientation || option.orientation === 'square') const isGenerating = previewPhase === 'preparing' || previewPhase === 'rendering' const previewStale = renderedSignature !== posterSignature useEffect(() => { const controller = new AbortController() getStyles(controller.signal).then((items) => { setStyles(items) setStyleId((current) => items.some((item) => item.id === current) ? current : items[0]?.id ?? current) }).catch((reason: Error) => { if (reason.name !== 'AbortError') setError(reason.message) }) return () => controller.abort() }, []) useEffect(() => { if (!isGenerating) return const timer = window.setInterval(() => { setGenerationElapsed(Math.floor((Date.now() - generationStartedAt.current) / 1000)) setGenerationProgress((current) => { const ceiling = previewPhase === 'preparing' ? 58 : 94 return Math.min(ceiling, current + Math.max(0.6, (ceiling - current) * 0.04)) }) }, 400) return () => window.clearInterval(timer) }, [isGenerating, previewPhase]) useEffect(() => { if (!exporting) return const timer = window.setInterval(() => { setExportElapsed(Math.floor((Date.now() - exportStartedAt.current) / 1000)) setExportProgress((current) => Math.min(94, current + Math.max(0.6, (94 - current) * 0.04))) }, 400) return () => window.clearInterval(timer) }, [exporting]) useEffect(() => () => { generationController.current?.abort() exportController.current?.abort() if (previewObjectUrl.current) URL.revokeObjectURL(previewObjectUrl.current) }, []) const runSearch = async (event: React.FormEvent) => { event.preventDefault() const query = searchQuery.trim() if (!query) return setSearching(true); setError('') try { setSearchResults(await searchPlaces(query)) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Search failed') } finally { setSearching(false) } } const chooseLocation = (result: LocationResult) => { setLocation(result); setBBox(extentFor(result)); setMapZoom(null); setSearchResults([]); setSearchQuery('') const title = result.display_name.split(',')[0]?.trim() || result.display_name setTypography((current) => ({ ...current, title, subtitle: result.region || result.country, caption: '' })) } const changeOrientation = (next: Orientation) => { if (next === orientation) return setOrientation(next) setSize((current) => current === '1:1' ? current : ORIENTATION_PAIR[current] ?? (next === 'portrait' ? '3:4' : '4:3')) } const updateColor = (key: string, value: string) => setCustomColors((current) => ({ ...current, [key]: value })) const togglePanel = (id: PanelId) => { setActivePanel(id) setOpenPanels((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next }) } const updateViewport = useCallback((nextBBox: BBox, zoom: number) => { const rounded = Object.fromEntries(Object.entries(nextBBox).map(([key, value]) => [key, Number(value.toFixed(6))])) as BBox setBBox((current) => { const unchanged = (Object.keys(rounded) as (keyof BBox)[]).every((key) => Math.abs(current[key] - rounded[key]) < 0.00001) return unchanged ? current : rounded }) setMapZoom((current) => current !== null && Math.abs(current - zoom) < 0.01 ? current : Number(zoom.toFixed(2))) }, []) const runGeneration = async () => { const requestPoster = poster const requestPosterSignature = posterSignature const requestDataSignature = dataSignature const controller = new AbortController() generationController.current?.abort() generationController.current = controller generationStartedAt.current = Date.now() setGenerationElapsed(0); setGenerationProgress(4); setPreviewPhase('preparing') setStatusText('Fetching streets, water, and parks…'); setError('') try { let currentMapDataId = mapDataId if (!currentMapDataId || preparedSignature !== requestDataSignature) { const result = await prepareMap(prepareRequest, controller.signal) currentMapDataId = result.map_data_id setMapDataId(currentMapDataId); setPreparedSignature(requestDataSignature) setStatusText(result.cache_hit ? 'Map data loaded from cache. Rendering preview…' : 'Map data ready. Rendering preview…') } else { setStatusText('Using prepared map data. Rendering preview…') } setGenerationProgress(68); setPreviewPhase('rendering') const blob = await renderPreview(currentMapDataId, requestPoster, controller.signal) if (previewObjectUrl.current) URL.revokeObjectURL(previewObjectUrl.current) const url = URL.createObjectURL(blob) previewObjectUrl.current = url setPreviewUrl(url); setRenderedSignature(requestPosterSignature); setGenerationProgress(100); setPreviewPhase('success') setStatusText(`Preview ready in ${((Date.now() - generationStartedAt.current) / 1000).toFixed(1)}s`) } catch (reason) { if (reason instanceof Error && reason.name === 'AbortError') return setPreviewPhase('error'); setError(reason instanceof Error ? reason.message : 'Preview failed') setStatusText('Could not generate the preview') } finally { if (generationController.current === controller) generationController.current = null } } const download = async (format: 'png' | 'svg' | 'pdf', dpi: number) => { if (!mapDataId || preparedSignature !== dataSignature) { setError('Wait for the map data to finish loading before export.'); return } const controller = new AbortController() exportController.current?.abort() exportController.current = controller exportStartedAt.current = Date.now() setExporting(true); setExportProgress(5); setExportElapsed(0); setExportOpen(false); setError('') setExportStatus(`Rendering ${format.toUpperCase()} at ${dpi} DPI…`) try { const result = await exportPoster(mapDataId, poster, format, dpi, controller.signal) setExportProgress(100) const url = URL.createObjectURL(result.blob) const link = document.createElement('a'); link.href = url; link.download = result.filename; link.click() window.setTimeout(() => URL.revokeObjectURL(url), 1000) setStatusText(`${format.toUpperCase()} downloaded in ${((Date.now() - exportStartedAt.current) / 1000).toFixed(1)}s`) } catch (reason) { if (!(reason instanceof Error && reason.name === 'AbortError')) setError(reason instanceof Error ? reason.message : 'Export failed') } finally { if (exportController.current === controller) exportController.current = null setExporting(false) } } const panelContent: Record = { location: <>
setSearchQuery(event.target.value)} placeholder="Search a place…" aria-label="Search a place" /> {searchQuery && } {searchResults.length > 0 &&
{searchResults.map((result) => )}
}
{location.display_name}{location.latitude.toFixed(4)}° / {location.longitude.toFixed(4)}°

Drag or zoom to set the exact area used by the poster.

, style: <>
{styles.slice(0, 6).map((style) => )}
{styles.length > 6 && }
Customize{Object.keys(customColors).length > 0 && }
updateColor('background', value)} /> updateColor('text', value)} /> updateColor('water', value)} /> updateColor('parks', value)} /> updateColor('road_primary', value)} /> updateColor('road_residential', value)} />
, layout:
{LAYOUT_OPTIONS.map((value) => )}
, type:
, layers:
{Object.entries(layers).map(([key, value]) => )}
, size: <>
{visibleSizeOptions.map((option) => )}

The selected map area is always kept in full. Export uses this ratio and DPI.

, } return
MapToPoster
{exportOpen &&
}
{panelMeta[activePanel][0]}Poster settings
{panelContent[activePanel]}
= 1 ? 'is-wide' : 'is-tall'}`} style={{ transform: `scale(${canvasZoom / 100})`, aspectRatio: String(posterAspect) }}> {`Poster {isGenerating &&
}
{exporting || isGenerating ? : previewPhase === 'success' && !previewStale ? : previewPhase === 'error' ? : }{exporting ? exportStatus : error || (previewPhase === 'success' && previewStale ? 'Settings changed. Generate again to update the preview.' : statusText)}
{exporting ? : isGenerating ? : null}
{canvasZoom}%
}