90 minutes at an observatory, 2 hours at a ski resort. Redoing stay times in trip plans by place type
I switched travel times to real road times, but stay time was still 90 minutes for any attraction. I reset it per place using tourism category codes and name rules, then recalculated 27 real itineraries with the old and new rules.
#Node.js #Algorithms #Maps #Backend
In the last post I switched travel times in trip plans from straight-line estimates to real road times. At the end I wrote that "if you calculate everything with one average speed, it all comes out wrong", and when I looked at the itineraries again, the other half of the day had the exact same problem. How long you stay at each place. In this post I'll go over how stay times were being set, what I thought about while splitting them up by place type, and what came out when I ran the old and new rules on 27 itineraries that were actually created. Any attraction was 90 minutes, no matter what Above is the first day of a Sokcho and Yangyang itinerary that was actually created in September. It stays at Hajodae Observatory from 13:40 to 15:10, and at Hyuhyuam Temple from 15:25 to 16:55. 90 minutes at an observatory is way more than enough to take in the view and take pictures. The reason was simple. Stay time was decided by just one thing, the content type in the Korea Tourism Organization data. Attraction 90 min Cultural facility 90 min Leisure sports 120 min Lodging 60 min Shopping 60 min Restaurant 60 min So an observatory was an attraction and got 90 minutes, and a national park was also an attraction and got 90 minutes. Ski resorts and golf courses were leisure sports, so 120 minutes. Hardly anyone goes to a ski resort and leaves after 2 hours. It was giving an hour and a half to look at one lighthouse and not even half a day to a ski resort. The itinerary generator has a fixed amount of time per day. At normal tempo it's 9 hours. If a place that only needs a quick look gets a lot of time, other places get dropped from the plan by that much. The itinerary above also came with a warning when it was generated: "Not enough time, so 9 places were left out." Stay time data for each place would be nice, but The best thing would be knowing how long people actually stay at each place. But the Korea Tourism Organization API has no such field. So I had no choice but to estimate it from the categories. Luckily, the tourism data has subcategory codes that are much finer than the content type. Even within attractions, they split into national parks, beaches, observatories, lighthouses, temples and so on. I decide by this first, then by keywords in the place name if there's no subcategory, and if that doesn't match either, it falls back to the old default. // part of the subcategory table in lib/stayTime.js National park, mountain, theme park 180 min Ski/snowboard, golf 240 min Hot spring/spa, rafting, trekking 150 min Beach, museum 90 min Art gallery, exhibition hall 75 min Temple, lake, waterfall, park 60 min Coastal scenery, port/harbor 45 min Observatory/monument, rock formation 40 min Lighthouse, mineral spring, bridge 30 min Fountain, statue 20 min The order of decisions goes like this. 1. If the tourism subcategory code is in the table, use that value 2. Restaurants, cafes and lodging are fixed at 60 min 3. Shopping is 60 min (even with names like market, outlet, department store) 4. Place name keywords (ski resort, water park, hot spring, trail, observatory, lighthouse ...) 5. Old content type default 6. If still nothing, 60 min The last two steps matter. A place with no category that can't be recognized by name either works exactly the same as before. No place gets worse, only the ones that can be recognized get better. Name rules were trickier than I thought Name keywords are needed for places with no subcategory, especially ones that came in through Kakao place search. But if you only check whether the text contains a word, the wrong things match. For example, if you try to catch hot springs with "spa", the clothing store Spao and Spider get caught. So I made it match only when the name ends with spa. When I tried to set train stations, names ending in "station", to 30 minutes, cemeteries (whose Korean name ends in the same character) got caught, so I made it match only when the character before isn't the one for grave. [/스키장|워터파크|골프/, 240], // ski resort | water park | golf [/온천|스파$/, 150], // excludes 'Spao', 'Spider' [/(? !묘)역$/, 30], // excludes cemeteries Restaurants don't go through the name rules at all. A name like Beach Restaurant could get caught by the beach rule and end up with 90 minutes for a meal. For the same reason, I blocked shops with golf or ski in their name from getting 240 minutes. I split the stored value from the value adjusted at planning time Stay time at the same place changes depending on who you go with. It takes longer with kids, and you get through it faster alone. If I stored that on the place, each place would have to hold a separate value for every kind of trip. So I split it into two layers. The place only stores the base value, and when building the itinerary, it adjusts on the spot based on companions and tempo. The adjusted value isn't stored. function stayMinutesFor(place, ctx) { if (place.category === 'stay') return 30; // lodging only gets 30 min for check-in in the plan let m = Number(place.stay_minutes) || baseStayMinutes(place); const isVisit = place.category === 'attraction' || place.category === 'culture'; if (isVisit ctx.companion === 'family') m += 15; if (isVisit ctx.companion === 'solo') m -= 15; if (place.category === 'food' ctx.startMinute = 17 * 60) m = Math.max(m, 75); if (place.category !== 'food' place.category !== 'cafe') { if (ctx.pace === 'relaxed') m *= 1.15; else if (ctx.pace === 'packed') m *= 0.85; } return round5(Math.min(300, Math.max(20, m))); } I handled dinner separately because that's how real trips go. Lunch takes an hour, but dinner usually takes longer. Meals starting after 17:00 get at least 75 minutes. Then the result is clamped between 20 and 300 minutes and rounded to 5-minute steps. How I applied it to existing places The place DB already had over 5,000 places. Recalculating all of them with the new rules would be simple, but there were values I had fixed by hand in the meantime. Things like Namiseom Island at 180 minutes, Inje Birch Forest at 150 minutes and Daegwallyeong Sheep Farm at 90 minutes. So when recalculating, I only changed places still at the old content type default . If a value was different from the default, I assumed someone had fixed it by hand and left it alone. const typeDefault = BY_TYPE[r.content_type_name] ?? 60; if (Number(r.stay_minutes) !== typeDefault) { kept++; continue; } As a result, 1,059 places changed and the 24 I'd fixed by hand stayed as they were. Among the changes, attractions dropping from 90 to 60 minutes were the most at 344, attractions going up to 120 minutes were 137, leisure sports going up to 150 minutes were 66, and 28 became 240 minutes. Protecting the hand-fixed values turned out to matter more than I expected. When I checked, some places had the wrong category in the source data itself. Inje Birch Forest has a mineral spring code, so by the rules it would get 30 minutes, and Daegwallyeong Sheep Farm has a cafe code. Rules can't catch things like that. Comparing with 27 real itineraries To see what difference the new rules actually make, I recalculated 27 real itineraries stored in the DB (54 days, 305 items) with the old and new rules. I kept the place order and the real road travel times as stored, and only changed the stay times. First I checked that the calculation was right. For all 27 itineraries, recalculating with the old rules matched the stored timetables exactly, down to the minute. That means the calculator behaves exactly like the real generator, so the results with only the rules swapped can be trusted too. Old rules New rules Items with changed stay time - 53 (of 305) Avg stay time per day 395.9 min 381.5 min Avg end time of last place 17:27 17:13 Days that got shorter - 19 Days unchanged - 27 Days that got longer - 8 Days with 60+ min freed up - 13 On average, days got about 15 minutes shorter. That doesn't look like much, but it's because only some places changed. If you look only at the days that changed, the difference is pretty big. The Sokcho itinerary above changes like this. Old rules New rules Hajodae Observatory 13:40 - 15:10 (90 min) 13:40 - 14:20 (40 min) Hyuhyuam Temple 15:25 - 16:55 (90 min) 15:40 - 16:40 (60 min) In the Yeongwol and Taebaek itinerary, the Korean Peninsula-shaped Terrain went from 90 to 45 minutes and the Raft Village from 90 to 60 minutes, so the first day ends at 17:15 instead of 18:30, and the second day at 17:35 instead of 18:55. The warning "Day 2 is packed" that used to be attached disappears too. On the other hand, some got longer. Walking courses with a trekking code, like some sections of the Baugil Trail and the DMZ Peace Trail, went from 120 to 150 minutes, and a ski resort in Jeongseon went from 120 to 240 minutes. That Jeongseon itinerary was at relaxed tempo, so another 15% got added and the day stretched to 20:40. This isn't wrong, it's how it should have been all along. When an itinerary is actually created fresh, the generator cuts the number of places that day or moves them to another day. Where does the saved time go The gap filling I built in the last post gets used again here. Once short-visit places like observatories and harbors get accurate, free time opens up in the afternoon, and that slot gets filled with nearby place recommendations. There were 13 days with 60+ minutes freed up, so that's room for one more place in the itinerary on each of those days. Candidates for filling get their time set by the same rules. Restaurant candidates going into dinner time are calculated at 75 minutes. What still doesn't line up Two things are left. One comes from the Korea Tourism Organization switching to a new category system. 2,453 of 4,646 places have codes from the new system, but my table only knows the old codes. Right now these places rely only on name rules, so I need to fill the table with the new codes too. The other is on the screen side. The "stay time 5 h 50 min" in the screenshot above is just the stored base values added up. But the timetable is drawn with values that include companion, tempo and dinner adjustments. So there are days where the summary card and the timetable don't add up the same. The part that recalculates on screen when a user reorders places also still uses base values and straight-line estimates. I need to change it to take the values calculated on the server as is. Final thoughts When I switched travel times to real roads, I thought that was everything that decided how accurate an itinerary was. But just looking at the Sokcho itinerary above, travel is 1 hour 8 minutes, and most of the rest of the day is stay time. The part I fixed so precisely was the smaller part. If you have a place where one value covers a lot of cases, I'd recommend first measuring how big a share of the whole that value actually takes up. If a value with a big share is set roughly, no matter how precisely you fix everything else, the result won't change much.