236function isFlightMode(mode) {
237 return mode.toLowerCase().startsWith('flight')
238}
239
240// country tag → center coordinates + display name
241const COUNTRY_CENTERS = {
242 'albania': { name: 'Albania', iso: 'AL', center: [20.17, 41.15] },
243 'argentina': { name: 'Argentina', iso: 'AR', center: [-63.62, -38.42] },
244 'austria': { name: 'Austria', iso: 'AT', center: [14.55, 47.52] },
245 'bosnia': { name: 'Bosnia and Herzegovina', iso: 'BA', center: [17.68, 43.92] },
246 'bosnia-and-herzegovina': { name: 'Bosnia and Herzegovina', iso: 'BA', center: [17.68, 43.92] },
247 'brazil': { name: 'Brazil', iso: 'BR', center: [-51.93, -14.24] },
248 'canada': { name: 'Canada', iso: 'CA', center: [-106.35, 56.13] },
249 'chile': { name: 'Chile', iso: 'CL', center: [-71.54, -35.68] },
250 'colombia': { name: 'Colombia', iso: 'CO', center: [-74.30, 4.57] },
251 'costa-rica': { name: 'Costa Rica', iso: 'CR', center: [-83.75, 9.75] },
252 'croatia': { name: 'Croatia', iso: 'HR', center: [15.20, 45.10] },
253 'czechia': { name: 'Czechia', iso: 'CZ', center: [15.47, 49.82] },
254 'denmark': { name: 'Denmark', iso: 'DK', center: [9.50, 56.26] },
255 'ecuador': { name: 'Ecuador', iso: 'EC', center: [-78.18, -1.83] },
256 'el-salvador': { name: 'El Salvador', iso: 'SV', center: [-88.90, 13.79] },
257 'estonia': { name: 'Estonia', iso: 'EE', center: [25.01, 58.60] },
258 'finland': { name: 'Finland', iso: 'FI', center: [25.75, 61.92] },
259 'france': { name: 'France', iso: 'FR', center: [2.21, 46.23] },
260 'germany': { name: 'Germany', iso: 'DE', center: [10.45, 51.17] },
261 'greece': { name: 'Greece', iso: 'GR', center: [21.82, 39.07] },
262 'guatemala': { name: 'Guatemala', iso: 'GT', center: [-90.23, 15.78] },
263 'honduras': { name: 'Honduras', iso: 'HN', center: [-86.24, 15.20] },
264 'hong-kong': { name: 'Hong Kong', iso: 'HK', center: [114.17, 22.32] },
265 'hungary': { name: 'Hungary', iso: 'HU', center: [19.50, 47.16] },
266 'iceland': { name: 'Iceland', iso: 'IS', center: [-19.02, 64.96] },
267 'indonesia': { name: 'Indonesia', iso: 'ID', center: [113.92, -0.79] },
268 'ireland': { name: 'Ireland', iso: 'IE', center: [-8.24, 53.41] },
269 'italy': { name: 'Italy', iso: 'IT', center: [12.57, 41.87] },
270 'mexico': { name: 'Mexico', iso: 'MX', center: [-102.55, 23.63] },
271 'montenegro': { name: 'Montenegro', iso: 'ME', center: [19.37, 42.71] },
272 'nicaragua': { name: 'Nicaragua', iso: 'NI', center: [-85.21, 12.87] },
273 'north-macedonia': { name: 'North Macedonia', iso: 'MK', center: [21.75, 41.51] },
274 'panama': { name: 'Panama', iso: 'PA', center: [-80.78, 8.54] },
275 'poland': { name: 'Poland', iso: 'PL', center: [19.15, 51.92] },
276 'portugal': { name: 'Portugal', iso: 'PT', center: [-8.22, 39.40] },
277 'puerto-rico': { name: 'Puerto Rico', iso: 'PR', center: [-66.59, 18.22] },
278 'senegal': { name: 'Senegal', iso: 'SN', center: [-14.45, 14.50] },
279 'serbia': { name: 'Serbia', iso: 'RS', center: [21.01, 44.02] },
280 'singapore': { name: 'Singapore', iso: 'SG', center: [103.82, 1.35] },
281 'slovenia': { name: 'Slovenia', iso: 'SI', center: [14.99, 46.15] },
282 'spain': { name: 'Spain', iso: 'ES', center: [-3.75, 40.46] },
283 'sweden': { name: 'Sweden', iso: 'SE', center: [18.64, 60.13] },
284 'switzerland': { name: 'Switzerland', iso: 'CH', center: [8.23, 46.82] },
285 'turkey': { name: 'Turkey', iso: 'TR', center: [35.24, 38.96] },
286 'uk': { name: 'United Kingdom', iso: 'GB', center: [-3.44, 55.38] },
287 'ukraine': { name: 'Ukraine', iso: 'UA', center: [31.17, 48.38] },
288 'usa': { name: 'United States', iso: 'US', center: [-95.71, 37.09] },
289}
290
291// city tag → key in CITIES lookup (for tags that don't match directly)
292const CITY_TAG_MAP = {
293 'bogota': 'Buenos Aires, AR', // bogota not in CITIES, skip — handled by auto-match
294 'mexico-city': 'Ciudad de Mexico, MX',
295 'new-york': 'New York, US',
296 'buenos-aires': 'Buenos Aires, AR',
297 'rio-de-janeiro': 'Rio de Janeiro, BR',
298 'sao-paulo': 'Sao Paulo, BR',
299 'salt-lake-city': 'Salt Lake City, US',
300 'san-francisco': 'San Francisco, US',
301 'san-diego': 'San Diego, US',
302 'los-angeles': 'Los Angeles, US',
303 'san-clemente': 'San Clemente, US',
304 'san-luis-obispo': 'San Luis Obispo, US',
305 'puerto-vallarta': 'Puerto Vallarta, MX',
306 'playa-del-carmen': 'Playa del Carmen, MX',
307 'ivano-frankivsk': 'Ivano-Frankivsk, UA',
308 'gili-trawangan': 'Gili Trawangan, ID',
309 'guatemala-city': 'Guatemala City, GT',
310 'panama-city': 'Panama City, PA',
311 'karlovy-vary': 'Karlovy Vary, CZ',
312 'hani-i-hotit': 'Hani i Hotit, AL',
313 'el-palomar': 'El Palomar, AR',
314 'carmel-by-the-sea': 'Carmel-by-the-Sea, US',
315 'agoura-hills': 'Agoura Hills, US',
316 'yosemite-village': 'Yosemite Village, CA, US',
317 'las-vegas': null, // not in CITIES
318 'st-louis': null,
319 'hong-kong': null,
320 'san-jose': 'San Jose, CR',
321 'san-salvador': 'San Salvador, SV',
322 'santa-rosa': 'Santa Rosa, US',
323 'san-juan': null,
324}
325
326// extra city coordinates not in CITIES
327const EXTRA_CITIES = {
328 'bogota': [-74.0721, 4.7110],
329 'brooklyn': [-73.9442, 40.6782],
330 'budapest': [19.0402, 47.4979],
331 'cagliari': [9.1217, 39.2238],
332 'copenhagen': [12.5683, 55.6761],
333 'dakar': [-17.4677, 14.7167],
334 'detroit': [-83.0458, 42.3314],
335 'ensenada': [-116.5964, 31.8667],
336 'faro': [-7.9304, 37.0194],
337 'frankfurt': [8.6821, 50.1109],
338 'gdynia': [18.5305, 54.5189],
339 'geneva': [6.1432, 46.2044],
340 'guayaquil': [-79.8868, -2.1710],
341 'hong-kong': [114.1694, 22.3193],
342 'honolulu': [-157.8583, 21.3069],
343 'houston': [-95.3698, 29.7604],
344 'iguazu': [-54.5786, -25.5972],
345 'istanbul': [28.9784, 41.0082],
346 'kolobrzeg': [15.5761, 54.1758],
347 'koper': [13.7301, 45.5469],
348 'kotor': [18.7712, 42.4247],
349 'las-vegas': [-115.1398, 36.1699],
350 'ljubljana': [14.5058, 46.0569],
351 'london': [-0.1278, 51.5074],
352 'malaga': [-4.4214, 36.7213],
353 'miami': [-80.1918, 25.7617],
354 'phoenix': [-112.0740, 33.4484],
355 'portland': [-122.6750, 45.5152],
356 'poznan': [16.9252, 52.4064],
357 'puerto-escondido': [-97.0722, 15.8720],
358 'reykjavik': [-21.8174, 64.1466],
359 'sacramento': [-121.4944, 38.5816],
360 'san-juan': [-66.1057, 18.4655],
361 'santiago': [-70.6693, -33.4489],
362 'singapore': [103.8198, 1.3521],
363 'st-louis': [-90.1994, 38.6270],
364 'stockholm': [18.0686, 59.3293],
365 'stuttgart': [9.1829, 48.7758],
366 'swinoujscie': [14.2471, 53.9101],
367 'tegucigalpa': [-87.2068, 14.0723],
368 'toronto': [-79.3832, 43.6532],
369 'trieste': [13.7768, 45.6495],
370 'vienna': [16.3738, 48.2082],
371}
372
373function resolveCityCoord(tag) {
374 // check explicit mapping first
375 if (CITY_TAG_MAP[tag] !== undefined) {
376 if (CITY_TAG_MAP[tag] === null) {
377 // mapped but no CITIES entry — check EXTRA_CITIES
378 return EXTRA_CITIES[tag] || null
379 }
380 return CITIES[CITY_TAG_MAP[tag]] || null
381 }
382 // check EXTRA_CITIES
383 if (EXTRA_CITIES[tag]) return EXTRA_CITIES[tag]
384 // auto-match: find CITIES key that starts with the tag name (case-insensitive)
385 var tagLower = tag.replace(/-/g, ' ').toLowerCase()
386 var keys = Object.keys(CITIES)
387 for (var i = 0; i < keys.length; i++) {
388 if (keys[i].toLowerCase().startsWith(tagLower + ',') || keys[i].toLowerCase() === tagLower) {
389 return CITIES[keys[i]]
390 }
391 }
392 return null
393}
394
395function parseFrontmatter(content) {
396 var lines = content.split('\n')
397 var inFrontmatter = false
398 var tags = []
399 var title = ''
400 var date = ''
401 var inTags = false
402
403 for (var i = 0; i < lines.length; i++) {
404 var line = lines[i]
405 if (line.trim() === '---') {
406 if (inFrontmatter) break
407 inFrontmatter = true
408 continue
409 }
410 if (!inFrontmatter) continue
411 var titleMatch = line.match(/^title:\s*(.+)/)
412 if (titleMatch) title = titleMatch[1].trim()
413 var dateMatch = line.match(/^date:\s*(.+)/)
414 if (dateMatch) date = dateMatch[1].trim()
415 if (line.match(/^tags:\s*$/)) { inTags = true; continue }
416 if (inTags && line.match(/^\s+-\s+/)) {
417 tags.push(line.replace(/^\s+-\s+/, '').trim())
418 } else if (inTags && !line.match(/^\s/)) {
419 inTags = false
420 }
421 }
422
423 return { title: title, date: date, tags: tags }
424}
425
426export default async function () {
427 var allFiles = await files.list()
428 var mdFiles = allFiles.filter(function(f) { return f.endsWith('.md') })
429
430 var allSegments = []
431 var allTripMeta = [] // { filename, title, date, tags }
432
433 for (var i = 0; i < mdFiles.length; i++) {
434 var content = await files.read(mdFiles[i])
435 if (!content) continue
436 var segments = parseTrip(mdFiles[i], content)
437 allSegments.push.apply(allSegments, segments)
438 var meta = parseFrontmatter(content)
439 meta.filename = mdFiles[i]
440 allTripMeta.push(meta)
441 }
442
253 // per-trip geojson files
443 // per-trip geojson files (all segments — flights + routes + stops)
444 var segmentsByTrip = {}
445 for (var i = 0; i < allSegments.length; i++) {
446 var seg = allSegments[i]
447 if (!segmentsByTrip[seg.trip]) segmentsByTrip[seg.trip] = []
483 errors.push(geoName + ': ' + e.message)
484 }
485 }
486
297 // global flights.geojson
298 var globalFeatures = []
299 var globalStops = {}
300 for (var i = 0; i < allSegments.length; i++) {
301 var seg = allSegments[i]
302 globalFeatures.push({
487 // --- flights.geojson: flights only (no trains, buses, cars) ---
488 var flightSegments = allSegments.filter(function(s) { return isFlightMode(s.mode) })
489 var flightFeatures = []
490 var flightStops = {}
491 for (var i = 0; i < flightSegments.length; i++) {
492 var seg = flightSegments[i]
493 flightFeatures.push({
494 type: 'Feature',
495 properties: {
305 layer: isFlightMode(seg.mode) ? 'flight' : 'route',
496 layer: 'flight',
497 from: seg.from, to: seg.to, date: seg.date, mode: seg.mode, trip: seg.trip,
498 },
499 geometry: { type: 'LineString', coordinates: [seg.fromCoord, seg.toCoord] },
500 })
310 globalStops[seg.from] = seg.fromCoord
311 globalStops[seg.to] = seg.toCoord
501 flightStops[seg.from] = seg.fromCoord
502 flightStops[seg.to] = seg.toCoord
503 }
313 var globalStopNames = Object.keys(globalStops)
314 for (var i = 0; i < globalStopNames.length; i++) {
315 globalFeatures.push({
504 var flightStopNames = Object.keys(flightStops)
505 for (var i = 0; i < flightStopNames.length; i++) {
506 flightFeatures.push({
507 type: 'Feature',
317 properties: { layer: 'stop', name: globalStopNames[i] },
318 geometry: { type: 'Point', coordinates: globalStops[globalStopNames[i]] },
508 properties: { layer: 'stop', name: flightStopNames[i] },
509 geometry: { type: 'Point', coordinates: flightStops[flightStopNames[i]] },
510 })
511 }
512 try {
322 await files.write('flights.geojson', JSON.stringify({ type: 'FeatureCollection', features: globalFeatures }, null, 2))
513 await files.write('flights.geojson', JSON.stringify({ type: 'FeatureCollection', features: flightFeatures }, null, 2))
514 } catch (e) {
515 errors.push('flights.geojson: ' + e.message)
516 }
517
518 // --- cities.geojson: city points from frontmatter tags ---
519 var cityVisits = {} // tag → { coord, trips: [{ filename, title, date }] }
520 for (var i = 0; i < allTripMeta.length; i++) {
521 var trip = allTripMeta[i]
522 for (var j = 0; j < trip.tags.length; j++) {
523 var tag = trip.tags[j]
524 if (!tag.startsWith('city/')) continue
525 var cityTag = tag.slice(5)
526 var coord = resolveCityCoord(cityTag)
527 if (!coord) continue
528 if (!cityVisits[cityTag]) cityVisits[cityTag] = { coord: coord, trips: [] }
529 cityVisits[cityTag].trips.push({ filename: trip.filename, title: trip.title, date: trip.date })
530 }
531 }
532 var cityFeatures = []
533 var cityTags = Object.keys(cityVisits)
534 for (var i = 0; i < cityTags.length; i++) {
535 var ct = cityTags[i]
536 var cv = cityVisits[ct]
537 var displayName = ct.replace(/-/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase() })
538 cityFeatures.push({
539 type: 'Feature',
540 properties: {
541 layer: 'city',
542 name: displayName,
543 visitCount: cv.trips.length,
544 trips: cv.trips.map(function(t) { return { file: t.filename, title: t.title, date: t.date } }),
545 },
546 geometry: { type: 'Point', coordinates: cv.coord },
547 })
548 }
549 try {
550 await files.write('cities.geojson', JSON.stringify({ type: 'FeatureCollection', features: cityFeatures }, null, 2))
551 } catch (e) {
552 errors.push('cities.geojson: ' + e.message)
553 }
554
555 // --- countries.geojson: country points from frontmatter tags ---
556 var countryVisits = {} // tag → { info, trips: [{ filename, title, date }] }
557 for (var i = 0; i < allTripMeta.length; i++) {
558 var trip = allTripMeta[i]
559 for (var j = 0; j < trip.tags.length; j++) {
560 var tag = trip.tags[j]
561 if (!tag.startsWith('country/')) continue
562 var countryTag = tag.slice(8)
563 var info = COUNTRY_CENTERS[countryTag]
564 if (!info) continue
565 if (!countryVisits[countryTag]) countryVisits[countryTag] = { info: info, trips: [] }
566 countryVisits[countryTag].trips.push({ filename: trip.filename, title: trip.title, date: trip.date })
567 }
568 }
569 var countryFeatures = []
570 var countryTags = Object.keys(countryVisits)
571 for (var i = 0; i < countryTags.length; i++) {
572 var ctag = countryTags[i]
573 var cv = countryVisits[ctag]
574 countryFeatures.push({
575 type: 'Feature',
576 properties: {
577 layer: 'country',
578 name: cv.info.name,
579 iso: cv.info.iso,
580 visitCount: cv.trips.length,
581 trips: cv.trips.map(function(t) { return { file: t.filename, title: t.title, date: t.date } }),
582 },
583 geometry: { type: 'Point', coordinates: cv.info.center },
584 })
585 }
586 try {
587 await files.write('countries.geojson', JSON.stringify({ type: 'FeatureCollection', features: countryFeatures }, null, 2))
588 } catch (e) {
589 errors.push('countries.geojson: ' + e.message)
590 }
591
592 return new Response(JSON.stringify({
593 ok: true,
594 trips: tripNames.length,
595 segments: allSegments.length,
331 stops: globalStopNames.length,
332 flights: allSegments.filter(function(s) { return isFlightMode(s.mode) }).length,
596 flights: flightSegments.length,
597 cities: cityTags.length,
598 countries: countryTags.length,
599 errors: errors,
600 }))
601}