Spaces:
Running
Running
Download frontend/src/App.tsx from isaachwf/MapToPoster: direct link, hf CLI and curl.
- Browser
- Download file 27.4 kB
-
https://huggingface.co/spaces/isaachwf/MapToPoster/resolve/main/frontend/src/App.tsx
- Command line
-
hf download hf://spaces/isaachwf/MapToPoster/frontend/src/App.tsx
-
curl -L -o App.tsx https://huggingface.co/spaces/isaachwf/MapToPoster/resolve/main/frontend/src/App.tsx
27.4 kB
| 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<Record<PosterSizeId, PosterSizeId>> = { | |
| '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 <section className={`editor-section editor-section-${id} ${open ? 'is-open' : ''}`}> | |
| <button className="section-heading" type="button" onClick={onToggle} aria-expanded={open}> | |
| <span>{title}</span>{open ? <ChevronDown size={17} /> : <ChevronRight size={17} />} | |
| </button> | |
| {open && <div className="section-content">{children}</div>} | |
| </section> | |
| } | |
| function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { | |
| return <label className="color-field"> | |
| <span>{label}</span><span className="color-control"><input type="color" value={value} onChange={(event) => onChange(event.target.value)} /><code>{value.toUpperCase()}</code></span> | |
| </label> | |
| } | |
| function ProgressFeedback({ value, label, elapsed }: { value: number; label: string; elapsed: number }) { | |
| const rounded = Math.round(value) | |
| return <div className="progress-feedback"> | |
| <div className="progress-meta"><span>{label}</span><span>{rounded}% · {elapsed}s</span></div> | |
| <div className="progress-track" role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={rounded}> | |
| <span className="progress-fill" style={{ width: `${rounded}%` }} /> | |
| </div> | |
| </div> | |
| } | |
| 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 <div className="generate-footer"> | |
| <button className="generate-button" type="button" onClick={onGenerate} disabled={disabled}> | |
| {busy ? <LoaderCircle className="spin" size={17} /> : <Play size={16} fill="currentColor" />} | |
| <span>{label}</span> | |
| </button> | |
| <small>Map data is fetched only after you start generation.</small> | |
| </div> | |
| } | |
| export default function App() { | |
| const [activePanel, setActivePanel] = useState<PanelId>('style') | |
| const [openPanels, setOpenPanels] = useState<Set<PanelId>>(new Set(['location', 'style', 'type'])) | |
| const [location, setLocation] = useState(DEFAULT_LOCATION) | |
| const [bbox, setBBox] = useState(DEFAULT_BBOX) | |
| const [mapZoom, setMapZoom] = useState<number | null>(null) | |
| const [searchQuery, setSearchQuery] = useState('') | |
| const [searchResults, setSearchResults] = useState<LocationResult[]>([]) | |
| const [searching, setSearching] = useState(false) | |
| const [styles, setStyles] = useState<StylePreset[]>([]) | |
| const [styleId, setStyleId] = useState('japanese_ink') | |
| const [customColors, setCustomColors] = useState<Record<string, string>>({}) | |
| const [typography, setTypography] = useState(DEFAULT_TYPE) | |
| const [layout, setLayout] = useState('classic') | |
| const [layers, setLayers] = useState(DEFAULT_LAYERS) | |
| const [orientation, setOrientation] = useState<Orientation>('portrait') | |
| const [size, setSize] = useState<PosterSizeId>('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<PreviewPhase>('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<string | null>(null) | |
| const generationController = useRef<AbortController | null>(null) | |
| const exportController = useRef<AbortController | null>(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<PosterRequest>(() => ({ | |
| 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<PanelId, React.ReactNode> = { | |
| location: <> | |
| <form className="search-box" onSubmit={runSearch}> | |
| <Search size={16} /><input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Search a place…" aria-label="Search a place" /> | |
| {searchQuery && <button type="button" className="icon-button" onClick={() => { setSearchQuery(''); setSearchResults([]) }} aria-label="Clear search"><X size={15} /></button>} | |
| <button className="search-submit" type="submit" disabled={searching}>{searching ? <LoaderCircle className="spin" size={15} /> : 'Search'}</button> | |
| </form> | |
| {searchResults.length > 0 && <div className="search-results">{searchResults.map((result) => <button key={`${result.latitude}-${result.longitude}-${result.display_name}`} onClick={() => chooseLocation(result)}> | |
| <MapPin size={15} /><span><strong>{result.display_name}</strong><small>{[result.region, result.country].filter(Boolean).join(', ')}</small></span> | |
| </button>)}</div>} | |
| <div className="selected-place"><span className="pin"><MapPin size={17} /></span><span><strong>{location.display_name}</strong><small>{location.latitude.toFixed(4)}° / {location.longitude.toFixed(4)}°</small></span></div> | |
| <MapViewport longitude={location.longitude} latitude={location.latitude} bbox={bbox} onViewportChange={updateViewport} /> | |
| <p className="field-help">Drag or zoom to set the exact area used by the poster.</p> | |
| </>, | |
| style: <> | |
| <div className="style-grid">{styles.slice(0, 6).map((style) => <button key={style.id} className={`style-card ${style.id === styleId ? 'is-selected' : ''}`} onClick={() => { setStyleId(style.id); setCustomColors({}) }} title={style.description}> | |
| <span className="style-preview" style={{ backgroundColor: style.background, color: style.road_primary }}>{style.preview && <img src={style.preview} alt="" onError={(event) => { event.currentTarget.style.display = 'none' }} />}<i /><i /><i /></span> | |
| <span>{style.name}</span>{style.id === styleId && <b><Check size={12} /></b>} | |
| </button>)}</div> | |
| {styles.length > 6 && <label className="field"><span>More styles</span><select value={styleId} onChange={(event) => { setStyleId(event.target.value); setCustomColors({}) }}>{styles.map((style) => <option key={style.id} value={style.id}>{style.name}</option>)}</select></label>} | |
| <div className="subheading"><span>Customize</span>{Object.keys(customColors).length > 0 && <button onClick={() => setCustomColors({})}>Reset</button>}</div> | |
| <div className="color-list"> | |
| <ColorField label="Background" value={effectiveColors.background ?? '#ffffff'} onChange={(value) => updateColor('background', value)} /> | |
| <ColorField label="Text" value={effectiveColors.text ?? '#17191d'} onChange={(value) => updateColor('text', value)} /> | |
| <ColorField label="Water" value={effectiveColors.water ?? '#c9c6bf'} onChange={(value) => updateColor('water', value)} /> | |
| <ColorField label="Parks" value={effectiveColors.parks ?? '#eae7e1'} onChange={(value) => updateColor('parks', value)} /> | |
| <ColorField label="Major roads" value={effectiveColors.road_primary ?? '#222222'} onChange={(value) => updateColor('road_primary', value)} /> | |
| <ColorField label="Minor roads" value={effectiveColors.road_residential ?? '#777777'} onChange={(value) => updateColor('road_residential', value)} /> | |
| </div> | |
| </>, | |
| layout: <div className="choice-grid">{LAYOUT_OPTIONS.map((value) => <button key={value} className={layout === value ? 'is-selected' : ''} onClick={() => setLayout(value)}><span className={`layout-glyph ${value}`} /><strong>{value.replace('_', ' ')}</strong></button>)}</div>, | |
| type: <div className="form-stack"> | |
| <label className="field"><span>Title</span><input value={typography.title} onChange={(event) => setTypography({ ...typography, title: event.target.value })} /></label> | |
| <label className="field"><span>Subtitle</span><input value={typography.subtitle} onChange={(event) => setTypography({ ...typography, subtitle: event.target.value })} /></label> | |
| <div className="two-fields"><label className="field"><span>Title size</span><input type="number" min="12" max="120" value={typography.title_size} onChange={(event) => setTypography({ ...typography, title_size: Number(event.target.value) })} /></label><label className="field"><span>Alignment</span><select value={typography.alignment} onChange={(event) => setTypography({ ...typography, alignment: event.target.value as Typography['alignment'] })}><option>left</option><option>center</option><option>right</option></select></label></div> | |
| <label className="range-field"><span>Letter spacing <output>{Math.round(typography.letter_spacing * 100)}%</output></span><input type="range" min="0" max="0.3" step="0.01" value={typography.letter_spacing} onChange={(event) => setTypography({ ...typography, letter_spacing: Number(event.target.value) })} /></label> | |
| <label className="check-field"><input type="checkbox" checked={typography.show_coordinates} onChange={(event) => setTypography({ ...typography, show_coordinates: event.target.checked })} />Show coordinates</label> | |
| <label className="check-field"><input type="checkbox" checked={typography.show_divider} onChange={(event) => setTypography({ ...typography, show_divider: event.target.checked })} />Show divider</label> | |
| </div>, | |
| layers: <div className="toggle-list">{Object.entries(layers).map(([key, value]) => <label key={key}><span>{key.replace('_', ' ')}</span><input type="checkbox" checked={value} onChange={(event) => setLayers({ ...layers, [key]: event.target.checked })} /></label>)}</div>, | |
| size: <> | |
| <div className="orientation-toggle" role="group" aria-label="Poster orientation"> | |
| <button type="button" className={orientation === 'portrait' ? 'is-selected' : ''} aria-pressed={orientation === 'portrait'} onClick={() => changeOrientation('portrait')}>Portrait</button> | |
| <button type="button" className={orientation === 'landscape' ? 'is-selected' : ''} aria-pressed={orientation === 'landscape'} onClick={() => changeOrientation('landscape')}>Landscape</button> | |
| </div> | |
| <div className="size-grid">{visibleSizeOptions.map((option) => <button key={option.id} aria-label={`${orientation} ${option.label}`} className={size === option.id ? 'is-selected' : ''} onClick={() => setSize(option.id)}><span className="size-shape" style={{ aspectRatio: String(option.aspect) }} />{option.label}</button>)}</div> | |
| <p className="field-help">The selected map area is always kept in full. Export uses this ratio and DPI.</p> | |
| </>, | |
| } | |
| return <div className="app-shell"> | |
| <header className="topbar"> | |
| <a className="brand" href="/">MapToPoster</a> | |
| <div className="topbar-tools"><button className="desktop-only icon-button" disabled aria-label="Undo"><Undo2 size={18} /></button><button className="desktop-only icon-button" disabled aria-label="Redo"><Redo2 size={18} /></button><button className="desktop-only quiet-button"><Layers3 size={17} /> Layers</button> | |
| <div className="export-group"><button className="export-primary" onClick={() => download('png', 300)} disabled={exporting || isGenerating}>{exporting ? <LoaderCircle className="spin" size={16} /> : <Download size={16} />}<span>{exporting ? `${Math.round(exportProgress)}%` : 'Export'}</span></button><button className="export-toggle" onClick={() => setExportOpen(!exportOpen)} disabled={exporting || isGenerating} aria-label="Export options"><ChevronDown size={15} /></button> | |
| {exportOpen && <div className="export-menu"><button onClick={() => download('png', 150)}>PNG · Preview</button><button onClick={() => download('png', 300)}>PNG · Print 300 DPI</button><button onClick={() => download('svg', 300)}>SVG · Vector</button><button onClick={() => download('pdf', 300)}>PDF · Print</button></div>} | |
| </div> | |
| </div> | |
| </header> | |
| <aside className="sidebar"> | |
| {PANEL_IDS.map((id) => <Section key={id} id={id} title={panelMeta[id][0]} open={openPanels.has(id)} onToggle={() => togglePanel(id)}>{panelContent[id]}</Section>)} | |
| <GenerateControl phase={previewPhase} stale={previewStale} disabled={isGenerating || exporting} onGenerate={runGeneration} /> | |
| </aside> | |
| <nav className="mobile-tabs" aria-label="Editor panels">{PANEL_IDS.map((id) => { const Icon = panelMeta[id][1]; return <button key={id} className={activePanel === id ? 'is-active' : ''} onClick={() => setActivePanel(id)}><Icon size={19} /><span>{panelMeta[id][0]}</span></button> })}</nav> | |
| <section className="mobile-sheet"><div className="mobile-sheet-heading"><strong>{panelMeta[activePanel][0]}</strong><span>Poster settings</span></div>{panelContent[activePanel]}<GenerateControl phase={previewPhase} stale={previewStale} disabled={isGenerating || exporting} onGenerate={runGeneration} /></section> | |
| <main className="workspace"> | |
| <div className="canvas-stage"> | |
| <div className={`poster-frame ${posterAspect >= 1 ? 'is-wide' : 'is-tall'}`} style={{ transform: `scale(${canvasZoom / 100})`, aspectRatio: String(posterAspect) }}> | |
| <img src={previewUrl} alt={`Poster preview for ${location.display_name}`} /> | |
| {isGenerating && <div className="preview-loading"><LoaderCircle className="spin" /><ProgressFeedback value={generationProgress} label={statusText} elapsed={generationElapsed} /></div>} | |
| </div> | |
| </div> | |
| <div className={`preview-status ${error ? 'error' : previewPhase}`} role="status"> | |
| <div className="preview-status-copy">{exporting || isGenerating ? <LoaderCircle className="spin" size={15} /> : previewPhase === 'success' && !previewStale ? <Check size={15} /> : previewPhase === 'error' ? <X size={15} /> : <Play size={13} />}<span>{exporting ? exportStatus : error || (previewPhase === 'success' && previewStale ? 'Settings changed. Generate again to update the preview.' : statusText)}</span></div> | |
| {exporting ? <ProgressFeedback value={exportProgress} label={exportStatus} elapsed={exportElapsed} /> : isGenerating ? <ProgressFeedback value={generationProgress} label={statusText} elapsed={generationElapsed} /> : null} | |
| </div> | |
| <div className="canvas-toolbar"><div className="tool-group"><button aria-label="Pan"><Move size={17} /></button><button aria-label="Fit canvas" onClick={() => setCanvasZoom(82)}><Expand size={17} /></button></div><div className="tool-group zoom-controls"><button onClick={() => setCanvasZoom((value) => Math.max(35, value - 10))} aria-label="Zoom out"><Minus size={16} /></button><output>{canvasZoom}%</output><button onClick={() => setCanvasZoom((value) => Math.min(140, value + 10))} aria-label="Zoom in"><Plus size={16} /></button></div><button className="fit-button" onClick={() => setCanvasZoom(82)}>Fit</button></div> | |
| </main> | |
| </div> | |
| } | |