1import { files } from '@sublimated/sdk'
2
3// hardcoded airport coordinates (IATA → [lon, lat])
4const AIRPORTS: Record<string, [number, number]> = {
5 ARN: [17.9186, 59.6519],
6 ATH: [23.9445, 37.9364],
7 BCN: [2.0785, 41.2971],
8 BEG: [20.3091, 44.8184],
9 BER: [13.5033, 52.3667],
10 BOG: [-74.1469, 4.7016],
11 BUD: [19.2556, 47.4298],
12 CAG: [9.0543, 39.2515],
13 CDG: [2.5479, 49.0097],
14 CGH: [-46.6564, -23.6261],
15 CPH: [12.6561, 55.6181],
16 CUN: [-86.8770, 21.0365],
17 CWL: [-3.3433, 51.3967],
18 DEN: [-104.6732, 39.8561],
19 DPS: [115.1671, -8.7482],
20 DSS: [-17.4902, 14.7397],
21 DUB: [-6.2701, 53.4264],
22 EDI: [-3.3725, 55.9500],
23 EPA: [-58.6126, -34.6099],
24 EWR: [-74.1687, 40.6895],
25 EZE: [-58.5356, -34.8222],
26 FCO: [12.2389, 41.8003],
27 FMM: [10.2395, 47.9888],
28 FRA: [8.5622, 50.0333],
29 GIG: [-43.2436, -22.8099],
30 GRU: [-46.4730, -23.4356],
31 GUA: [-90.5275, 14.5833],
32 GYE: [-79.8836, -2.1574],
33 HEL: [24.9633, 60.3172],
34 HKG: [113.9145, 22.3089],
35 HNL: [-157.9224, 21.3187],
36 HRK: [36.2860, 49.9248],
37 IAH: [-95.3414, 29.9844],
38 IGR: [-54.4734, -25.7373],
39 IST: [28.8141, 40.9769],
40 JFK: [-73.7781, 40.6413],
41 KBP: [30.8947, 50.3450],
42 KEF: [-22.6056, 63.9850],
43 KRK: [19.7836, 50.0777],
44 LAS: [-115.1523, 36.0840],
45 LAX: [-118.4085, 33.9425],
46 LHR: [-0.4614, 51.4700],
47 LIS: [-9.1354, 38.7742],
48 MAD: [-3.5673, 40.4936],
49 MEX: [-99.0721, 19.4363],
50 MIA: [-80.2906, 25.7959],
51 MID: [-89.6577, 20.9370],
52 MUC: [11.7861, 48.3538],
53 MUN: [11.7861, 48.3538], // alias for MUC
54 NYO: [16.9122, 58.7886],
55 OAK: [-122.2208, 37.7213],
56 OGG: [-156.4305, 20.8986],
57 ORY: [2.3594, 48.7233],
58 OZH: [35.3157, 47.8671],
59 PDX: [-122.5975, 45.5898],
60 PHX: [-112.0116, 33.4373],
61 PRG: [14.2632, 50.1008],
62 PTY: [-79.3835, 9.0714],
63 PVR: [-105.2551, 20.6801],
64 PXM: [-97.0898, 15.8769],
65 SAN: [-117.1896, 32.7336],
66 SAW: [29.3092, 40.8986],
67 SCL: [-70.7858, -33.3930],
68 SDU: [-43.1631, -22.9105],
69 SFO: [-122.3790, 37.6213],
70 SIN: [103.9894, 1.3502],
71 SJU: [-66.0018, 18.4394],
72 SKP: [21.6214, 41.9617],
73 SLC: [-111.9791, 40.7884],
74 STL: [-90.3700, 38.7487],
75 TGD: [19.2519, 42.3593],
76 TIJ: [-116.9700, 32.5411],
77 TIV: [18.7233, 42.4047],
78 TLL: [24.8328, 59.4133],
79 TXL: [13.2877, 52.5597],
80 UIO: [-78.3575, -0.1292],
81 VIE: [16.5697, 48.1103],
82 WAW: [20.9671, 52.1657],
83}
84
85// hardcoded city coordinates ("City, CC" → [lon, lat])
86const CITIES: Record<string, [number, number]> = {
87 'Agoura Hills, US': [-118.7609, 34.1531],
88 'Athens, GR': [23.7275, 37.9838],
89 'Bacalar, MX': [-88.3918, 18.6813],
90 'Badajoz, ES': [-6.9706, 38.8794],
91 'Banos, EC': [-78.4247, -1.3928],
92 'Barcelona, ES': [2.1734, 41.3851],
93 'Becici, ME': [18.8567, 42.2886],
94 'Belgrade, RS': [20.4568, 44.7866],
95 'Berlin, DE': [13.4050, 52.5200],
96 'Bologna, IT': [11.3426, 44.4949],
97 'Brookfield, CT, US': [-73.4068, 41.4645],
98 'Buenos Aires, AR': [-58.3816, -34.6037],
99 'Cadiz, ES': [-6.2926, 36.5271],
100 'Cancun, MX': [-86.8515, 21.1619],
101 'Cardiff, GB': [-3.1791, 51.4816],
102 'Carmel-by-the-Sea, US': [-121.9233, 36.5552],
103 'Castelldefels, ES': [1.9767, 41.2800],
104 'Ciudad de Mexico, MX': [-99.1332, 19.4326],
105 'Denpasar, ID': [115.2126, -8.6500],
106 'Denver, US': [-104.9903, 39.7392],
107 'Dresden, DE': [13.7373, 51.0504],
108 'Drogobych, UA': [23.5064, 49.3497],
109 'Dubrovnik, HR': [18.0944, 42.6507],
110 'Edinburgh, GB': [-3.1883, 55.9533],
111 'El Palomar, AR': [-58.6126, -34.6099],
112 'Ellenville, NY, US': [-74.3968, 41.7170],
113 'Encinitas, US': [-117.2928, 33.0370],
114 'Florence, IT': [11.2558, 43.7696],
115 'Fremont, CA, US': [-121.9886, 37.5485],
116 'Gili Trawangan, ID': [116.0345, -8.3520],
117 'Glasgow, GB': [-4.2518, 55.8642],
118 'Guadalajara, MX': [-103.3496, 20.6597],
119 'Guatemala City, GT': [-90.5069, 14.6349],
120 'Hani i Hotit, AL': [19.5603, 42.5336],
121 'Helsinki, FI': [24.9384, 60.1699],
122 'Ivano-Frankivsk, UA': [24.7111, 48.9226],
123 'Kahului, HI, US': [-156.4729, 20.8893],
124 'Karlovy Vary, CZ': [12.8714, 50.2306],
125 'Kharkiv, UA': [36.2304, 49.9935],
126 'Kihei, HI, US': [-156.4659, 20.7644],
127 'Kornati, HR': [15.3000, 43.8000],
128 'Krakow, PL': [19.9450, 50.0647],
129 'Kramatorsk, UA': [37.5558, 48.7362],
130 'Kyiv, UA': [30.5234, 50.4501],
131 'Larkspur, CA, US': [-122.5352, 37.9341],
132 'Leon, NI': [-86.8780, 12.4346],
133 'Lisbon, PT': [-9.1393, 38.7223],
134 'Los Angeles, US': [-118.2437, 34.0522],
135 'Lviv, UA': [24.0297, 49.8397],
136 'Madrid, ES': [-3.7038, 40.4168],
137 'Mahahual, MX': [-87.7112, 18.7142],
138 'Mammoth, CA, US': [-118.9721, 37.6485],
139 'Managua, NI': [-86.2504, 12.1150],
140 'Manchester, GB': [-2.2426, 53.4808],
141 'Memmingen, DE': [10.1811, 47.9837],
142 'Merida, MX': [-89.5926, 20.9674],
143 'Milan, IT': [9.1900, 45.4642],
144 'Morelia, MX': [-101.1950, 19.7060],
145 'Munich, DE': [11.5820, 48.1351],
146 'Napa, US': [-122.2869, 38.2975],
147 'New York, US': [-74.0060, 40.7128],
148 'Newark, NJ, US': [-74.1724, 40.7357],
149 'Ohrid, MK': [20.8019, 41.1231],
150 'Panama City, PA': [-79.5197, 8.9824],
151 'Paris, FR': [2.3522, 48.8566],
152 'Playa del Carmen, MX': [-87.0739, 20.6296],
153 'Podgorica, ME': [19.2636, 42.4304],
154 'Prague, CZ': [14.4378, 50.0755],
155 'Provo, US': [-111.6585, 40.2338],
156 'Puerto Vallarta, MX': [-105.2253, 20.6534],
157 'Pylypets, UA': [23.3311, 48.6614],
158 'Quito, EC': [-78.4678, -0.1807],
159 'Rio de Janeiro, BR': [-43.1729, -22.9068],
160 'Rome, IT': [12.4964, 41.9028],
161 'Sacramento, US': [-121.4944, 38.5816],
162 'Salt Lake City, US': [-111.8910, 40.7608],
163 'San Clemente, US': [-117.6120, 33.4270],
164 'San Diego, US': [-117.1611, 32.7157],
165 'San Francisco, US': [-122.4194, 37.7749],
166 'San Jose, CR': [-84.0907, 9.9281],
167 'San Luis Obispo, US': [-120.6596, 35.2828],
168 'San Salvador, SV': [-89.1872, 13.6929],
169 'Santa Rosa, US': [-122.7141, 38.4405],
170 'Sao Paulo, BR': [-46.6333, -23.5505],
171 'Sarajevo, BA': [18.4131, 43.8563],
172 'Sayulita, MX': [-105.4413, 20.8692],
173 'Sevilla, ES': [-5.9845, 37.3891],
174 'Shkoder, AL': [19.5126, 42.0693],
175 'Skopje, MK': [21.4254, 41.9973],
176 'Split, HR': [16.4402, 43.5081],
177 'Stockton, US': [-121.2908, 37.9577],
178 'Tallinn, EE': [24.7536, 59.4370],
179 'Tegucigalpa, HN': [-87.2068, 14.0723],
180 'Temecula, US': [-117.1484, 33.4936],
181 'Thira, GR': [25.4615, 36.3932],
182 'Tijuana, MX': [-117.0382, 32.5149],
183 'Tirana, AL': [19.8187, 41.3275],
184 'Tivat, ME': [18.7233, 42.4047],
185 'Tulum, MX': [-87.4654, 20.2115],
186 'Vail, CO, US': [-106.3742, 39.6403],
187 'Vallejo, CA, US': [-122.2566, 38.1041],
188 'Venice, IT': [12.3155, 45.4408],
189 'Vorokhta, UA': [24.5417, 48.2747],
190 'Warsaw, PL': [21.0122, 52.2297],
191 'Washington, US': [-77.0369, 38.9072],
192 'Wroclaw, PL': [17.0385, 51.1079],
193 'Yosemite Village, CA, US': [-119.5936, 37.7490],
194 'Zadar, HR': [15.2314, 44.1194],
195 'Zagreb, HR': [15.9819, 45.8150],
196 'Zaporizhzhia, UA': [35.1396, 47.8388],
197 'Zurich, CH': [8.5417, 47.3769],
198}
199
200function resolveLocation(loc: string): [number, number] | null {
201 const trimmed = loc.trim()
202 if (AIRPORTS[trimmed]) return AIRPORTS[trimmed]
203 if (CITIES[trimmed]) return CITIES[trimmed]
204 return null
205}
206
207interface Segment {
208 from: string
209 to: string
210 date: string
211 mode: string
212 trip: string
213 fromCoord: [number, number]
214 toCoord: [number, number]
215}
216
217function parseTrip(filename: string, content: string): Segment[] {
218 const segments: Segment[] = []
219 const lines = content.split('\n')
220 let inTable = false
221
222 for (const line of lines) {
223 if (line.match(/^\|\s*from\s*\|/i)) {
224 inTable = true
225 continue
226 }
227 if (inTable && line.match(/^\|\s*-/)) continue
228 if (inTable && line.startsWith('|')) {
229 const parts = line.split('|').map(p => p.trim()).filter(Boolean)
230 if (parts.length >= 4) {
231 const from = parts[0]
232 const to = parts[1]
233 const date = parts[2]
234 const mode = parts[3]
235 const fromCoord = resolveLocation(from)
236 const toCoord = resolveLocation(to)
237 if (fromCoord && toCoord) {
238 segments.push({ from, to, date, mode, trip: filename, fromCoord, toCoord })
239 }
240 }
241 } else if (inTable && !line.startsWith('|')) {
242 inTable = false
243 }
244 }
245
246 return segments
247}
248
249function isFlightMode(mode: string): boolean {
250 return mode.toLowerCase().startsWith('flight')
251}
252
253export default async function () {
254 const allFiles = await files.list('.')
255 const mdFiles = allFiles.filter((f: string) => f.endsWith('.md'))
256
257 const allSegments: Segment[] = []
258
259 for (const file of mdFiles) {
260 const content = await files.read(file)
261 if (!content) continue
262 const segments = parseTrip(file, content as string)
263 allSegments.push(...segments)
264 }
265
266 // per-trip geojson files
267 const segmentsByTrip = new Map<string, Segment[]>()
268 for (const seg of allSegments) {
269 const list = segmentsByTrip.get(seg.trip) || []
270 list.push(seg)
271 segmentsByTrip.set(seg.trip, list)
272 }
273
274 for (const [trip, segs] of segmentsByTrip) {
275 const features = []
276 const stops = new Map<string, [number, number]>()
277 for (const seg of segs) {
278 features.push({
279 type: 'Feature',
280 properties: {
281 layer: isFlightMode(seg.mode) ? 'flight' : 'route',
282 from: seg.from, to: seg.to, date: seg.date, mode: seg.mode,
283 },
284 geometry: { type: 'LineString', coordinates: [seg.fromCoord, seg.toCoord] },
285 })
286 stops.set(seg.from, seg.fromCoord)
287 stops.set(seg.to, seg.toCoord)
288 }
289 for (const [name, coord] of stops) {
290 features.push({
291 type: 'Feature',
292 properties: { layer: 'stop', name },
293 geometry: { type: 'Point', coordinates: coord },
294 })
295 }
296 await files.write(`${trip}.geojson`, JSON.stringify({ type: 'FeatureCollection', features }, null, 2))
297 }
298
299 // global flights.geojson — all segments combined
300 const globalFeatures = []
301 const globalStops = new Map<string, [number, number]>()
302 for (const seg of allSegments) {
303 globalFeatures.push({
304 type: 'Feature',
305 properties: {
306 layer: isFlightMode(seg.mode) ? 'flight' : 'route',
307 from: seg.from, to: seg.to, date: seg.date, mode: seg.mode, trip: seg.trip,
308 },
309 geometry: { type: 'LineString', coordinates: [seg.fromCoord, seg.toCoord] },
310 })
311 globalStops.set(seg.from, seg.fromCoord)
312 globalStops.set(seg.to, seg.toCoord)
313 }
314 for (const [name, coord] of globalStops) {
315 globalFeatures.push({
316 type: 'Feature',
317 properties: { layer: 'stop', name },
318 geometry: { type: 'Point', coordinates: coord },
319 })
320 }
321 await files.write('flights.geojson', JSON.stringify({ type: 'FeatureCollection', features: globalFeatures }, null, 2))
322
323 return new Response(JSON.stringify({
324 ok: true,
325 trips: segmentsByTrip.size,
326 segments: allSegments.length,
327 stops: globalStops.size,
328 flights: allSegments.filter(s => isFlightMode(s.mode)).length,
329 }))
330}