when-am-i / files / src / components / ResultPanel.tsx
ResultPanel.tsx
| 1 | import React from 'react'; |
| 2 | import type { Answer, Era, HistoricalDate, Resolution } from '../types'; |
| 3 | import { repoFileUrl } from '../utils/report'; |
| 4 | import { parseGregorianDate } from '../utils/julianDay'; |
| 5 | |
| 6 | interface ResultPanelProps { |
| 7 | picked: { lat: number; lng: number } | null; |
| 8 | date: HistoricalDate; |
| 9 | resolution: Resolution | null; |
| 10 | /** Same calendar→color mapping used by the map overlays and year timeline. */ |
| 11 | colorFor: (calendarName: string) => string; |
| 12 | } |
| 13 | |
| 14 | function yearText(date: HistoricalDate): string { |
| 15 | return date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`; |
| 16 | } |
| 17 | |
| 18 | function yearLabel(year: number): string { |
| 19 | return year > 0 ? `${year} CE` : `${1 - year} BCE`; |
| 20 | } |
| 21 | |
| 22 | // The era's span at this place, surfacing the changeover dates structurally — |
| 23 | // e.g. "In use here since 1954" calls out Vietnam's switch to the Gregorian |
| 24 | // calendar without burying it in the description prose. Drawn straight from the |
| 25 | // era's own start/end (no cross-era guessing); null for timeless eras. |
| 26 | function eraSpanText(era: Era): string | null { |
| 27 | const start = era.start ? parseGregorianDate(era.start).year : null; |
| 28 | const end = era.end ? parseGregorianDate(era.end).year : null; |
| 29 | if (start === null && end === null) return null; |
| 30 | if (start !== null && end !== null) return `In use here ${yearLabel(start)} – ${yearLabel(end)}`; |
| 31 | if (start !== null) return `In use here since ${yearLabel(start)}`; |
| 32 | return `In use here until ${yearLabel(end as number)}`; |
| 33 | } |
| 34 | |
| 35 | const MONTHS = [ |
| 36 | 'January', 'February', 'March', 'April', 'May', 'June', |
| 37 | 'July', 'August', 'September', 'October', 'November', 'December', |
| 38 | ]; |
| 39 | |
| 40 | // Full modern date in the narrator's voice (CE/BCE), matching the page header — |
| 41 | // distinct from the local answer cards, which speak in AD/BC. See calendars.ts. |
| 42 | function fullDateText(date: HistoricalDate): string { |
| 43 | return `${MONTHS[date.month - 1]} ${date.day}, ${yearText(date)}`; |
| 44 | } |
| 45 | |
| 46 | const cardStyle: React.CSSProperties = { |
| 47 | border: '1px solid #e3e5e8', |
| 48 | borderRadius: 8, |
| 49 | padding: 16, |
| 50 | background: '#fff', |
| 51 | }; |
| 52 | |
| 53 | // Readable title from a source URL's last path segment — e.g. a Wikipedia |
| 54 | // ".../wiki/King_Philip%27s_War" becomes "King Philip's War". Falls back to the |
| 55 | // raw URL if it can't be parsed. |
| 56 | function sourceTitle(url: string): string { |
| 57 | try { |
| 58 | const { pathname, hostname } = new URL(url); |
| 59 | const slug = pathname.split('/').filter(Boolean).pop() ?? hostname; |
| 60 | return decodeURIComponent(slug).replace(/_/g, ' '); |
| 61 | } catch { |
| 62 | return url; |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | function Sources({ sources }: { sources?: string[] }) { |
| 67 | if (!sources || sources.length === 0) return null; |
| 68 | return ( |
| 69 | <p style={{ margin: '8px 0 0', fontSize: '0.8em', color: '#888' }}> |
| 70 | Sources:{' '} |
| 71 | {sources.map((src, i) => ( |
| 72 | <React.Fragment key={src}> |
| 73 | {i > 0 && ', '} |
| 74 | <a href={src} target="_blank" rel="noopener noreferrer" style={{ color: '#888' }}> |
| 75 | [{i + 1} - {sourceTitle(src)}] |
| 76 | </a> |
| 77 | </React.Fragment> |
| 78 | ))} |
| 79 | </p> |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | // "About N winters since <event>" — a reckoning a local could give when their |
| 84 | // calendar has no numbered year. The anchor event is sourced data, so we link it. |
| 85 | function Reckoning({ reckoning }: { reckoning?: { label: string; sources: string[] } }) { |
| 86 | if (!reckoning) return null; |
| 87 | return ( |
| 88 | <p style={{ margin: '6px 0 0', fontSize: '0.95em', color: '#444' }}> |
| 89 | … and {reckoning.label}. |
| 90 | <span style={{ fontSize: '0.85em', color: '#888' }}> |
| 91 | {' '} |
| 92 | {reckoning.sources.map((src, i) => ( |
| 93 | <React.Fragment key={src}> |
| 94 | {i > 0 && ' '} |
| 95 | <a href={src} target="_blank" rel="noopener noreferrer" style={{ color: '#888' }}> |
| 96 | [{i + 1}] |
| 97 | </a> |
| 98 | </React.Fragment> |
| 99 | ))} |
| 100 | </span> |
| 101 | </p> |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | function AnswerCard({ answer, color }: { answer: Answer; color: string }) { |
| 106 | const { era, conversion } = answer; |
| 107 | const who = era.usedBy ?? 'a local'; |
| 108 | |
| 109 | return ( |
| 110 | <li |
| 111 | style={{ |
| 112 | ...cardStyle, |
| 113 | listStyle: 'none', |
| 114 | position: 'relative', |
| 115 | overflow: 'hidden', |
| 116 | border: `3px solid ${color}`, |
| 117 | }} |
| 118 | > |
| 119 | {/* A faint wash of the calendar's color along the top edge, fading |
| 120 | straight down to transparent — a quiet hint of which calendar this |
| 121 | card belongs to, echoing the map/timeline legend without tinting |
| 122 | the body text. Linear (not radial) so it fades evenly across the |
| 123 | full width instead of leaving a curved edge partway down. */} |
| 124 | <div |
| 125 | aria-hidden |
| 126 | style={{ |
| 127 | position: 'absolute', |
| 128 | top: 0, |
| 129 | left: 0, |
| 130 | right: 0, |
| 131 | height: 90, |
| 132 | background: `linear-gradient(to bottom, ${color} 0%, transparent 100%)`, |
| 133 | opacity: 0.35, |
| 134 | pointerEvents: 'none', |
| 135 | }} |
| 136 | /> |
| 137 | <div style={{ position: 'relative', fontSize: '0.85em', color: '#555', marginBottom: 4 }}> |
| 138 | Ask <strong>{who}</strong>: |
| 139 | </div> |
| 140 | |
| 141 | {conversion.convertible ? ( |
| 142 | <> |
| 143 | <div style={{ fontSize: '1.6em', fontWeight: 700 }}> |
| 144 | {conversion.localDateLabel ?? conversion.localYearLabel} |
| 145 | </div> |
| 146 | <div style={{ color: '#555', marginTop: 2 }}> |
| 147 | {conversion.system} · {era.name} |
| 148 | </div> |
| 149 | </> |
| 150 | ) : ( |
| 151 | <> |
| 152 | {conversion.localDateLabel ? ( |
| 153 | <div style={{ fontSize: '1.25em', fontWeight: 700 }}>{conversion.localDateLabel}</div> |
| 154 | ) : ( |
| 155 | <div style={{ fontSize: '1.1em', fontWeight: 600 }}> |
| 156 | They don’t count years that way. |
| 157 | </div> |
| 158 | )} |
| 159 | <div style={{ color: '#555', marginTop: 4 }}> |
| 160 | {conversion.system} · {era.name} |
| 161 | </div> |
| 162 | {conversion.note && ( |
| 163 | <p style={{ margin: '8px 0 0', fontSize: '0.9em', color: '#666' }}>{conversion.note}</p> |
| 164 | )} |
| 165 | <div style={{ marginTop: 4, fontSize: '0.85em', color: '#888' }}> |
| 166 | Gregorian reference: {conversion.localYearLabel} |
| 167 | </div> |
| 168 | </> |
| 169 | )} |
| 170 | |
| 171 | {/* Call out the era's span (the changeover dates) — but not for the |
| 172 | synthetic global Gregorian default (no sourceFile), whose 1927 |
| 173 | start is a deliberate simplification, not a local switchover. */} |
| 174 | {answer.sourceFile && eraSpanText(era) && ( |
| 175 | <div style={{ marginTop: 6, fontSize: '0.85em', color: '#555' }}> |
| 176 | 📅 {eraSpanText(era)} |
| 177 | </div> |
| 178 | )} |
| 179 | |
| 180 | <Reckoning reckoning={conversion.reckoning} /> |
| 181 | |
| 182 | {era.description && ( |
| 183 | <p style={{ margin: '8px 0 0', fontSize: '0.9em', color: '#444' }}>{era.description}</p> |
| 184 | )} |
| 185 | <Sources sources={era.sources} /> |
| 186 | {answer.sourceFile && ( |
| 187 | <p style={{ margin: '6px 0 0', fontSize: '0.8em' }}> |
| 188 | <a href={repoFileUrl(answer.sourceFile)} target="_blank" rel="noopener noreferrer"> |
| 189 | View calendar data ({answer.region.name}) |
| 190 | </a> |
| 191 | </p> |
| 192 | )} |
| 193 | </li> |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | const ResultPanel: React.FC<ResultPanelProps> = ({ picked, date, resolution, colorFor }) => { |
| 198 | if (!picked || !resolution) { |
| 199 | return ( |
| 200 | <p style={{ color: '#666', marginTop: 16 }}> |
| 201 | Pick a spot on the map to hear what the locals would say. |
| 202 | </p> |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | if (resolution.missReason === 'no-region') { |
| 207 | return ( |
| 208 | <div style={{ ...cardStyle, marginTop: 16, color: '#555' }}> |
| 209 | <strong>No calendar data for this spot.</strong> |
| 210 | <p style={{ margin: '8px 0 0', fontSize: '0.9em' }}> |
| 211 | No region in the dataset covers this exact point. The <strong>shaded areas</strong>{' '} |
| 212 | on the map have calendar data for this date — pin one of those, or{' '} |
| 213 | <a href="https://code.fuzzyworld.net/contribute/" target="_blank" rel="noopener noreferrer">contribute a calendar</a>. |
| 214 | </p> |
| 215 | </div> |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | if (resolution.missReason === 'no-era') { |
| 220 | return ( |
| 221 | <div style={{ ...cardStyle, marginTop: 16, color: '#555' }}> |
| 222 | <strong>No era covers {yearText(date)} here.</strong> |
| 223 | <p style={{ margin: '8px 0 0', fontSize: '0.9em' }}> |
| 224 | There’s calendar data for this area, but none spanning that date yet. Try another year. |
| 225 | </p> |
| 226 | </div> |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | return ( |
| 231 | <div style={{ marginTop: 16 }}> |
| 232 | <p style={{ color: '#555', marginTop: 0 }}> |
| 233 | On <strong>{fullDateText(date)}</strong>, here’s what you’d hear if you asked |
| 234 | “When am I?”: |
| 235 | </p> |
| 236 | <ul style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 0, margin: 0 }}> |
| 237 | {resolution.answers.map((answer, i) => ( |
| 238 | <AnswerCard |
| 239 | key={`${answer.region.name}-${answer.era.name}-${i}`} |
| 240 | answer={answer} |
| 241 | color={colorFor(answer.era.calendar)} |
| 242 | /> |
| 243 | ))} |
| 244 | </ul> |
| 245 | </div> |
| 246 | ); |
| 247 | }; |
| 248 | |
| 249 | export { ResultPanel }; |