mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
41851429c9
The first round could not photograph a few screens because the service behind them was not there: Dawarich, AirTrail, a document store, the booking extractor. They are there now, so the guides are too. - map-dawarich-trail, dawarich-countries and dawarich-wishes: the run uploads a recording of the seeded trip, and of the year before it, into a Dawarich of its own (dawarich-track.ts) and reads it back. - airtrail-import: four flights are put into an AirTrail account, two of them connecting in Tokyo, so the picker offers the layover. - import-booking-file and import-transport-file: the confirmations carry their facts as schema.org data the way portals send them, the extractor reads them, and the guides go on to the filled-in form and the card. - files-sync and document-providers: a Nextcloud folder with two documents in it, bound to the trip, and the switches in the admin panel. - read-place: three seeded places carry their OpenStreetMap id, so the card shows the week's hours, the phone number and the Open or Closed ring. The rating stays Google's, and the texts say so now. - map-compass on the MapLibre renderer, which the guide switches to and back; files-preview opens a PDF, which needs the full browser rather than the headless shell (channel: chromium on the help-media project). Each of those takes an address and a key from e2e/help/media.env, which is not committed; media.env.example and the README say what each one needs and why the address has to be the machine's own on the network. The seed's dates are offsets from the day the pictures are taken (e2e/dates.ts): the trip ends today, so the boarding pass, What's Next and the forecast have something to show whenever the run happens, and nobody bumps a date in three files every few weeks. run.mjs fixes the day once per run so the projects and the workers agree across midnight.
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
/**
|
|
* A synthetic Dawarich recording for the help-center pictures.
|
|
*
|
|
* TREK only ever reads from Dawarich, so the media run has to put a recording
|
|
* there itself. This module makes one: a phone track that follows the seeded
|
|
* demo trip through Tokyo and Kyoto, day by day, plus five short trips in the
|
|
* year before it so the Atlas has countries to offer. Everything is relative to
|
|
* the day the pictures are taken, so it stays inside the trip's dates whenever
|
|
* the run happens.
|
|
*
|
|
* The points go through Dawarich's own batch endpoint as GeoJSON features;
|
|
* see `ensureDawarichFixtures` in fixtures.ts for the upload.
|
|
*/
|
|
|
|
export interface TrackFeature {
|
|
type: 'Feature'
|
|
geometry: { type: 'Point'; coordinates: [number, number] }
|
|
properties: {
|
|
timestamp: string
|
|
horizontal_accuracy: number
|
|
altitude: number
|
|
speed: number
|
|
device_id: string
|
|
}
|
|
}
|
|
|
|
/** One place on a day: when the phone gets there (local time) and how long it stays. */
|
|
interface Waypoint {
|
|
t: string
|
|
lat: number
|
|
lng: number
|
|
stay?: number
|
|
}
|
|
|
|
interface DayPlan {
|
|
offset: number
|
|
utcOffset: number
|
|
points: Waypoint[]
|
|
}
|
|
|
|
export const DAWARICH_DEVICE = 'trek-help-fixture'
|
|
|
|
const JST = 9
|
|
|
|
// Every place the trail visits. The trip's own places come first and match the
|
|
// seed's coordinates, so the dashed line ends exactly on the markers.
|
|
const P = {
|
|
sensoji: [35.7148, 139.7967],
|
|
nakamise: [35.7118, 139.7963],
|
|
asakusaStation: [35.7109, 139.7975],
|
|
toyosuStation: [35.6551, 139.7961],
|
|
teamlab: [35.6486, 139.79],
|
|
shinjukuStation: [35.6896, 139.7006],
|
|
gracery: [35.6946, 139.7012],
|
|
shibuya: [35.6595, 139.7005],
|
|
harajuku: [35.6702, 139.7027],
|
|
meiji: [35.6764, 139.6993],
|
|
ueno: [35.7146, 139.7714],
|
|
akihabara: [35.6984, 139.7731],
|
|
ginza: [35.6717, 139.765],
|
|
tsukiji: [35.6654, 139.7707],
|
|
tokyoTower: [35.6586, 139.7454],
|
|
roppongi: [35.6605, 139.7292],
|
|
tokyoStation: [35.6812, 139.7671],
|
|
shinYokohama: [35.5075, 139.6174],
|
|
nagoya: [35.1709, 136.8815],
|
|
kyotoStation: [34.9858, 135.7588],
|
|
kanra: [34.9942, 135.7592],
|
|
fushimi: [34.9671, 135.7727],
|
|
arashiyama: [35.017, 135.6716],
|
|
togetsukyo: [35.013, 135.6776],
|
|
nishiki: [35.005, 135.7649],
|
|
kiyomizu: [34.9949, 135.785],
|
|
gion: [35.0037, 135.7752],
|
|
yasaka: [35.0037, 135.7785],
|
|
todaiji: [34.689, 135.8398],
|
|
naraPark: [34.6851, 135.843],
|
|
ginkakuji: [35.027, 135.7982],
|
|
philosophersPath: [35.0227, 135.7948],
|
|
nanzenji: [35.0114, 135.7939],
|
|
heian: [35.0163, 135.7824],
|
|
} as const
|
|
|
|
type Place = readonly [number, number]
|
|
|
|
const at = (t: string, [lat, lng]: Place, stay?: number): Waypoint => ({ t, lat, lng, stay })
|
|
|
|
/** The trip itself: the last day is the picture day (offset 0), the first is nine days before. */
|
|
function japanDays(): DayPlan[] {
|
|
const day = (offset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset: JST, points })
|
|
return [
|
|
day(-9, [
|
|
at('08:40', P.gracery, 10),
|
|
at('09:30', P.sensoji, 90),
|
|
at('11:05', P.nakamise, 40),
|
|
at('11:50', P.asakusaStation, 5),
|
|
at('12:40', P.ginza, 70),
|
|
at('13:50', P.toyosuStation, 5),
|
|
at('14:00', P.teamlab, 120),
|
|
at('16:10', P.toyosuStation, 5),
|
|
at('17:00', P.shinjukuStation, 10),
|
|
at('17:15', P.gracery, 60),
|
|
at('19:00', P.shinjukuStation, 90),
|
|
at('21:00', P.gracery, 30),
|
|
]),
|
|
day(-8, [
|
|
at('09:00', P.gracery, 10),
|
|
at('09:40', P.shibuya, 45),
|
|
at('10:40', P.harajuku, 30),
|
|
at('11:20', P.meiji, 75),
|
|
at('13:00', P.harajuku, 60),
|
|
at('14:30', P.shibuya, 90),
|
|
at('16:40', P.shinjukuStation, 10),
|
|
at('17:00', P.gracery, 240),
|
|
]),
|
|
day(-7, [
|
|
at('09:10', P.gracery, 10),
|
|
at('10:00', P.ueno, 120),
|
|
at('12:30', P.akihabara, 100),
|
|
at('14:40', P.ginza, 120),
|
|
at('17:20', P.shinjukuStation, 10),
|
|
at('17:40', P.gracery, 200),
|
|
]),
|
|
day(-6, [
|
|
at('08:30', P.gracery, 10),
|
|
at('09:20', P.tsukiji, 90),
|
|
at('11:30', P.tokyoTower, 80),
|
|
at('13:30', P.roppongi, 150),
|
|
at('16:40', P.shinjukuStation, 10),
|
|
at('17:00', P.gracery, 240),
|
|
]),
|
|
day(-5, [
|
|
at('07:40', P.gracery, 10),
|
|
at('08:15', P.tokyoStation, 15),
|
|
at('08:48', P.shinYokohama, 1),
|
|
at('10:05', P.nagoya, 1),
|
|
at('10:45', P.kyotoStation, 20),
|
|
at('11:20', P.kanra, 120),
|
|
at('14:00', P.gion, 90),
|
|
at('16:20', P.kanra, 30),
|
|
at('17:00', P.fushimi, 150),
|
|
at('20:00', P.kanra, 60),
|
|
]),
|
|
day(-4, [
|
|
at('08:30', P.kanra, 10),
|
|
at('09:30', P.togetsukyo, 30),
|
|
at('10:10', P.arashiyama, 60),
|
|
at('11:20', P.togetsukyo, 40),
|
|
at('12:30', P.nishiki, 90),
|
|
at('14:20', P.gion, 60),
|
|
at('15:40', P.kanra, 240),
|
|
]),
|
|
day(-3, [
|
|
at('09:00', P.kanra, 10),
|
|
at('09:50', P.kiyomizu, 100),
|
|
at('11:50', P.yasaka, 40),
|
|
at('12:40', P.gion, 120),
|
|
at('15:00', P.heian, 60),
|
|
at('16:30', P.kanra, 240),
|
|
]),
|
|
day(-2, [
|
|
at('08:20', P.kanra, 10),
|
|
at('08:45', P.kyotoStation, 15),
|
|
at('09:45', P.naraPark, 60),
|
|
at('11:00', P.todaiji, 90),
|
|
at('12:40', P.naraPark, 120),
|
|
at('15:30', P.kyotoStation, 10),
|
|
at('16:00', P.kanra, 200),
|
|
]),
|
|
day(-1, [
|
|
at('09:00', P.kanra, 10),
|
|
at('09:50', P.nanzenji, 60),
|
|
at('11:00', P.philosophersPath, 45),
|
|
at('11:50', P.ginkakuji, 60),
|
|
at('13:20', P.nishiki, 90),
|
|
at('15:20', P.kanra, 240),
|
|
]),
|
|
day(0, [
|
|
at('08:30', P.kanra, 30),
|
|
at('09:30', P.gion, 60),
|
|
at('11:00', P.nishiki, 60),
|
|
at('12:30', P.kanra, 60),
|
|
at('14:00', P.kyotoStation, 120),
|
|
]),
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Five short trips in the year before the pictures, in countries the Atlas does
|
|
* not have yet: Portugal, Jordan, Czechia, South Korea and Taiwan. Jordan
|
|
* dwells at Petra long enough for the wishlist rule (250 m, 20 minutes).
|
|
*/
|
|
function worldDays(): DayPlan[] {
|
|
const day = (offset: number, utcOffset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset, points })
|
|
return [
|
|
// Portugal, spring
|
|
day(-158, 1, [
|
|
at('09:30', [38.7077, -9.1366], 60),
|
|
at('11:00', [38.7114, -9.13], 120),
|
|
at('14:00', [38.6979, -9.2065], 90),
|
|
at('15:50', [38.6916, -9.216], 60),
|
|
at('18:00', [38.7077, -9.1366], 120),
|
|
]),
|
|
day(-157, 1, [
|
|
at('10:00', [41.1407, -8.611], 90),
|
|
at('12:00', [41.147, -8.6149], 60),
|
|
at('13:30', [41.1403, -8.6094], 120),
|
|
at('16:00', [41.1496, -8.6109], 120),
|
|
]),
|
|
// Jordan, late winter
|
|
day(-205, 3, [
|
|
at('09:00', [31.9544, 35.9344], 90),
|
|
at('11:00', [31.9539, 35.9106], 120),
|
|
at('15:00', [31.717, 35.587], 150),
|
|
]),
|
|
day(-204, 3, [
|
|
at('08:30', [30.3285, 35.4444], 90),
|
|
at('10:20', [30.3222, 35.4517], 120),
|
|
at('13:00', [30.3285, 35.4444], 60),
|
|
at('15:30', [29.576, 35.42], 180),
|
|
]),
|
|
// Czechia, early summer
|
|
day(-100, 2, [
|
|
at('09:30', [50.0875, 14.4213], 90),
|
|
at('11:15', [50.0865, 14.4114], 45),
|
|
at('12:30', [50.0911, 14.4016], 150),
|
|
at('16:00', [50.0645, 14.4183], 90),
|
|
]),
|
|
day(-99, 2, [
|
|
at('10:00', [50.0793, 14.4275], 120),
|
|
at('13:00', [50.0865, 14.4114], 60),
|
|
at('15:00', [50.0875, 14.4213], 120),
|
|
]),
|
|
// South Korea and Taiwan, one journey last autumn
|
|
day(-300, 9, [
|
|
at('09:30', [37.5796, 126.977], 120),
|
|
at('12:00', [37.5826, 126.9831], 90),
|
|
at('14:30', [37.5636, 126.9856], 120),
|
|
at('17:30', [37.5512, 126.9882], 90),
|
|
]),
|
|
day(-299, 9, [
|
|
at('10:00', [37.5563, 126.9236], 150),
|
|
at('14:00', [37.5796, 126.977], 90),
|
|
]),
|
|
day(-297, 8, [
|
|
at('10:00', [25.0372, 121.4999], 60),
|
|
at('11:30', [25.0421, 121.5074], 120),
|
|
at('15:00', [25.0339, 121.5645], 120),
|
|
at('18:30', [25.088, 121.5245], 120),
|
|
]),
|
|
day(-296, 8, [
|
|
at('10:30', [25.1096, 121.8446], 240),
|
|
at('16:00', [25.0421, 121.5074], 120),
|
|
]),
|
|
]
|
|
}
|
|
|
|
/** Deterministic jitter, so two runs produce the same recording. */
|
|
function noise(seed: number): number {
|
|
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
|
|
return x - Math.floor(x)
|
|
}
|
|
|
|
function isoAt(dayIso: string, hhmm: string, utcOffset: number, plusSeconds = 0): string {
|
|
const [h, m] = hhmm.split(':').map(Number)
|
|
const local = Date.UTC(+dayIso.slice(0, 4), +dayIso.slice(5, 7) - 1, +dayIso.slice(8, 10), h, m)
|
|
return new Date(local - utcOffset * 3600_000 + plusSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
}
|
|
|
|
function minutes(hhmm: string): number {
|
|
const [h, m] = hhmm.split(':').map(Number)
|
|
return h * 60 + m
|
|
}
|
|
|
|
function shiftDay(baseDay: string, offset: number): string {
|
|
const d = new Date(`${baseDay}T00:00:00Z`)
|
|
d.setUTCDate(d.getUTCDate() + offset)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
function feature(lng: number, lat: number, timestamp: string, speed: number, seed: number): TrackFeature {
|
|
return {
|
|
type: 'Feature',
|
|
geometry: { type: 'Point', coordinates: [+lng.toFixed(6), +lat.toFixed(6)] },
|
|
properties: {
|
|
timestamp,
|
|
horizontal_accuracy: 4 + Math.round(noise(seed) * 8),
|
|
altitude: 20 + Math.round(noise(seed + 1) * 30),
|
|
speed: +speed.toFixed(2),
|
|
device_id: DAWARICH_DEVICE,
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Turns one day's waypoints into a track: a point a minute while moving, one every five while staying. */
|
|
function dayFeatures(dayIso: string, plan: DayPlan, seedBase: number): TrackFeature[] {
|
|
const out: TrackFeature[] = []
|
|
const pts = plan.points
|
|
let seed = seedBase
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const here = pts[i]
|
|
const stay = here.stay ?? 0
|
|
const arrive = minutes(here.t)
|
|
for (let k = 0; k <= stay; k += 5) {
|
|
seed++
|
|
const wobble = 0.00012
|
|
out.push(
|
|
feature(
|
|
here.lng + (noise(seed) - 0.5) * wobble,
|
|
here.lat + (noise(seed + 7) - 0.5) * wobble,
|
|
isoAt(dayIso, here.t, plan.utcOffset, k * 60),
|
|
0,
|
|
seed,
|
|
),
|
|
)
|
|
}
|
|
const next = pts[i + 1]
|
|
if (!next) break
|
|
const leaveAt = arrive + stay
|
|
const travel = minutes(next.t) - leaveAt
|
|
if (travel <= 0) continue
|
|
// Moving: straight line with a little sway, a point a minute. Long hops
|
|
// (the Shinkansen) get thinned out so a day never exceeds Dawarich's page.
|
|
const step = travel > 60 ? 2 : 1
|
|
const km = Math.hypot((next.lat - here.lat) * 111, (next.lng - here.lng) * 91)
|
|
const speed = (km * 1000) / (travel * 60)
|
|
for (let m = step; m < travel; m += step) {
|
|
seed++
|
|
const f = m / travel
|
|
const sway = Math.sin(f * Math.PI) * 0.0008 * (noise(seed) - 0.5)
|
|
out.push(
|
|
feature(
|
|
here.lng + (next.lng - here.lng) * f + sway,
|
|
here.lat + (next.lat - here.lat) * f + sway,
|
|
isoAt(dayIso, here.t, plan.utcOffset, (stay + m) * 60),
|
|
speed,
|
|
seed,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** The whole recording for a picture day: the trip's ten days plus the year before. */
|
|
export function dawarichRecording(baseDay: string): { trip: TrackFeature[]; world: TrackFeature[] } {
|
|
const build = (plans: DayPlan[], seedBase: number) =>
|
|
plans.flatMap((plan, i) => dayFeatures(shiftDay(baseDay, plan.offset), plan, seedBase + i * 10_000))
|
|
return { trip: build(japanDays(), 1), world: build(worldDays(), 500_000) }
|
|
}
|
|
|
|
/** The UTC window one part of the recording covers, for checking whether it is already there. */
|
|
export function recordingWindow(baseDay: string, part: 'trip' | 'world'): { from: string; to: string } {
|
|
const plans = part === 'trip' ? japanDays() : worldDays()
|
|
const offsets = plans.map(p => p.offset)
|
|
return { from: shiftDay(baseDay, Math.min(...offsets) - 1), to: shiftDay(baseDay, Math.max(...offsets) + 1) }
|
|
}
|