fuzzyworld

when-am-i / files / src / components / ResultPanel.tsx

ResultPanel.tsx

9.9 KB · history · blame

1import React from 'react';
2import type { Answer, Era, HistoricalDate, Resolution } from '../types';
3import { repoFileUrl } from '../utils/report';
4import { parseGregorianDate } from '../utils/julianDay';
5
6interface 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
14function yearText(date: HistoricalDate): string {
15 return date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`;
16}
17
18function 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.
26function 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
35const 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.
42function fullDateText(date: HistoricalDate): string {
43 return `${MONTHS[date.month - 1]} ${date.day}, ${yearText(date)}`;
44}
45
46const 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.
56function 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
66function 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.
85function 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 &hellip; 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
105function 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&rsquo;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
197const 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&rsquo;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&rsquo;s what you&rsquo;d hear if you asked
234 &ldquo;When am I?&rdquo;:
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
249export { ResultPanel };