Travel — Trip Planner
A flagship interactive trip planner for the fictional Adriatic and Alps route, built in vanilla HTML, CSS, and JavaScript. Search a catalogue of sights, nature, food, and stays, then add spots that flow into a day-by-day itinerary you can drag and reorder across days. A live header tracks total days, stops, estimated budget, and cost per day, an SVG route map draws numbered pins in your day order, and a category budget breakdown updates in real time. Trips auto-save to localStorage with save and clear controls.
MCP
Code
:root {
--bg: #fbf7f1;
--surface: #fffdf9;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-d: #156d6d;
--coral: #e8623f;
--coral-d: #c94c2c;
--sand: #e7d8c3;
--sand-soft: #f3ebdd;
--gold: #c98a2b;
--line: rgba(36, 31, 26, 0.12);
--line-2: rgba(36, 31, 26, 0.22);
--r-sm: 10px;
--r-md: 16px;
--r-lg: 24px;
--shadow-1: 0 1px 2px rgba(36, 31, 26, 0.06);
--shadow-2: 0 10px 30px rgba(36, 31, 26, 0.12);
--display: "Fraunces", Georgia, "Times New Roman", serif;
--sans: "Work Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--sans);
color: var(--ink);
background:
radial-gradient(120% 60% at 100% 0%, #fdeede 0%, transparent 55%),
var(--bg);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
h1, h2, h3 { font-family: var(--display); margin: 0; letter-spacing: -0.01em; }
a { color: inherit; }
.skip-link {
position: absolute;
left: -999px;
top: 0;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 0 0 var(--r-sm) 0;
z-index: 50;
}
.skip-link:focus { left: 0; }
:focus-visible {
outline: 2.5px solid var(--teal);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: 18px;
padding: 12px clamp(14px, 4vw, 36px);
background: rgba(251, 247, 241, 0.85);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 9px; }
.brand__mark { display: grid; place-items: center; }
.brand__text { font-family: var(--display); font-weight: 700; font-size: 1.2rem; }
.brand__text span { color: var(--teal); }
.topbar__nav { display: flex; gap: 4px; margin-left: 8px; }
.topbar__nav a {
text-decoration: none;
color: var(--muted);
font-weight: 500;
font-size: 0.92rem;
padding: 7px 12px;
border-radius: 999px;
}
.topbar__nav a:hover { color: var(--ink); background: var(--sand-soft); }
.topbar__nav a.is-current { color: var(--teal-d); background: #d9eded; }
.topbar__actions { margin-left: auto; display: flex; gap: 8px; }
.btn {
font-family: var(--sans);
font-weight: 600;
font-size: 0.9rem;
border: 1px solid transparent;
border-radius: 999px;
padding: 9px 16px;
cursor: pointer;
transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
}
.btn:active { transform: translateY(1px); }
.btn--ghost { background: var(--coral); color: #fff; box-shadow: var(--shadow-1); }
.btn--ghost:hover { background: var(--coral-d); }
.btn--quiet { background: transparent; color: var(--muted); border-color: var(--line-2); }
.btn--quiet:hover { color: var(--ink); border-color: var(--ink); }
.btn--soft { background: #d9eded; color: var(--teal-d); }
.btn--soft:hover { background: #c9e6e6; }
/* ---------- Hero ---------- */
.hero {
position: relative;
margin: 14px clamp(14px, 4vw, 36px) 0;
border-radius: var(--r-lg);
overflow: hidden;
isolation: isolate;
box-shadow: var(--shadow-2);
}
.hero__scene { position: absolute; inset: 0; z-index: -1; }
.hero__svg { width: 100%; height: 100%; display: block; }
.hero::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(100deg, rgba(36, 31, 26, 0.62) 0%, rgba(36, 31, 26, 0.18) 58%, transparent 100%);
}
.hero__body { padding: clamp(22px, 4vw, 40px); color: #fff; max-width: 640px; }
.hero__eyebrow {
margin: 0 0 8px;
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #ffe2cf;
}
.hero__title {
font-size: clamp(1.9rem, 5vw, 3rem);
font-weight: 600;
line-height: 1.04;
margin: 0 0 10px;
outline-offset: 6px;
border-radius: 6px;
}
.hero__title:focus-visible { outline-color: #fff; }
.hero__sub { margin: 0 0 22px; max-width: 46ch; color: #fbeee2; font-size: 1rem; }
.hero__stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
}
.stat {
background: rgba(255, 253, 249, 0.16);
border: 1px solid rgba(255, 255, 255, 0.28);
backdrop-filter: blur(6px);
border-radius: var(--r-md);
padding: 10px 16px;
min-width: 92px;
}
.stat dt { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: #ffe7d8; margin: 0 0 2px; }
.stat dd { margin: 0; font-family: var(--display); font-weight: 700; font-size: 1.35rem; }
/* ---------- Planner grid ---------- */
.planner {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.25fr) minmax(0, 0.95fr);
gap: 16px;
padding: 20px clamp(14px, 4vw, 36px) 8px;
align-items: start;
}
.col {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px;
box-shadow: var(--shadow-1);
}
.col--map { position: sticky; top: 76px; }
.col__head { margin-bottom: 14px; }
.col__head h2 { font-size: 1.18rem; font-weight: 600; }
.col__head--row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.col__hint { margin: 2px 0 0; color: var(--muted); font-size: 0.84rem; }
/* ---------- Search + filters ---------- */
.search {
position: relative;
margin-bottom: 12px;
}
.search__icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
font-size: 1.05rem;
}
.search input {
width: 100%;
font-family: var(--sans);
font-size: 0.94rem;
padding: 11px 14px 11px 34px;
border: 1px solid var(--line-2);
border-radius: 999px;
background: var(--bg);
color: var(--ink);
}
.search input:focus-visible { outline-offset: 0; border-color: var(--teal); }
.filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.chip {
font-family: var(--sans);
font-size: 0.82rem;
font-weight: 500;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--muted);
cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s;
}
.chip:hover { border-color: var(--ink); color: var(--ink); }
.chip.is-active { background: var(--ink); color: #fff; border-color: var(--ink); }
/* ---------- POI list ---------- */
.poi-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; max-height: 560px; overflow-y: auto; }
.poi-list::-webkit-scrollbar { width: 8px; }
.poi-list::-webkit-scrollbar-thumb { background: var(--sand); border-radius: 8px; }
.poi {
display: grid;
grid-template-columns: 48px 1fr auto;
gap: 12px;
align-items: center;
padding: 11px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--surface);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s;
}
.poi:hover { border-color: var(--line-2); box-shadow: var(--shadow-1); }
.poi.is-added { opacity: 0.55; }
.poi__thumb {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.4rem;
color: #fff;
box-shadow: inset 0 -10px 18px rgba(0, 0, 0, 0.16);
}
.poi__body { min-width: 0; }
.poi__name { font-weight: 600; font-size: 0.95rem; margin: 0; }
.poi__meta { margin: 2px 0 0; color: var(--muted); font-size: 0.8rem; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.poi__price { font-weight: 600; color: var(--teal-d); }
.poi__best { background: var(--sand-soft); color: var(--gold); padding: 1px 7px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; }
.rating { color: var(--gold); letter-spacing: 0.5px; }
.poi__add {
border: 1px solid var(--teal);
background: #fff;
color: var(--teal-d);
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.14s, color 0.14s, transform 0.12s;
}
.poi__add:hover { background: var(--teal); color: #fff; }
.poi__add:active { transform: scale(0.92); }
.poi.is-added .poi__add { border-color: var(--line-2); color: var(--muted); cursor: default; }
.poi-empty { color: var(--muted); font-size: 0.9rem; text-align: center; padding: 24px 0; }
/* ---------- Days / itinerary ---------- */
.days { display: flex; flex-direction: column; gap: 14px; }
.day {
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--bg);
overflow: hidden;
}
.day.is-drop { outline: 2px dashed var(--teal); outline-offset: -4px; background: #eef7f7; }
.day__head {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
background: linear-gradient(90deg, var(--sand-soft), transparent);
border-bottom: 1px solid var(--line);
}
.day__num {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--teal);
color: #fff;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.82rem;
flex: none;
}
.day__title { font-family: var(--display); font-weight: 600; font-size: 1rem; }
.day__cost { margin-left: auto; font-weight: 600; color: var(--teal-d); font-size: 0.86rem; }
.day__remove {
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 1.05rem;
padding: 2px 4px;
border-radius: 6px;
}
.day__remove:hover { color: var(--coral-d); background: #fbe6df; }
.day__list { list-style: none; margin: 0; padding: 8px; min-height: 56px; display: flex; flex-direction: column; gap: 7px; }
.day__empty { color: var(--muted); font-size: 0.84rem; text-align: center; padding: 14px 8px; border: 1px dashed var(--line-2); border-radius: var(--r-sm); }
.stop {
display: grid;
grid-template-columns: auto 36px 1fr auto auto;
gap: 10px;
align-items: center;
padding: 9px 11px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-sm);
cursor: grab;
transition: box-shadow 0.15s, border-color 0.15s, transform 0.1s;
}
.stop:hover { border-color: var(--line-2); box-shadow: var(--shadow-1); }
.stop.dragging { opacity: 0.5; cursor: grabbing; }
.stop__grip { color: var(--line-2); font-size: 1rem; cursor: grab; user-select: none; }
.stop__pin {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 0.95rem;
color: #fff;
}
.stop__name { font-weight: 600; font-size: 0.9rem; min-width: 0; }
.stop__sub { color: var(--muted); font-size: 0.76rem; }
.stop__cost { font-weight: 600; font-size: 0.85rem; color: var(--teal-d); }
.stop__del {
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 1.1rem;
width: 26px;
height: 26px;
border-radius: 6px;
line-height: 1;
}
.stop__del:hover { color: var(--coral-d); background: #fbe6df; }
/* ---------- Map ---------- */
.map {
position: relative;
border-radius: var(--r-sm);
overflow: hidden;
border: 1px solid var(--line);
margin-bottom: 16px;
background: #dceef0;
}
.map__svg { width: 100%; height: auto; display: block; }
.map__empty {
position: absolute;
inset: 0;
margin: 0;
display: grid;
place-items: center;
color: var(--teal-d);
font-size: 0.86rem;
text-align: center;
padding: 16px;
background: rgba(220, 238, 240, 0.7);
}
.map__pin-label { font-family: var(--sans); font-size: 9px; font-weight: 600; fill: var(--ink); }
.map__pin-num { font-family: var(--sans); font-size: 9px; font-weight: 700; fill: #fff; }
/* ---------- Budget ---------- */
.budget__hd { font-size: 1rem; font-weight: 600; margin-bottom: 10px; }
.budget__list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.budget__row { display: flex; align-items: center; gap: 10px; font-size: 0.86rem; }
.budget__bar { flex: 1; height: 8px; border-radius: 999px; background: var(--sand-soft); overflow: hidden; }
.budget__fill { height: 100%; border-radius: 999px; transition: width 0.4s ease; }
.budget__cat { width: 64px; color: var(--muted); }
.budget__val { width: 56px; text-align: right; font-weight: 600; }
.budget__empty { color: var(--muted); font-size: 0.85rem; }
.budget__total {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.budget__total strong { font-family: var(--display); font-size: 1.4rem; color: var(--coral-d); }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: #fff;
padding: 12px 20px;
border-radius: 999px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: var(--shadow-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 60;
max-width: 90vw;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Footer ---------- */
.foot {
text-align: center;
color: var(--muted);
font-size: 0.82rem;
padding: 24px 16px 36px;
}
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
.planner { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); }
.col--map { grid-column: 1 / -1; position: static; }
.col--map .map { max-width: 420px; }
}
@media (max-width: 760px) {
.planner { grid-template-columns: 1fr; }
.col--map { position: static; }
.topbar__nav { display: none; }
.poi-list { max-height: none; }
.stop { grid-template-columns: auto 30px 1fr auto auto; }
}
@media (max-width: 420px) {
.hero__stats { gap: 7px; }
.stat { min-width: 0; flex: 1 1 calc(50% - 7px); padding: 9px 12px; }
.topbar__actions .btn { padding: 8px 12px; }
.brand__text { font-size: 1.05rem; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}(function () {
"use strict";
/* ----------------------------------------------------------------
* Data — fictional Adriatic & Alps catalogue of POIs.
* x/y are normalized (0..1) map coordinates for the SVG route map.
* ---------------------------------------------------------------- */
var CATALOG = [
{ id: "old-town", name: "Stari Grad Walls", place: "Dubrovnik", cat: "sight", emoji: "🏰", color: "#e8623f", price: 38, rating: 5, best: "Spring", x: 0.30, y: 0.78 },
{ id: "kayak", name: "Sea-Cave Kayaking", place: "Lokrum Bay", cat: "nature", emoji: "🛶", color: "#1f8a8a", price: 55, rating: 4, best: "Summer", x: 0.42, y: 0.70 },
{ id: "konoba", name: "Konoba Mareta", place: "Cavtat", cat: "food", emoji: "🍝", color: "#c98a2b", price: 44, rating: 5, best: "Anytime", x: 0.36, y: 0.86 },
{ id: "split", name: "Diocletian Palace", place: "Split", cat: "sight", emoji: "🏛", color: "#e8623f", price: 22, rating: 4, best: "Autumn", x: 0.24, y: 0.58 },
{ id: "plitvice", name: "Plitvice Lakes", place: "Lika", cat: "nature", emoji: "💧", color: "#1f8a8a", price: 40, rating: 5, best: "Spring", x: 0.18, y: 0.40 },
{ id: "truffle", name: "Istrian Truffle Hunt", place: "Motovun", cat: "food", emoji: "🍄", color: "#c98a2b", price: 78, rating: 5, best: "Autumn", x: 0.10, y: 0.30 },
{ id: "harbor-inn", name: "Harbor View Suite", place: "Rovinj", cat: "stay", emoji: "🛏", color: "#6b6259", price: 165, rating: 4, best: "Summer", x: 0.08, y: 0.24 },
{ id: "ljub", name: "Triple Bridge Walk", place: "Ljubljana", cat: "sight", emoji: "🌉", color: "#e8623f", price: 0, rating: 4, best: "Anytime", x: 0.30, y: 0.18 },
{ id: "bled", name: "Lake Bled Rowboat", place: "Bled", cat: "nature", emoji: "⛵", color: "#1f8a8a", price: 30, rating: 5, best: "Summer", x: 0.40, y: 0.10 },
{ id: "vintgar", name: "Vintgar Gorge", place: "Gorje", cat: "nature", emoji: "⛰", color: "#1f8a8a", price: 12, rating: 4, best: "Spring", x: 0.52, y: 0.08 },
{ id: "chalet", name: "Alpine Chalet Stay", place: "Kranjska Gora", cat: "stay", emoji: "🏔", color: "#6b6259", price: 210, rating: 5, best: "Winter", x: 0.62, y: 0.14 },
{ id: "potica", name: "Potica Bakery Class", place: "Bled", cat: "food", emoji: "🥐", color: "#c98a2b", price: 35, rating: 4, best: "Anytime", x: 0.46, y: 0.16 },
{ id: "soca", name: "Soča River Raft", place: "Bovec", cat: "nature", emoji: "🚣", color: "#1f8a8a", price: 62, rating: 5, best: "Summer", x: 0.70, y: 0.22 },
{ id: "postojna", name: "Postojna Cave", place: "Postojna", cat: "sight", emoji: "🦇", color: "#e8623f", price: 31, rating: 4, best: "Anytime", x: 0.24, y: 0.30 },
{ id: "gelato", name: "Piazza Gelateria", place: "Piran", cat: "food", emoji: "🍦", color: "#c98a2b", price: 9, rating: 5, best: "Summer", x: 0.18, y: 0.52 },
{ id: "salt", name: "Sečovlje Salt Pans", place: "Piran", cat: "nature", emoji: "🧂", color: "#1f8a8a", price: 14, rating: 3, best: "Autumn", x: 0.14, y: 0.46 }
];
var CAT_LABEL = { sight: "Sight", nature: "Nature", food: "Food", stay: "Stay" };
var STORAGE_KEY = "wanderatlas.trip.v1";
/* ----------------------------------------------------------------
* State
* ---------------------------------------------------------------- */
var state = {
title: "Coast to Summit Escape",
// days: array of arrays of POI ids (in order)
days: [[], [], []]
};
var byId = {};
CATALOG.forEach(function (p) { byId[p.id] = p; });
/* ----------------------------------------------------------------
* Element refs
* ---------------------------------------------------------------- */
var els = {
search: document.getElementById("searchInput"),
filters: document.querySelectorAll(".chip"),
poiList: document.getElementById("poiList"),
poiEmpty: document.getElementById("poiEmpty"),
days: document.getElementById("days"),
addDay: document.getElementById("addDayBtn"),
saveBtn: document.getElementById("saveBtn"),
clearBtn: document.getElementById("clearBtn"),
title: document.getElementById("tripTitle"),
statDays: document.getElementById("statDays"),
statStops: document.getElementById("statStops"),
statBudget: document.getElementById("statBudget"),
statPerDay: document.getElementById("statPerDay"),
pinLayer: document.getElementById("pinLayer"),
routeLayer: document.getElementById("routeLayer"),
mapEmpty: document.getElementById("mapEmpty"),
budgetList: document.getElementById("budgetList"),
budgetTotal: document.getElementById("budgetTotal"),
toast: document.getElementById("toast")
};
var SVGNS = "http://www.w3.org/2000/svg";
var activeFilter = "all";
/* ----------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------- */
function money(n) { return "$" + Math.round(n).toLocaleString("en-US"); }
function freeOr(n) { return n === 0 ? "Free" : money(n); }
var toastTimer;
function toast(msg) {
els.toast.textContent = msg;
els.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { els.toast.classList.remove("show"); }, 2200);
}
function addedIds() {
var s = {};
state.days.forEach(function (d) { d.forEach(function (id) { s[id] = true; }); });
return s;
}
function stars(n) {
return "★★★★★".slice(0, n) + "☆☆☆☆☆".slice(0, 5 - n);
}
/* ----------------------------------------------------------------
* Render: POI explore list
* ---------------------------------------------------------------- */
function renderPois() {
var q = els.search.value.trim().toLowerCase();
var added = addedIds();
els.poiList.innerHTML = "";
var shown = 0;
CATALOG.forEach(function (p) {
if (activeFilter !== "all" && p.cat !== activeFilter) return;
if (q && (p.name + " " + p.place + " " + p.cat).toLowerCase().indexOf(q) === -1) return;
shown++;
var li = document.createElement("li");
li.className = "poi" + (added[p.id] ? " is-added" : "");
var thumb = document.createElement("div");
thumb.className = "poi__thumb";
thumb.style.background = "linear-gradient(135deg," + p.color + "," + shade(p.color) + ")";
thumb.textContent = p.emoji;
var body = document.createElement("div");
body.className = "poi__body";
var name = document.createElement("p");
name.className = "poi__name";
name.textContent = p.name;
var meta = document.createElement("p");
meta.className = "poi__meta";
meta.innerHTML =
"<span>" + escapeHtml(p.place) + "</span>" +
'<span class="rating" aria-label="' + p.rating + ' out of 5">' + stars(p.rating) + "</span>" +
'<span class="poi__price">' + freeOr(p.price) + "</span>" +
'<span class="poi__best">' + escapeHtml(p.best) + "</span>";
body.appendChild(name);
body.appendChild(meta);
var add = document.createElement("button");
add.className = "poi__add";
add.type = "button";
add.setAttribute("aria-label",
(added[p.id] ? "Already in trip: " : "Add to trip: ") + p.name);
add.textContent = added[p.id] ? "✓" : "+";
if (!added[p.id]) {
add.addEventListener("click", function () { addStop(p.id); });
} else {
add.disabled = true;
}
li.appendChild(thumb);
li.appendChild(body);
li.appendChild(add);
els.poiList.appendChild(li);
});
els.poiEmpty.hidden = shown !== 0;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
});
}
// darken a hex color for gradients
function shade(hex) {
var n = parseInt(hex.slice(1), 16);
var r = Math.max(0, (n >> 16) - 38);
var g = Math.max(0, ((n >> 8) & 255) - 38);
var b = Math.max(0, (n & 255) - 38);
return "rgb(" + r + "," + g + "," + b + ")";
}
/* ----------------------------------------------------------------
* Render: itinerary days + drag/drop
* ---------------------------------------------------------------- */
function renderDays() {
els.days.innerHTML = "";
state.days.forEach(function (dayStops, di) {
var dayCost = dayStops.reduce(function (sum, id) { return sum + byId[id].price; }, 0);
var day = document.createElement("div");
day.className = "day";
day.dataset.day = di;
var head = document.createElement("div");
head.className = "day__head";
head.innerHTML =
'<span class="day__num">' + (di + 1) + "</span>" +
'<span class="day__title">Day ' + (di + 1) + "</span>" +
'<span class="day__cost">' + freeOr(dayCost) + "</span>";
var rm = document.createElement("button");
rm.className = "day__remove";
rm.type = "button";
rm.innerHTML = "✕";
rm.title = "Remove day";
rm.setAttribute("aria-label", "Remove day " + (di + 1));
rm.addEventListener("click", function () { removeDay(di); });
head.appendChild(rm);
var list = document.createElement("ul");
list.className = "day__list";
list.dataset.day = di;
if (dayStops.length === 0) {
var empty = document.createElement("li");
empty.className = "day__empty";
empty.textContent = "Drag spots here, or add from the left.";
list.appendChild(empty);
} else {
dayStops.forEach(function (id, si) {
list.appendChild(buildStop(id, di, si));
});
}
// drop-target wiring on the list
wireDrop(list, day, di);
day.appendChild(head);
day.appendChild(list);
els.days.appendChild(day);
});
}
function buildStop(id, di, si) {
var p = byId[id];
var li = document.createElement("li");
li.className = "stop";
li.draggable = true;
li.dataset.id = id;
li.dataset.day = di;
li.dataset.idx = si;
li.innerHTML =
'<span class="stop__grip" aria-hidden="true">⠿</span>' +
'<span class="stop__pin" style="background:linear-gradient(135deg,' + p.color + "," + shade(p.color) + ')">' + p.emoji + "</span>" +
'<span class="stop__name">' + escapeHtml(p.name) +
'<br><span class="stop__sub">' + escapeHtml(p.place) + " · " + CAT_LABEL[p.cat] + "</span></span>" +
'<span class="stop__cost">' + freeOr(p.price) + "</span>";
var del = document.createElement("button");
del.className = "stop__del";
del.type = "button";
del.innerHTML = "✕";
del.setAttribute("aria-label", "Remove " + p.name + " from trip");
del.addEventListener("click", function (e) {
e.stopPropagation();
removeStop(di, id);
});
li.appendChild(del);
li.addEventListener("dragstart", function (e) {
drag = { id: id, fromDay: di };
li.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", id); } catch (err) {}
});
li.addEventListener("dragend", function () {
li.classList.remove("dragging");
drag = null;
clearDropHints();
});
return li;
}
var drag = null;
function wireDrop(list, dayEl, di) {
list.addEventListener("dragover", function (e) {
if (!drag) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
dayEl.classList.add("is-drop");
});
list.addEventListener("dragleave", function (e) {
if (!list.contains(e.relatedTarget)) dayEl.classList.remove("is-drop");
});
list.addEventListener("drop", function (e) {
if (!drag) return;
e.preventDefault();
dayEl.classList.remove("is-drop");
// find insertion index based on pointer position
var after = getDropIndex(list, e.clientY);
moveStop(drag.id, drag.fromDay, di, after);
});
}
function getDropIndex(list, y) {
var stops = Array.prototype.slice.call(list.querySelectorAll(".stop:not(.dragging)"));
for (var i = 0; i < stops.length; i++) {
var box = stops[i].getBoundingClientRect();
if (y < box.top + box.height / 2) return i;
}
return stops.length;
}
function clearDropHints() {
var ds = els.days.querySelectorAll(".day.is-drop");
Array.prototype.forEach.call(ds, function (d) { d.classList.remove("is-drop"); });
}
/* ----------------------------------------------------------------
* Mutations
* ---------------------------------------------------------------- */
function addStop(id) {
if (addedIds()[id]) return;
// add to the day with the fewest stops (keeps things balanced)
var target = 0, min = Infinity;
state.days.forEach(function (d, i) {
if (d.length < min) { min = d.length; target = i; }
});
state.days[target].push(id);
toast(byId[id].name + " → Day " + (target + 1));
update();
}
function removeStop(di, id) {
var idx = state.days[di].indexOf(id);
if (idx > -1) state.days[di].splice(idx, 1);
update();
}
function moveStop(id, fromDay, toDay, insertIdx) {
var from = state.days[fromDay];
var fi = from.indexOf(id);
if (fi === -1) return;
from.splice(fi, 1);
var dest = state.days[toDay];
// if moving within same day and removing earlier item shifts indices
if (fromDay === toDay && fi < insertIdx) insertIdx--;
if (insertIdx < 0) insertIdx = 0;
if (insertIdx > dest.length) insertIdx = dest.length;
dest.splice(insertIdx, 0, id);
update();
}
function addDay() {
state.days.push([]);
toast("Day " + state.days.length + " added");
update();
}
function removeDay(di) {
if (state.days.length <= 1) { toast("Keep at least one day"); return; }
state.days.splice(di, 1);
update();
}
/* ----------------------------------------------------------------
* Render: route map (SVG pins + connecting route)
* ---------------------------------------------------------------- */
function renderMap() {
els.pinLayer.innerHTML = "";
els.routeLayer.innerHTML = "";
// flatten in day → order sequence
var seq = [];
state.days.forEach(function (d) { d.forEach(function (id) { seq.push(id); }); });
if (seq.length === 0) {
els.mapEmpty.style.display = "grid";
return;
}
els.mapEmpty.style.display = "none";
var W = 320, H = 360, PAD = 28;
var pts = seq.map(function (id) {
var p = byId[id];
return { p: p, x: PAD + p.x * (W - PAD * 2), y: PAD + p.y * (H - PAD * 2) };
});
// route polyline
if (pts.length > 1) {
var d = "M " + pts.map(function (pt) { return pt.x.toFixed(1) + " " + pt.y.toFixed(1); }).join(" L ");
var path = document.createElementNS(SVGNS, "path");
path.setAttribute("d", d);
els.routeLayer.appendChild(path);
}
// pins (numbered)
pts.forEach(function (pt, i) {
var g = document.createElementNS(SVGNS, "g");
var teardrop = document.createElementNS(SVGNS, "path");
teardrop.setAttribute("d",
"M " + pt.x + " " + (pt.y + 11) +
" C " + (pt.x - 9) + " " + (pt.y - 2) + " " + (pt.x - 9) + " " + (pt.y - 14) + " " + pt.x + " " + (pt.y - 14) +
" C " + (pt.x + 9) + " " + (pt.y - 14) + " " + (pt.x + 9) + " " + (pt.y - 2) + " " + pt.x + " " + (pt.y + 11) + " Z");
teardrop.setAttribute("fill", pt.p.color);
teardrop.setAttribute("stroke", "#fff");
teardrop.setAttribute("stroke-width", "1.4");
var num = document.createElementNS(SVGNS, "text");
num.setAttribute("class", "map__pin-num");
num.setAttribute("x", pt.x);
num.setAttribute("y", pt.y - 4.5);
num.setAttribute("text-anchor", "middle");
num.textContent = i + 1;
var label = document.createElementNS(SVGNS, "text");
label.setAttribute("class", "map__pin-label");
label.setAttribute("x", pt.x);
label.setAttribute("y", pt.y + 22);
label.setAttribute("text-anchor", "middle");
label.textContent = pt.p.place;
var t = document.createElementNS(SVGNS, "title");
t.textContent = (i + 1) + ". " + pt.p.name + " (" + pt.p.place + ")";
g.appendChild(t);
g.appendChild(teardrop);
g.appendChild(num);
g.appendChild(label);
els.pinLayer.appendChild(g);
});
}
/* ----------------------------------------------------------------
* Render: stats + budget breakdown
* ---------------------------------------------------------------- */
function renderStats() {
var added = addedIds();
var ids = Object.keys(added);
var total = ids.reduce(function (s, id) { return s + byId[id].price; }, 0);
var nDays = state.days.length;
els.statDays.textContent = nDays;
els.statStops.textContent = ids.length;
els.statBudget.textContent = money(total);
els.statPerDay.textContent = money(nDays ? total / nDays : 0);
// budget by category
var cats = { sight: 0, nature: 0, food: 0, stay: 0 };
ids.forEach(function (id) { cats[byId[id].cat] += byId[id].price; });
var catMeta = {
sight: { label: "Sights", color: "#e8623f" },
nature: { label: "Nature", color: "#1f8a8a" },
food: { label: "Food", color: "#c98a2b" },
stay: { label: "Stays", color: "#6b6259" }
};
els.budgetList.innerHTML = "";
if (total === 0) {
var li = document.createElement("li");
li.className = "budget__empty";
li.textContent = "No costs yet — add a spot to start your budget.";
els.budgetList.appendChild(li);
} else {
Object.keys(catMeta).forEach(function (cat) {
var val = cats[cat];
if (val === 0) return;
var pct = Math.round((val / total) * 100);
var row = document.createElement("li");
row.className = "budget__row";
row.innerHTML =
'<span class="budget__cat">' + catMeta[cat].label + "</span>" +
'<span class="budget__bar"><span class="budget__fill" style="width:' + pct + "%;background:" + catMeta[cat].color + '"></span></span>' +
'<span class="budget__val">' + money(val) + "</span>";
els.budgetList.appendChild(row);
});
}
els.budgetTotal.textContent = money(total);
}
/* ----------------------------------------------------------------
* Persistence
* ---------------------------------------------------------------- */
function save(announce) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ title: state.title, days: state.days }));
if (announce) toast("Trip saved to this browser ✓");
} catch (e) {
if (announce) toast("Couldn't save (storage blocked)");
}
}
function load() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
var data = JSON.parse(raw);
if (data && Array.isArray(data.days)) {
// sanitize ids against current catalog
state.days = data.days.map(function (d) {
return (Array.isArray(d) ? d : []).filter(function (id) { return byId[id]; });
});
if (state.days.length === 0) state.days = [[]];
if (typeof data.title === "string" && data.title.trim()) {
state.title = data.title.trim();
els.title.textContent = state.title;
}
}
} catch (e) { /* ignore corrupt state */ }
}
function clearTrip() {
if (Object.keys(addedIds()).length === 0) { toast("Trip is already empty"); return; }
state.days = [[], [], []];
update();
toast("Trip cleared");
}
/* ----------------------------------------------------------------
* Master update — re-render everything + autosave
* ---------------------------------------------------------------- */
function update() {
renderPois();
renderDays();
renderMap();
renderStats();
save(false);
}
/* ----------------------------------------------------------------
* Events
* ---------------------------------------------------------------- */
els.search.addEventListener("input", renderPois);
Array.prototype.forEach.call(els.filters, function (chip) {
chip.addEventListener("click", function () {
Array.prototype.forEach.call(els.filters, function (c) {
c.classList.remove("is-active");
c.setAttribute("aria-pressed", "false");
});
chip.classList.add("is-active");
chip.setAttribute("aria-pressed", "true");
activeFilter = chip.dataset.cat;
renderPois();
});
});
els.addDay.addEventListener("click", addDay);
els.saveBtn.addEventListener("click", function () { save(true); });
els.clearBtn.addEventListener("click", clearTrip);
els.title.addEventListener("blur", function () {
var t = els.title.textContent.replace(/\s+/g, " ").trim();
if (!t) { t = "Untitled Trip"; els.title.textContent = t; }
state.title = t;
save(false);
});
els.title.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); els.title.blur(); }
});
/* ----------------------------------------------------------------
* Boot
* ---------------------------------------------------------------- */
load();
update();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Trip Planner — Wander Atlas</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=Work+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#planner">Skip to planner</a>
<header class="topbar" role="banner">
<div class="brand">
<span class="brand__mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28">
<path d="M16 2C9.9 2 5 6.9 5 13c0 7.5 9.4 16 10.4 16.9.4.3.9.3 1.2 0C17.6 29 27 20.5 27 13 27 6.9 22.1 2 16 2Z" fill="var(--coral)"/>
<circle cx="16" cy="13" r="4.4" fill="#fff"/>
</svg>
</span>
<span class="brand__text">Wander<span>Atlas</span></span>
</div>
<nav class="topbar__nav" aria-label="Primary">
<a href="#planner" class="is-current" aria-current="page">Plan</a>
<a href="#explore">Explore</a>
<a href="#itinerary">Itinerary</a>
</nav>
<div class="topbar__actions">
<button class="btn btn--ghost" id="saveBtn" type="button">Save trip</button>
<button class="btn btn--quiet" id="clearBtn" type="button">Clear</button>
</div>
</header>
<section class="hero" aria-label="Trip overview">
<div class="hero__scene" aria-hidden="true">
<svg class="hero__svg" viewBox="0 0 1200 360" preserveAspectRatio="xMidYMid slice" role="img">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffd9a0"/>
<stop offset=".55" stop-color="#f6b08a"/>
<stop offset="1" stop-color="#e98f78"/>
</linearGradient>
<linearGradient id="ridge1" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2f7d82"/>
<stop offset="1" stop-color="#1f5f6b"/>
</linearGradient>
<linearGradient id="ridge2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3f9b97"/>
<stop offset="1" stop-color="#2a7a7c"/>
</linearGradient>
</defs>
<rect width="1200" height="360" fill="url(#sky)"/>
<circle cx="930" cy="120" r="54" fill="#fff3df" opacity=".9"/>
<path d="M0 250 L160 170 L320 240 L470 150 L640 250 L820 160 L1000 250 L1200 180 L1200 360 L0 360Z" fill="url(#ridge1)" opacity=".55"/>
<path d="M0 300 L180 220 L360 300 L540 210 L720 300 L900 220 L1080 300 L1200 250 L1200 360 L0 360Z" fill="url(#ridge2)"/>
</svg>
</div>
<div class="hero__body">
<p class="hero__eyebrow">14-night journey · Adriatic & Alps</p>
<h1 class="hero__title" id="tripTitle" contenteditable="true" spellcheck="false" role="textbox" aria-label="Trip title, editable">Coast to Summit Escape</h1>
<p class="hero__sub">Build it spot by spot — add destinations, drag them between days, and watch your route and budget update live.</p>
<dl class="hero__stats">
<div class="stat"><dt>Days</dt><dd id="statDays">0</dd></div>
<div class="stat"><dt>Stops</dt><dd id="statStops">0</dd></div>
<div class="stat"><dt>Est. budget</dt><dd id="statBudget">$0</dd></div>
<div class="stat"><dt>Per day</dt><dd id="statPerDay">$0</dd></div>
</dl>
</div>
</section>
<main class="planner" id="planner">
<!-- Explore column -->
<section class="col col--explore" id="explore" aria-labelledby="exploreHd">
<div class="col__head">
<h2 id="exploreHd">Explore spots</h2>
<p class="col__hint">Search the atlas, then add to your trip.</p>
</div>
<div class="search">
<span class="search__icon" aria-hidden="true">⌕</span>
<input id="searchInput" type="search" placeholder="Search Dubrovnik, fjords, gelato…" aria-label="Search destinations" autocomplete="off" />
</div>
<div class="filters" role="group" aria-label="Filter by category">
<button class="chip is-active" data-cat="all" type="button" aria-pressed="true">All</button>
<button class="chip" data-cat="sight" type="button" aria-pressed="false">⛪ Sights</button>
<button class="chip" data-cat="nature" type="button" aria-pressed="false">⛰ Nature</button>
<button class="chip" data-cat="food" type="button" aria-pressed="false">🍝 Food</button>
<button class="chip" data-cat="stay" type="button" aria-pressed="false">🛏 Stays</button>
</div>
<ul class="poi-list" id="poiList" aria-live="polite"></ul>
<p class="poi-empty" id="poiEmpty" hidden>No spots match that search.</p>
</section>
<!-- Itinerary column -->
<section class="col col--itin" id="itinerary" aria-labelledby="itinHd">
<div class="col__head col__head--row">
<div>
<h2 id="itinHd">Your itinerary</h2>
<p class="col__hint">Drag a spot to reorder or move it to another day.</p>
</div>
<button class="btn btn--soft" id="addDayBtn" type="button">+ Add day</button>
</div>
<div class="days" id="days"></div>
</section>
<!-- Map + budget column -->
<aside class="col col--map" aria-labelledby="mapHd">
<div class="col__head">
<h2 id="mapHd">Route map</h2>
<p class="col__hint">Pins follow your day order.</p>
</div>
<div class="map" id="map">
<svg class="map__svg" viewBox="0 0 320 360" role="img" aria-label="Mini route map of your trip">
<defs>
<linearGradient id="sea" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#dceef0"/>
<stop offset="1" stop-color="#bfe2e6"/>
</linearGradient>
<linearGradient id="land" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#efe3cf"/>
<stop offset="1" stop-color="#e3d3b8"/>
</linearGradient>
</defs>
<rect width="320" height="360" fill="url(#sea)"/>
<path d="M0 60 Q90 40 150 90 T320 70 L320 0 L0 0Z" fill="url(#land)" opacity=".9"/>
<path d="M0 300 Q80 260 170 300 T320 280 L320 360 L0 360Z" fill="url(#land)"/>
<path d="M40 150 q30 -40 80 -20 t90 30 q40 -10 70 20 l0 60 q-50 30 -110 10 t-130 -30Z" fill="#f3ead9" opacity=".85"/>
<g id="routeLayer" fill="none" stroke="var(--coral)" stroke-width="2.4" stroke-dasharray="3 5" stroke-linecap="round"></g>
<g id="pinLayer"></g>
</svg>
<p class="map__empty" id="mapEmpty">Add spots to draw your route.</p>
</div>
<div class="budget">
<h3 class="budget__hd">Budget breakdown</h3>
<ul class="budget__list" id="budgetList"></ul>
<div class="budget__total">
<span>Total</span>
<strong id="budgetTotal">$0</strong>
</div>
</div>
</aside>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<footer class="foot" role="contentinfo">
Illustrative travel UI — fictional destinations, prices & maps. Trip auto-saves to your browser.
</footer>
<script src="script.js"></script>
</body>
</html>Trip Planner
A full-bleed editorial trip planner for the fictional Coast to Summit Escape — a 14-night journey down the Adriatic coast and up into the Alps. A landscape-photography hero rendered entirely in layered SVG (warm sky, sunset sun, two mountain ridges) sits behind an editable trip title and a live stats rail: total days, stops, estimated budget, and cost per day. The three-column workspace pairs an Explore catalogue on the left, your Itinerary in the middle, and a sticky Route map + budget on the right.
Search the atlas or filter by Sights, Nature, Food, and Stays, then hit the + on any card to drop it into the day with the fewest stops. Each itinerary stop is fully draggable: pick it up by the grip and drop it anywhere — reorder within a day or move it to another day entirely, with the route map, day subtotals, and category budget all recomputing instantly. Add or remove days, delete spots, rename the trip, and the running totals follow along.
The mini map is first-class — a CSS/SVG sea-and-land mockup with numbered teardrop pins and a dashed route line drawn in your exact day-by-day order, each pin labelled with its place and titled for hover. Everything auto-saves to localStorage, so a reload restores your trip; explicit Save trip and Clear controls confirm with a toast. The layout collapses gracefully from three columns to two to a single stacked column down to ~360px, with keyboard-usable controls and visible focus rings throughout.
Illustrative travel UI only — fictional destinations, prices, and maps.