Přehled

Cestou necestou je webová aplikace pro prezentaci cesty na mapě: vybereš zastávky, dopravní prostředek a přehraješ animovanou jízdu po trase. Trasu uložíš pod krátký odkaz a pošleš komukoli.

StackNext.js 15 (App Router, React 19, TypeScript) · Mapbox GL JS 3 · Mapbox Directions & Geocoding v6 · Neon Postgres (@neondatabase/serverless) · Vercel (region fra1)
Produkcehttps://cestou-necestou.online (Vercel alias: cestou-necestou.vercel.app)
AutorAlemon (alemon.cz)
NázevIdiom „cestou necestou" – auto a kolo jedou cestou (po síti), letadlo a vlak necestou (vzduchem, přímo). Dvě slova, bez pomlčky.

Co appka umí

Instalace a nasazení

1
Neon: vytvoř projekt v regionu eu-central-1 (Frankfurt), aby seděl s Vercel fra1. V SQL editoru spusť obsah schema.sql (viz Neon a schéma DB). Skript je idempotentní – lze spouštět opakovaně.
2
Mapbox: Account → Tokens → vytvoř public token (pk.…) s výchozími public scope. Viz Mapbox token.
3
Vercel: importuj repozitář. Framework Preset musí být Next.js – vercel.json ho vynucuje ("framework": "nextjs"), přesto zkontroluj. package.json musí být v kořeni repozitáře (nebo nastav Root Directory).
4
Env proměnné podle tabulky níže, pro Production i Preview. Po každé změně NEXT_PUBLIC_* proměnné je nutný Redeploy – zapékají se do bundlu při buildu.
5
Obrázky vozidel do public/vehicles/ a public/vehicles/menu/ (viz Ikony a obrázky).
6
Deploy. Otevři /, přidej dvě zastávky, ulož – tím ověříš Mapbox i spojení na Neon.

Proměnné prostředí

ProměnnáPovinnáPopis
DATABASE_URLanoPooled connection string z Neonu (…-pooler.eu-central-1.aws.neon.tech/…?sslmode=require). Jen na serveru.
NEXT_PUBLIC_MAPBOX_TOKENanoPublic token pk.…. Je viditelný v prohlížeči – zapékaný při buildu.
NEXT_PUBLIC_SITE_URLneVeřejná URL – nastav https://cestou-necestou.online, ať OG odkazy vedou na hlavní doménu. Bez ní se použije VERCEL_PROJECT_PRODUCTION_URL, lokálně localhost:3000.

Vzor je v .env.example; lokálně vytvoř .env.local (je v .gitignore).

Neon a schéma DB

create table if not exists routes (
  id          bigserial primary key,
  slug        text unique not null,          -- veřejný identifikátor, 7 znaků
  title       text,
  vehicle     text not null check (vehicle in ('car','train','plane','bike')),
  stops       jsonb not null,                -- [{lat, lng, name}, …]
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now(),
  views       integer not null default 0     -- zvyšuje se při každém načtení
);
create index if not exists routes_created_at_idx on routes (created_at desc);
alter table routes add column if not exists edit_key text;   -- tajný klíč vlastníka, 24 znaků
alter table routes add column if not exists updated_at timestamptz not null default now();

V databázi je jen seznam zastávek a prostředek. Geometrie trasy se dopočítává při každém načtení, takže se neukládají velké polylines a změna prostředku je okamžitá.

Mapbox token

Lokální vývoj

cp .env.example .env.local     # doplň hodnoty
npm install
npm run dev                    # http://localhost:3000
npm run build                  # ověření produkčního buildu
npm run db:init                # psql "$DATABASE_URL" -f schema.sql
Mapbox GL potřebuje window, proto se RouteApp načítá přes RouteAppLoader s dynamic(…, { ssr: false }). Do RouteApp.tsx nedávej nic, co by muselo běžet na serveru.

Ovládání

Levý panel

SekceCo dělá
Čím pojedešVýběr prostředku. Změna okamžitě přepočítá trasu (u auta/kola volá Mapbox Directions).
Přidat zastávkuVyhledání místa (Mapbox Geocoding, česky) – Enter nebo tlačítko Hledat, výsledek klikem přidáš na konec. Alternativně klik do mapy.
ZastávkySeznam v pořadí jízdy. Název je editovatelný (uloží se při opuštění pole), šipky přeskládají, ✕ odebere. Svislá červená přerušovaná linka spojuje start a cíl.
TrasaDélka v km a odhad času z průměrné rychlosti prostředku (viz tabulka). Pod tím poznámka, jak byla trasa spočítána.
AnimaceRychlost 1–10, Mapa sleduje jízdu (kamera drží vozidlo ve středu), Opakovat. Vše se projeví i za jízdy.
Uložit a sdílet / Tvoje trasaNázev trasy a akce podle stavu – viz Ukládání, sdílení, editace.

Mapa

Lišta přehrávače (dole)

Klávesové zkratky

KlávesaAkce
MezerníkPřehrát / pauza
+ / −Rychlost animace o 1 výš / níž

Zkratky se ignorují, když je kurzor v textovém poli.

Ukládání, sdílení, editace

Stavy trasy

StavKdeTlačítka v patičce
Nová, neuložená/Uložit odkaz → vytvoří slug, zkopíruje odkaz, přesměruje na /t/<slug>, uloží editační klíč do prohlížeče.
Uložená, jsi vlastník/t/<slug>Uložit změny (aktivní jen když je co uložit) · Kopie (nový slug, původní zůstane) · v sekci Tvoje trasa navíc Smazat trasu a Editační odkaz.
Uložená, nejsi vlastník/t/<slug>Uložit jako kopii – úpravy se uloží pod novým odkazem, původní zůstane.

Odkazy v sekci uložené trasy

Jak funguje vlastnictví

Při vytvoření trasy server vygeneruje 24znakový edit_key (~120 bitů) a vrátí ho jednou v odpovědi. Klient ho uloží do localStorage["cestounecestou:editkey:<slug>"]. Při otevření trasy klient klíč ověří HEAD požadavkem, takže editační UI se zobrazí jen s platným klíčem; neplatný se z úložiště odstraní. Server porovnává klíč přímo ve WHERE klauzuli, bez shody se nic nezmění.

Vymazání dat prohlížeče = ztráta editace všech tras, které nemáš uložené editačním odkazem. Pro důležité trasy si editační odkaz ulož (např. do správce hesel).

Režim prezentace

Zapneš tlačítkem ▶ Prezentace nebo parametrem ?present=1. Panel je skrytý, mapa na celou obrazovku, lišta přehrávače zůstává. Doporučený postup: zapnout Mapa sleduje jízdu, přiblížit tak, aby byla trasa čitelná, Mezerník. Pro plátno bez lišty prohlížeče použij F11.

Dopravní prostředky

Vše je definováno v lib/types.ts v objektu VEHICLES. Přidání nového prostředku = nový záznam + rozšíření typu VehicleId a check constraint ve schema.sql.

PoleVýznam
iconEmoji pro textové kontexty (OG popis).
imgIkona markeru na mapě, pohled shora, míří nahoru. Cesta v /public.
menuImgIkona do výběru a lišty přehrávače, boční pohled.
angleKorekce natočení ve stupních, když obrázek nemíří přesně nahoru (180 = míří dolů). Běžně 0.
sizeVýška markeru v px. Šířka se odvodí z poměru stran obrázku.
trailerVolitelné tažené vozy – viz Vlaková souprava.
labelZobrazovaný název.
kmhPrůměrná rychlost pro odhad času.
routemapbox · brouter · straight · greatcircle – viz Routování.
profileJen pro mapbox: driving nebo cycling.

Výchozí hodnoty

ProstředekRoutingkm/hVelikostPoznámka
🚗 AutoMapbox driving8052 px
🚆 VlakBRouter rail11084 px + 3× vagon 88 pxpo kolejích dle OSM; fallback přímé spojnice
✈️ Letadloortodroma80064 px
🚲 KoloMapbox cycling1856 pxcyklostezky a vedlejší silnice

Ikony a obrázky

public/vehicles/ ├── car.png markery – pohled shora, míří nahoru ├── airplane.png ├── bicycle.png ├── train-loco.png lokomotiva ├── train-car.png vagon (opakuje se) └── menu/ ├── car.png menu a přehrávač – boční pohled, míří doprava ├── airplane.png ├── bicycle.png └── train.png

Prompt pro generování (základ)

Flat cartoon comic-style vehicle icon for a travel map marker, seen from above at a
high angle (bird's-eye view tilted about 20°, roof dominant). The vehicle points
straight UP. Bold thick dark outlines, simple rounded shapes, bright flat colors,
small white glossy highlights, no gradients, no text, no ground, no shadow, no
scenery, no rails, no road. Centered, ~85% of canvas, plain solid #00FF00 background.
Square 1024×1024 PNG, readable at 48 px.

Paleta appky: červená #E63946, tmavě modrá #1B2A41, krémová #EDE6D8.

Vlaková souprava

Vlak je lokomotiva (img) + trailer.count vagonů (trailer.img), každý jako samostatný marker. Každý vůz má vlastní pozici na trase a vlastní natočení, takže se souprava v oblouku ohýbá.

train: {
  img: "/vehicles/train-loco.png", size: 84,
  trailer: { img: "/vehicles/train-car.png", count: 3, size: 88, gap: 0 },
  …
}
PoleVýznam
countPočet vagonů.
sizeDélka vagonu v px (výška obrázku).
gapMezera mezi vozy v px. Může být záporná – vozy se překryjí (užitečné, když má PNG průhledný okraj).

Rozestupy jsou v pixelech a přepočítávají se na metry podle aktuálního zoomu (metersPerPixel v geo.ts), takže souprava drží pohromadě při libovolném přiblížení. Každý vůz je tuhé těleso s oběma konci na trati (viz Animace), proto se souprava v obloucích ohýbá jako skutečný vlak. Při změně zoomu v pauze se přeskládá.

Routování tras

RežimJakKde
mapboxMapbox Directions v5, geometries=geojson&overview=full, všechny zastávky jako waypointy v jednom požadavku (limit 25). Volá se přímo z prohlížeče s public tokenem.RouteApp.tsx
brouterŽeleznice. Klient volá POST /api/rail; server se ptá veřejné instance brouter.de s profilem rail (OSM koleje) a identifikuje se hlavičkou User-Agent cestou-necestou (https://cestou-necestou.online; route presentation app). Odpověď se kešuje 30 dní v Next Data Cache, takže se stejná kombinace zastávek počítá jednou. Timeout 55 s (maxDuration = 60).app/api/rail/route.ts
greatcircleOrtodroma mezi po sobě jdoucími zastávkami, 64 segmentů na úsek (greatCircle).geo.ts
straightPřímé spojnice zastávek. Použije se jako fallback, když router selže.–

Když router selže (síť, limit, BRouter nenajde trať), appka spadne na přímé spojnice a zobrazí toast. Geometrie se do DB neukládá – počítá se při každém načtení (u vlaku z cache).

BRouter rail routuje po všem, co má v OSM koleje (včetně tramvají a vleček), bez jízdních řádů. Je to hobby server bez garance dostupnosti – proto cache a fallback. Kdyby přestal stačit: OpenRailRouting (Geofabrik, self-host v Dockeru, rozlišuje elektrifikaci a rozchod), Transitous (plánování podle jízdních řádů, nutná dohoda s provozovateli). Google Routes transit má 10 000 volání měsíčně zdarma, ale výsledky smí být zobrazeny jen na Google mapě.

Animace

Trasa je pole bodů [lat, lng] s kumulativní vzdáleností (cumulative). Stav animace (pos v km, playing, …) žije v useRef, aby se React nepřekresloval každý frame – mapa se aktualizuje přímo (setData zdroje trail, setLngLat markerů).

ParametrHodnotaKde
Rychlost jízdyspeed × 15 px/s na obrazovce (speed 1–10 → 15–150 px/s); v km/s se přepočítává podle zoomu každý frame – při přiblížení vozidlo zpomalí, při oddálení zrychlítick()
Natočení vozidlatětiva mezi zadním a předním bodem vozu na trase (délka vozu = size px → km podle zoomu); pozice = střed tětivyplaceConsist
Vyhlazení otáčenípodle zoomu: 45 % rozdílu za frame při zoomu ≥ 12, 90 % při zoomu ≤ 6 (lineárně mezi); při skoku posuvníkem bez vyhlazeníCONSIST.SMOOTH_*
Velikost soupravypodle zoomu: 100 % velikosti ze size při zoomu ≥ 12, 55 % při zoomu ≤ 6CONSIST.SCALE_*
Zjednodušení trasyDouglas–Peucker, tolerance 8 m (jen Mapbox/BRouter geometrie)simplify v geo.ts
Max. krok mezi framy0,1 s (po přepnutí záložky se neposkočí)tick()

Vozidla jsou modelována jako tuhá tělesa na trati: přední i zadní bod vozu leží na trase, natočení je tětiva mezi nimi. Drobné klikatiny geometrie (kolejiště, výhybky) tak délka vozu sama vyprůměruje a na malém zoomu, kde jeden vůz měří desítky km, se vůz natáčí podle celkového směru trati. pos je přední bod hlavního vozu; vagony se zavěšují za sebe (přední bod = zadní bod předchozího − gap). Před startem se trasa extrapoluje dozadu, aby souprava vyjížděla zpoza startu. Rychlost, smyčka a sledování se čtou z settings refu každý frame.

Vrstvy na mapě

IdPopis
ghostCelá trasa, tmavě modrá přerušovaná, 40 % krytí.
trail-halo + trailUjetá část – bílý podklad 8 px a červená 4 px linka.
markery zastávekmapboxgl.Marker s tečkou a popiskem; start a cíl vyplněné.
marker vozidla (+ vagony)mapboxgl.Marker s rotationAlignment: "map", rotace = azimut + angle.

Architektura

Struktura souborů

app/ ├── layout.tsx metadata (title template, OG), fonty ├── globals.css veškerý styl + @import mapbox-gl.css ├── page.tsx editor nové trasy ├── t/[slug]/page.tsx uložená trasa (server načte z DB, generateMetadata) ├── t/[slug]/not-found.tsx ├── api/routes/ │ ├── route.ts POST – vytvoření │ └── [slug]/route.ts GET · HEAD · PUT · DELETE └── api/rail/route.ts POST – železniční routing (BRouter proxy + cache) components/ ├── RouteAppLoader.tsx dynamic import bez SSR └── RouteApp.tsx hlavní klientská komponenta lib/ ├── brand.ts název, prefix localStorage, SITE_URL ├── types.ts VEHICLES, typy ├── geo.ts haversine, ortodroma, azimut, positionAt, destination, metersPerPixel ├── db.ts Neon dotazy ├── slug.ts makeSlug (7 znaků), makeEditKey (24 znaků) └── validate.ts validace těla požadavku public/vehicles/ ikony (viz výše) schema.sql · vercel.json · .env.example · README.md

API reference

Všechny odpovědi jsou JSON. Chyby mají tvar { "error": "text" }. Editační operace vyžadují hlavičku x-edit-key.

POST/api/routes

Vytvoří trasu. Tělo:

{
  "vehicle": "car" | "train" | "plane" | "bike",
  "stops": [ { "lat": 50.08584, "lng": 14.42295, "name": "Praha" }, … ],   // 2–25
  "title": "Volitelný název"                                              // max 120 znaků
}

Odpověď 201: { "slug": "k7m2xq4", "editKey": "…24 znaků…" }. editKey se vrací jen tady, nelze ho později získat znovu. Kolize slugu se řeší až 5 pokusy.

GET/api/routes/:slug

Vrátí { slug, title, vehicle, stops }, jinak 404. Zvýší views.

HEAD/api/routes/:slug

Ověření klíče: 204 pokud x-edit-key sedí, jinak 403. Bez těla.

PUT/api/routes/:slug

Přepíše trasu. Tělo stejné jako u POST. 401 bez klíče, 403 při neshodě, 400 při neplatných datech, 200 { slug }.

DELETE/api/routes/:slug

204 po smazání, 401/403 jako u PUT.

POST/api/rail

Železniční geometrie přes BRouter. Tělo { "stops": [ { "lat", "lng" }, … ] } (2–25). Odpověď { "path": [[lat, lng], …], "points": n }. 502 když je BRouter nedostupný, 422 když trať nenajde (text chyby v error). Klient v obou případech použije přímé spojnice.

Validace (lib/validate.ts)

Bezpečnost

SEO

Vše je řešeno přes Next.js Metadata API, bez externích balíčků. Základem je NEXT_PUBLIC_SITE_URL – musí být nastavená při buildu, jinak sitemap i canonical ukazují na Vercel alias.

CoKdePoznámka
/sitemap.xmlapp/sitemap.tsJen / a dokumentace. Uložené trasy záměrně ne – jsou to sdílené odkazy uživatelů.
/robots.txtapp/robots.tsPovoluje vše kromě /api/, odkazuje na sitemapu.
Title, description, keywords, canonical, robotsapp/layout.tsxTitle template %s · Cestou necestou; canonical /.
Open Graph + Twitter kartaapp/layout.tsx, app/opengraph-image.tsxObrázek 1200×630 generovaný na edge (next/og) – brand a stopa.
OG obrázek každé trasyapp/t/[slug]/opengraph-image.tsxNázev trasy (nebo „Start → Cíl"), prostředek a počet zastávek. Náhled v chatech tak ukazuje konkrétní cestu.
Canonical trasy, noindex u 404app/t/[slug]/page.tsxTrasy se indexovat mohou (kdyby ne, přidej robots: { index: false } do generateMetadata).
JSON-LD WebApplicationapp/page.tsxStrukturovaná data pro Google + skrytý <h1> (vizuální hlavičku má aplikace vlastní).
Faviconapp/icon.svgNext ho zaregistruje automaticky.

Google Search Console

  1. search.google.com/search-console → Přidat vlastnictví → typ Doména → cestou-necestou.online.
  2. Ověření DNS TXT záznamem u registrátora domény (Vercel → Domains ho umí přidat, pokud DNS spravuje Vercel).
  3. Sitemaps → přidat https://cestou-necestou.online/sitemap.xml.
  4. Kontrola URL → „Požádat o indexování" pro /.

Kontrola náhledů: Facebook Sharing Debugger, X Card Validator, nebo jednoduše vložit odkaz do chatu (WhatsApp, Messenger, Slack).

Řešení potíží

PříznakPříčinaŘešení
Vercel: No Output Directory named "public"Framework Preset „Other" nebo package.json v podsložceSettings → Build: preset Next.js, správný Root Directory. vercel.json má "framework": "nextjs".
Mapa prázdná, styl a fonty se načítají, žádné .vector.pbfKontejner mapy má 0 px výšku (CSS .mapboxgl-map { position: relative } přebilo pravidlo appky)Opraveno: mapbox CSS je importované jako první v globals.css, selektor .mapwrap .map. Neměň pořadí importů.
Text „Chybí NEXT_PUBLIC_MAPBOX_TOKEN"Proměnná nebyla při builduNastav ji ve Vercelu a Redeploy.
Marker zobrazuje text (např. „Vlak") místo obrázkuPNG na dané cestě neexistuje (404)Ověř URL /vehicles/….png v prohlížeči; zkontroluj názvy a že je nasazený aktuální types.ts.
Vozidlo je tenká čárka / vagony s mezeramiPNG má velké průhledné okrajeOřízni těsně; případně záporný gap.
Vozidlo jede pozpátkuObrázek nemíří nahoruOtoč PNG v editoru nebo nastav angle.
Vozidlo se „cuká"Šum v geometrii trasyŘeší model tuhého tělesa + simplify; ladit lze toleranci 0.008 km a konstanty CONSIST v RouteApp.tsx (vyhlazení a velikost podle zoomu).
Router nedostupný – použity přímé spojniceMapbox Directions nebo BRouter selhal (limit, síť, >25 zastávek, trať nenalezena)Zkontroluj konzoli / odpověď /api/rail; u vlaku zkus zastávku posunout blíž ke kolejím.
Vlak jede po tramvajové trati nebo vlečceProfil rail nerozlišuje typ kolejíPřidej mezibod na správné trati.
Výpočet vlakové trasy trvá dlouhoBRouter počítá dlouhé tratě desítky sekundPoprvé je to normální; podruhé jde z cache. Limit funkce je 60 s.
Uložení vrátí 500Chybí tabulka / sloupceSpusť schema.sql; zkontroluj DATABASE_URL.
Nevidím tlačítka editace u své trasyKlíč není v tomto prohlížečiOtevři editační odkaz, nebo ulož jako kopii.
Po rozbalení zipu je nasazená stará verzePřepsány jen některé souboryPorovnej podle struktury; zejména lib/types.ts, RouteApp.tsx, globals.css.

Limity a licence

Možné rozšíření

↑ Zpět na začátek