1 Commits

Author SHA1 Message Date
haerikimmm
d2fb40f692 added logo image and slogan to sign in page 2026-06-16 15:20:07 +09:00
26 changed files with 1553 additions and 170 deletions

BIN
public/plane 2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -62,13 +62,14 @@
<button class="journey-play-btn" onclick={startJourney}>▶</button> <button class="journey-play-btn" onclick={startJourney}>▶</button>
{/if} {/if}
</div> </div>
<StatsPanel /> {#if !journeyActive}<StatsPanel />{/if}
</div> </div>
{:else} {:else}
<TimelineView <TimelineView
onDetailChange={(v) => (inDetail = v)} onDetailChange={(v) => (inDetail = v)}
{pendingCountry} {pendingCountry}
onNewEntryClear={() => (pendingCountry = '')} onNewEntryClear={() => (pendingCountry = '')}
onGoToMap={() => { screen = 'worldmap'; }}
/> />
{/if} {/if}
</Layout> </Layout>
@@ -139,4 +140,5 @@
.journey-play-btn:active { .journey-play-btn:active {
transform: scale(0.92); transform: scale(0.92);
} }
</style> </style>

BIN
src/assets 2/airplane 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
src/assets 2/bus 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

BIN
src/assets 2/car 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
src/assets 2/ship 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
src/assets 2/train 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
src/assets 2/walk 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/logo-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
src/assets/logo-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

View File

@@ -1,12 +1,13 @@
<script> <script>
import { signInWithGoogle } from './userStore.svelte.js'; import { signInWithGoogle } from './userStore.svelte.js';
import logoImg from '../../assets/logo.png';
</script> </script>
<div class="overlay"> <div class="overlay">
<div class="card"> <div class="card">
<img src="/logo.png" alt="Map Journal" class="logo" /> <img src={logoImg} alt="Journi" class="logo" />
<h1 class="title">Map Journal</h1> <h1 class="title">Journi</h1>
<p class="subtitle">Sign in to start your journey</p> <p class="subtitle">Collect Colors Along the Way</p>
<button class="google-btn" onclick={signInWithGoogle}> <button class="google-btn" onclick={signInWithGoogle}>
<svg class="google-icon" viewBox="0 0 48 48"> <svg class="google-icon" viewBox="0 0 48 48">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/> <path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
@@ -24,64 +25,71 @@
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.85); background: var(--bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
backdrop-filter: blur(4px);
} }
.card { .card {
background: #1e2937;
border-radius: 16px;
padding: 48px 40px;
text-align: center; text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); max-width: 360px;
max-width: 400px;
width: 90%; width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
} }
.logo { .logo {
width: 80px; width: 216px;
height: 80px; height: 216px;
border-radius: 12px; object-fit: contain;
margin-bottom: 16px; margin-bottom: 16px;
} }
.title { .title {
font: 700 28px/1.2 sans-serif; font-family: var(--heading);
color: #f1f5f9; font-size: 28px;
margin-bottom: 8px; font-weight: 600;
color: var(--text-h);
letter-spacing: -0.5px;
margin: 0;
} }
.subtitle { .subtitle {
font: 400 15px/1.4 sans-serif; font-family: var(--sans);
color: #94a3b8; font-size: 14px;
margin-bottom: 32px; font-weight: 300;
color: var(--text);
margin: 0 0 32px;
} }
.google-btn { .google-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 12px 28px; padding: 10px 24px;
border: 1px solid rgba(255,255,255,0.15); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: #334155; background: var(--bg-subtle);
color: #f1f5f9; color: var(--text-h);
font: 500 16px/1.4 sans-serif; font-family: var(--sans);
font-size: 14px;
font-weight: 400;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.15s, border-color 0.15s;
} }
.google-btn:hover { .google-btn:hover {
background: #475569; background: var(--bg);
border-color: var(--accent-border);
} }
.google-icon { .google-icon {
width: 22px; width: 20px;
height: 22px; height: 20px;
flex-shrink: 0; flex-shrink: 0;
} }
</style> </style>

View File

@@ -1,7 +1,6 @@
import { auth, db, googleProvider } from '../firebase.js'; import { auth, db, googleProvider } from '../firebase.js';
import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth'; import { onAuthStateChanged, signInWithPopup, signOut as fbSignOut } from 'firebase/auth';
import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore'; import { doc, getDoc, setDoc, serverTimestamp } from 'firebase/firestore';
import { initSelectionListener } from '../layout/selection.svelte.js';
import { initEntriesListener } from '../stores/entriesStore.svelte.js'; import { initEntriesListener } from '../stores/entriesStore.svelte.js';
let _initialized = false; let _initialized = false;
@@ -48,7 +47,6 @@ export function initAuth() {
onAuthStateChanged(auth, async (fbUser) => { onAuthStateChanged(auth, async (fbUser) => {
if (fbUser) { if (fbUser) {
user = fbUser; user = fbUser;
initSelectionListener(fbUser.uid);
initEntriesListener(fbUser.uid); initEntriesListener(fbUser.uid);
const docRef = doc(db, 'users', fbUser.uid); const docRef = doc(db, 'users', fbUser.uid);
const docSnap = await getDoc(docRef); const docSnap = await getDoc(docRef);

19
src/lib/firebase 2.js Normal file
View File

@@ -0,0 +1,19 @@
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);
export const googleProvider = new GoogleAuthProvider();

View File

@@ -1,5 +1,6 @@
<script> <script>
import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js'; import { getUser, getUserProfile, signOut } from '../auth/userStore.svelte.js';
import logo1Img from '../../assets/logo-1.png';
let { screen, onNavigate } = $props(); let { screen, onNavigate } = $props();
@@ -20,8 +21,11 @@
<div class="topbar"> <div class="topbar">
<div class="left"> <div class="left">
<div class="brand">
<img src={logo1Img} class="logo-img" alt="Journi logo" />
<span class="app-name">Journi</span> <span class="app-name">Journi</span>
</div> </div>
</div>
<div class="center"> <div class="center">
<div class="segmented"> <div class="segmented">
@@ -83,6 +87,18 @@
gap: 10px; gap: 10px;
} }
.brand {
display: flex;
align-items: center;
gap: 4px;
}
.logo-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.app-name { .app-name {
font-family: var(--heading); font-family: var(--heading);
font-size: 22px; font-size: 22px;

View File

@@ -1,60 +1,19 @@
import { db } from '../firebase.js'; import { journals } from '../stores/journalStore.js';
import { doc, onSnapshot, setDoc, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore'; import { nameToId } from '../shared/countries.js';
let selected = $state(new Set()); let selected = $state(new Set());
let totalCountries = $state(0); let totalCountries = $state(0);
let homeCountryCode = $state(null);
let _uid = null;
let _unsubscribe = null;
export function initSelectionListener(uid) { // Derive visited countries from journal entries
if (_unsubscribe) _unsubscribe(); journals.subscribe((entries) => {
_uid = uid; const ids = new Set();
const userRef = doc(db, 'users', uid); for (const e of entries) {
_unsubscribe = onSnapshot(userRef, (snap) => { const id = nameToId[e.location?.country];
if (snap.exists()) { if (id) ids.add(id);
const codes = snap.data().visitedCountries || [];
selected = new Set(codes);
homeCountryCode = snap.data().homeCountryCode || null;
}
});
}
const visitedRef = doc(db, 'visited', 'countries');
onSnapshot(visitedRef, (snap) => {
if (snap.exists()) {
selected = new Set(snap.data().ids ?? []);
} }
selected = ids;
}); });
function persist() {
setDoc(visitedRef, { ids: [...selected] });
}
export function toggle(id) {
const was = selected.has(id);
const next = new Set(selected);
if (was) next.delete(id);
else next.add(id);
selected = next;
persist();
if (_uid) {
const userRef = doc(db, 'users', _uid);
if (was) updateDoc(userRef, { visitedCountries: arrayRemove(id) });
else updateDoc(userRef, { visitedCountries: arrayUnion(id) });
}
}
export function clearAll() {
selected = new Set();
persist();
if (_uid) {
const userRef = doc(db, 'users', _uid);
updateDoc(userRef, { visitedCountries: [] });
}
}
export function getSelected() { export function getSelected() {
return selected; return selected;
} }
@@ -66,7 +25,3 @@ export function setTotalCount(n) {
export function getTotalCount() { export function getTotalCount() {
return totalCountries; return totalCountries;
} }
export function getHomeCountryCode() {
return homeCountryCode;
}

View File

@@ -3,7 +3,7 @@
* Searchable combobox input. * Searchable combobox input.
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }} * @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
*/ */
let { id, value = $bindable(), options, placeholder = '', required = false, onselect } = $props(); let { id, value = $bindable(), options, placeholder = '', required = false, onselect, onblurcommit } = $props();
let query = $state(value); let query = $state(value);
let open = $state(false); let open = $state(false);
@@ -39,7 +39,11 @@
} }
function onBlur() { function onBlur() {
setTimeout(() => { open = false; focused = -1; }, 150); setTimeout(() => {
open = false;
focused = -1;
if (onblurcommit && query.trim()) onblurcommit(query.trim());
}, 150);
} }
// Keep query in sync if value is changed externally // Keep query in sync if value is changed externally

View File

@@ -63,8 +63,18 @@ const nameToAlpha2 = {
'eSwatini':'SZ','Åland':'AX', 'eSwatini':'SZ','Åland':'AX',
}; };
export const countryNames = feature(worldData, worldData.objects.countries) const _features = feature(worldData, worldData.objects.countries).features;
.features.map(f => f.properties?.name).filter(Boolean).sort();
export const countryNames = _features
.map(f => f.properties?.name).filter(Boolean).sort();
// country name → topojson numeric ID (e.g. 'Japan' → '392')
export const nameToId = Object.fromEntries(
_features
.filter(f => f.properties?.name && f.id)
.map(f => [f.properties.name, String(f.id)])
);
nameToId['Kosovo'] = 'XK';
/** @param {string} country */ /** @param {string} country */
export function flagEmoji(country) { export function flagEmoji(country) {

View File

@@ -0,0 +1,45 @@
import { db } from '../firebase.js';
import { collection, doc, onSnapshot, query, orderBy, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
let entries = $state([]);
let _uid = null;
let _unsubscribe = null;
export function getEntries() {
return entries;
}
export function initEntriesListener(uid) {
if (_unsubscribe) _unsubscribe();
_uid = uid;
const q = query(
collection(db, 'users', uid, 'entries'),
orderBy('createdAt', 'desc')
);
_unsubscribe = onSnapshot(q, (snap) => {
entries = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
});
}
export async function addEntry(data) {
if (!_uid) return null;
const ref = await addDoc(collection(db, 'users', _uid, 'entries'), {
...data,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
});
return ref.id;
}
export async function updateEntry(id, data) {
if (!_uid) return;
await updateDoc(doc(db, 'users', _uid, 'entries', id), {
...data,
updatedAt: serverTimestamp(),
});
}
export async function removeEntry(id) {
if (!_uid) return;
await deleteDoc(doc(db, 'users', _uid, 'entries', id));
}

View File

@@ -0,0 +1,454 @@
<script>
import { get } from 'svelte/store';
import { journals, addJournal } from '../stores/journalStore.js';
import { countryNames } from '../shared/countries.js';
import SearchInput from '../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
let { initialCountry = '', onBack } = $props();
// ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]);
let cityInput = $state('');
let country = $state(initialCountry);
let date = $state(new Date().toISOString().slice(0, 10));
let days = $state('');
let tripType = $state('');
let transport = $state('');
let photos = $state([]);
let answers = $state(['', '', '']);
let errors = $state({ country: '', cities: '', date: '', days: '', tripType: '', transport: '' });
// ── Steps ──────────────────────────────────────────────────────────
let step = $state(1); // 1 | 2 | 3
// ── Random questions ───────────────────────────────────────────────
const ALL_QUESTIONS = [
'If this trip had a movie title, what would it be?',
'What was the most unexpected thing that happened?',
'Which moment would you relive for just 10 more minutes?',
'What was your best accidental discovery?\n(A café, a street, a person, a view…)',
'If your trip had a theme song, what would it sound like?',
'What did you pack but never use?',
'What was the smallest thing that made you surprisingly happy?',
'If you could steal one thing from this place (without consequences), what would it be?\n(A tradition, a smell, a sunset, a food…)',
'What story from this trip will you probably tell your friends first?',
'What version of yourself showed up on this trip?',
];
function pickRandom() {
const shuffled = [...ALL_QUESTIONS].sort(() => Math.random() - 0.5);
return shuffled.slice(0, 3);
}
const questions = pickRandom();
// ── Helpers ────────────────────────────────────────────────────────
// Suggest cities — if a country is selected, show cities only from that country;
// otherwise show all known cities.
let cityOptions = $derived(
country.trim()
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
);
function addCity(val) {
const t = (val ?? cityInput).trim();
if (t && !cities.includes(t)) cities = [...cities, t];
cityInput = '';
}
function removeCity(c) { cities = cities.filter(x => x !== c); }
$effect(() => { if (country.trim()) errors.country = ''; });
$effect(() => { if (cities.length > 0) errors.cities = ''; });
$effect(() => { if (date) errors.date = ''; });
$effect(() => { if (days && Number(days) >= 1) errors.days = ''; });
$effect(() => { if (tripType) errors.tripType = ''; });
$effect(() => { if (transport) errors.transport = ''; });
const transportOptions = [
{ value: 'flight', label: '✈ Flight' },
{ value: 'train', label: '🚂 Train' },
{ value: 'bus', label: '🚌 Bus' },
{ value: 'car', label: '🚗 Car' },
{ value: 'ship', label: '🚢 Ship' },
{ value: 'walk', label: '🚶 Walk' },
];
// ── Navigation ─────────────────────────────────────────────────────
function nextStep() {
if (step === 1) {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
let hasError = false;
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
if (!date) { errors.date = 'Date is required.'; hasError = true; }
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
if (hasError) return;
}
step++;
}
function prevStep() {
if (step === 1) onBack();
else step--;
}
// ── Save ───────────────────────────────────────────────────────────
let saving = $state(false);
async function save() {
saving = true;
const memo = questions
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
.filter(Boolean)
.join('\n\n');
try {
await addJournal({
title: `${cities.join(', ')}, ${country}`,
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
onBack();
} catch {
saving = false;
}
}
</script>
<div class="layout">
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={prevStep}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
{step === 1 ? 'Back' : 'Previous'}
</button>
</div>
<div class="steps">
{#each [1,2,3] as s}
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
{/each}
</div>
<div class="topbar-right">
{#if step < 3}
<button class="save-btn" onclick={nextStep}>Next</button>
{:else}
<button class="save-btn" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save trip'}
</button>
{/if}
</div>
</header>
<div class="scroll">
<div class="form">
{#if step === 1}
<!-- ── STEP 1: Details ── -->
<h2 class="step-title">Trip details</h2>
<div class="row">
<div class="field">
<label class="label" for="nc-country">Country <span class="req">*</span></label>
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="tags">
{#each cities as c}
<span class="tag">{c}<button type="button" class="tag-rm" onclick={() => removeCity(c)}>×</button></span>
{/each}
</div>
{/if}
</div>
</div>
<div class="row">
<div class="field">
<label class="label" for="nc-date">Date <span class="req">*</span></label>
<input id="nc-date" class="input" type="date" bind:value={date} />
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-days">Days <span class="req">*</span></label>
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
</div>
</div>
<div class="field">
<label class="label">Trip type <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
{t === 'solo' ? 'Solo' : t === 'friends' ? 'With friends' : 'With family'}
</label>
{/each}
</div>
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
</div>
<div class="field">
<label class="label">How did you get there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="transport-opt" class:active={transport === opt.value}>
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
{opt.label}
</label>
{/each}
</div>
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
</div>
{:else if step === 2}
<!-- ── STEP 2: Photos ── -->
<h2 class="step-title">Photos</h2>
<p class="step-sub">Optional — add photos from your trip</p>
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
{:else}
<!-- ── STEP 3: Questions ── -->
<h2 class="step-title">Your memories</h2>
{#each questions as q, i}
<div class="q-card">
<p class="q-text">{q}</p>
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
font-family: var(--sans);
}
/* topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 52px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.topbar-left, .topbar-right {
display: flex;
align-items: center;
min-width: 110px;
}
.topbar-right { justify-content: flex-end; }
.steps {
display: flex;
gap: 8px;
align-items: center;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background 0.2s, transform 0.2s;
}
.step-dot.active {
background: var(--accent);
transform: scale(1.25);
}
.step-dot.done {
background: var(--accent);
opacity: 0.35;
}
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: var(--text);
background: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
.save-btn {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* scroll + form */
.scroll { flex: 1; overflow-y: auto; }
.form {
max-width: 560px;
margin: 0 auto;
padding: 36px 48px 80px;
display: flex;
flex-direction: column;
gap: 18px;
}
.step-title {
font-size: 20px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.3px;
margin: 0 0 2px;
}
.step-sub {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
margin: -10px 0 4px;
}
/* fields (same as EditForm) */
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.label {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-sub);
}
.req { color: var(--accent); font-size: 11px; }
.ferr { font-size: 11px; color: #dc2626; }
.input {
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text-h);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
outline: none;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.input:focus { border-color: var(--accent-border); }
.toggle-row { display: flex; gap: 8px; }
.toggle-opt {
display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 300; color: var(--text);
padding: 7px 14px; border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
background: var(--bg-subtle);
}
.toggle-opt input { display: none; }
.toggle-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); }
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.transport-opt {
display: flex; align-items: center; justify-content: center; gap: 6px;
font-size: 13px; font-weight: 300; color: var(--text);
padding: 8px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-subtle);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
white-space: nowrap;
}
.transport-opt input { display: none; }
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; font-weight: 300; color: var(--accent);
background: var(--accent-bg); border: 1px solid var(--accent-border);
border-radius: 20px; padding: 3px 10px 3px 12px;
}
.tag-rm {
background: none; border: none; color: var(--accent);
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
}
.tag-rm:hover { opacity: 1; }
/* question cards */
.q-card {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.q-text {
font-size: 14px;
font-weight: 400;
color: var(--text-h);
line-height: 1.5;
margin: 0;
white-space: pre-line;
}
.q-input {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
outline: none;
resize: none;
line-height: 1.6;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.q-input:focus { border-color: var(--accent-border); }
.q-input::placeholder { color: var(--text-sub); font-style: italic; }
</style>

View File

@@ -0,0 +1,465 @@
<script>
import { get } from 'svelte/store';
import { journals, addJournal } from '../stores/journalStore.js';
import { countryNames } from '../shared/countries.js';
import SearchInput from '../shared/SearchInput.svelte';
import PhotoEditor from './PhotoEditor.svelte';
import airplaneImg from '../../assets/airplane.png';
import trainImg from '../../assets/train.png';
import busImg from '../../assets/bus.png';
import carImg from '../../assets/car.png';
import shipImg from '../../assets/ship.png';
import walkImg from '../../assets/walk.png';
let { initialCountry = '', onBack } = $props();
// ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]);
let cityInput = $state('');
let country = $state(initialCountry);
let date = $state(new Date().toISOString().slice(0, 10));
let days = $state('');
let tripType = $state('');
let transport = $state('');
let photos = $state([]);
let answers = $state(['', '', '']);
let errors = $state({ country: '', cities: '', date: '', days: '', tripType: '', transport: '' });
// ── Steps ──────────────────────────────────────────────────────────
let step = $state(1); // 1 | 2 | 3
// ── Random questions ───────────────────────────────────────────────
const ALL_QUESTIONS = [
'If this trip had a movie title, what would it be?',
'What was the most unexpected thing that happened?',
'Which moment would you relive for just 10 more minutes?',
'What was your best accidental discovery?\n(A café, a street, a person, a view…)',
'If your trip had a theme song, what would it sound like?',
'What did you pack but never use?',
'What was the smallest thing that made you surprisingly happy?',
'If you could steal one thing from this place (without consequences), what would it be?\n(A tradition, a smell, a sunset, a food…)',
'What story from this trip will you probably tell your friends first?',
'What version of yourself showed up on this trip?',
];
function pickRandom() {
const shuffled = [...ALL_QUESTIONS].sort(() => Math.random() - 0.5);
return shuffled.slice(0, 3);
}
const questions = pickRandom();
// ── Helpers ────────────────────────────────────────────────────────
// Suggest cities — if a country is selected, show cities only from that country;
// otherwise show all known cities.
let cityOptions = $derived(
country.trim()
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
);
function addCity(val) {
const t = (val ?? cityInput).trim();
if (t && !cities.includes(t)) cities = [...cities, t];
cityInput = '';
}
function removeCity(c) { cities = cities.filter(x => x !== c); }
$effect(() => { if (country.trim()) errors.country = ''; });
$effect(() => { if (cities.length > 0) errors.cities = ''; });
$effect(() => { if (date) errors.date = ''; });
$effect(() => { if (days && Number(days) >= 1) errors.days = ''; });
$effect(() => { if (tripType) errors.tripType = ''; });
$effect(() => { if (transport) errors.transport = ''; });
const transportOptions = [
{ value: 'flight', label: 'Flight', img: airplaneImg },
{ value: 'train', label: 'Train', img: trainImg },
{ value: 'bus', label: 'Bus', img: busImg },
{ value: 'car', label: 'Car', img: carImg },
{ value: 'ship', label: 'Ship', img: shipImg },
{ value: 'walk', label: 'Walk', img: walkImg },
];
// ── Navigation ─────────────────────────────────────────────────────
function nextStep() {
if (step === 1) {
errors = { country: '', cities: '', date: '', days: '', tripType: '', transport: '' };
let hasError = false;
if (!country.trim()) { errors.country = 'Country is required.'; hasError = true; }
if (cities.length === 0) { errors.cities = 'Add at least one city.'; hasError = true; }
if (!date) { errors.date = 'Date is required.'; hasError = true; }
if (!days || Number(days) < 1) { errors.days = 'Enter a valid number of days.'; hasError = true; }
if (!tripType) { errors.tripType = 'Select a trip type.'; hasError = true; }
if (!transport) { errors.transport = 'Select how you got there.'; hasError = true; }
if (hasError) return;
}
step++;
}
function prevStep() {
if (step === 1) onBack();
else step--;
}
// ── Save ───────────────────────────────────────────────────────────
let saving = $state(false);
async function save() {
saving = true;
const memo = questions
.map((q, i) => answers[i].trim() ? `Q: ${q.split('\n')[0]}\nA: ${answers[i].trim()}` : '')
.filter(Boolean)
.join('\n\n');
try {
await addJournal({
title: `${cities.join(', ')}, ${country}`,
date,
days: Number(days),
tripType,
transport,
memo,
photos,
location: { cities, country },
});
onBack();
} catch {
saving = false;
}
}
</script>
<div class="layout">
<header class="topbar">
<div class="topbar-left">
<button class="ghost-btn" onclick={prevStep}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
{step === 1 ? 'Back' : 'Previous'}
</button>
</div>
<div class="steps">
{#each [1,2,3] as s}
<div class="step-dot" class:active={step === s} class:done={step > s}></div>
{/each}
</div>
<div class="topbar-right">
{#if step < 3}
<button class="save-btn" onclick={nextStep}>Next</button>
{:else}
<button class="save-btn" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save trip'}
</button>
{/if}
</div>
</header>
<div class="scroll">
<div class="form">
{#if step === 1}
<!-- ── STEP 1: Details ── -->
<h2 class="step-title">Trip details</h2>
<div class="row">
<div class="field">
<label class="label" for="nc-country">Country <span class="req">*</span></label>
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0}
<div class="tags">
{#each cities as c}
<span class="tag">{c}<button type="button" class="tag-rm" onclick={() => removeCity(c)}>×</button></span>
{/each}
</div>
{/if}
</div>
</div>
<div class="row">
<div class="field">
<label class="label" for="nc-date">Date <span class="req">*</span></label>
<input id="nc-date" class="input" type="date" bind:value={date} />
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
</div>
<div class="field">
<label class="label" for="nc-days">Days <span class="req">*</span></label>
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
</div>
</div>
<div class="field">
<label class="label">Trip type <span class="req">*</span></label>
<div class="toggle-row">
{#each ['solo','friends','family'] as t}
<label class="toggle-opt" class:active={tripType === t}>
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
{t === 'solo' ? 'Solo' : t === 'friends' ? 'With friends' : 'With family'}
</label>
{/each}
</div>
{#if errors.tripType}<span class="ferr">{errors.tripType}</span>{/if}
</div>
<div class="field">
<label class="label">How did you get there? <span class="req">*</span></label>
<div class="transport-grid">
{#each transportOptions as opt}
<label class="transport-opt" class:active={transport === opt.value}>
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
<img src={opt.img} alt={opt.label} class="transport-img" />
<span class="transport-label">{opt.label}</span>
</label>
{/each}
</div>
{#if errors.transport}<span class="ferr">{errors.transport}</span>{/if}
</div>
{:else if step === 2}
<!-- ── STEP 2: Photos ── -->
<h2 class="step-title">Photos</h2>
<p class="step-sub">Optional — add photos from your trip</p>
<PhotoEditor {photos} onchange={(p) => (photos = p)} />
{:else}
<!-- ── STEP 3: Questions ── -->
<h2 class="step-title">Your memories</h2>
{#each questions as q, i}
<div class="q-card">
<p class="q-text">{q}</p>
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
font-family: var(--sans);
}
/* topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 52px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.topbar-left, .topbar-right {
display: flex;
align-items: center;
min-width: 110px;
}
.topbar-right { justify-content: flex-end; }
.steps {
display: flex;
gap: 8px;
align-items: center;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background 0.2s, transform 0.2s;
}
.step-dot.active {
background: var(--accent);
transform: scale(1.25);
}
.step-dot.done {
background: var(--accent);
opacity: 0.35;
}
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: var(--text);
background: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ghost-btn:hover { background: var(--bg-subtle); border-color: var(--border); color: var(--text-h); }
.save-btn {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: #fff;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.save-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* scroll + form */
.scroll { flex: 1; overflow-y: auto; }
.form {
max-width: 560px;
margin: 0 auto;
padding: 36px 48px 80px;
display: flex;
flex-direction: column;
gap: 18px;
}
.step-title {
font-size: 20px;
font-weight: 400;
color: var(--text-h);
letter-spacing: -0.3px;
margin: 0 0 2px;
}
.step-sub {
font-size: 13px;
font-weight: 300;
color: var(--text-sub);
margin: -10px 0 4px;
}
/* fields (same as EditForm) */
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.label {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-sub);
}
.req { color: var(--accent); font-size: 11px; }
.ferr { font-size: 11px; color: #dc2626; }
.input {
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text-h);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
outline: none;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.input:focus { border-color: var(--accent-border); }
.toggle-row { display: flex; gap: 8px; }
.toggle-opt {
display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 300; color: var(--text);
padding: 7px 14px; border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
background: var(--bg-subtle);
}
.toggle-opt input { display: none; }
.toggle-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); }
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.transport-opt {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; aspect-ratio: 1;
border-radius: 12px; border: 1px solid var(--border); background: var(--bg-subtle);
cursor: pointer; transition: border-color 0.15s, background 0.15s;
}
.transport-opt input { display: none; }
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); }
.transport-img { width: 60px; height: 60px; object-fit: contain; }
.transport-label {
font-size: 12px; font-weight: 300; color: var(--text-sub);
letter-spacing: 0.02em;
}
.transport-opt.active .transport-label { color: var(--accent); }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; font-weight: 300; color: var(--accent);
background: var(--accent-bg); border: 1px solid var(--accent-border);
border-radius: 20px; padding: 3px 10px 3px 12px;
}
.tag-rm {
background: none; border: none; color: var(--accent);
font-size: 15px; line-height: 1; cursor: pointer; padding: 0; opacity: 0.6;
}
.tag-rm:hover { opacity: 1; }
/* question cards */
.q-card {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.q-text {
font-size: 14px;
font-weight: 400;
color: var(--text-h);
line-height: 1.5;
margin: 0;
white-space: pre-line;
}
.q-input {
font-family: var(--sans);
font-size: 13px;
font-weight: 300;
color: var(--text-h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
outline: none;
resize: none;
line-height: 1.6;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.q-input:focus { border-color: var(--accent-border); }
.q-input::placeholder { color: var(--text-sub); font-style: italic; }
</style>

View File

@@ -11,7 +11,7 @@
import shipImg from '../../assets/ship.png'; import shipImg from '../../assets/ship.png';
import walkImg from '../../assets/walk.png'; import walkImg from '../../assets/walk.png';
let { initialCountry = '', onBack } = $props(); let { initialCountry = '', onBack, onSaved = onBack } = $props();
// ── Fields ───────────────────────────────────────────────────────── // ── Fields ─────────────────────────────────────────────────────────
let cities = $state([]); let cities = $state([]);
@@ -124,7 +124,7 @@
photos, photos,
location: { cities, country }, location: { cities, country },
}); });
onBack(); onSaved();
} catch { } catch {
saving = false; saving = false;
} }
@@ -172,7 +172,7 @@
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="nc-city">Cities <span class="req">*</span></label> <label class="label" for="nc-city">Cities <span class="req">*</span></label>
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} /> <SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} onblurcommit={addCity} />
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if} {#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
{#if cities.length > 0} {#if cities.length > 0}
<div class="tags"> <div class="tags">
@@ -369,6 +369,25 @@
.ferr { font-size: 11px; color: #dc2626; } .ferr { font-size: 11px; color: #dc2626; }
.combo-select, .city-text {
font-family: var(--sans);
font-size: 14px;
font-weight: 300;
color: var(--text-h);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
outline: none;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
display: block;
}
.combo-select:focus, .city-text:focus { border-color: var(--accent-border); }
.combo-select { margin-bottom: 6px; cursor: pointer; }
.city-text { margin-top: 0; }
.input { .input {
font-family: var(--sans); font-family: var(--sans);
font-size: 14px; font-size: 14px;

View File

@@ -9,7 +9,7 @@
import ShareCard from './ShareCard.svelte'; import ShareCard from './ShareCard.svelte';
import SharePreview from './SharePreview.svelte'; import SharePreview from './SharePreview.svelte';
let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {} } = $props(); let { onDetailChange = () => {}, pendingCountry = '', onNewEntryClear = () => {}, onGoToMap = () => {} } = $props();
let selectedId = $state(/** @type {string|null} */(null)); let selectedId = $state(/** @type {string|null} */(null));
let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list')); let view = $state(/** @type {'list'|'detail'|'edit'|'new'} */('list'));
let showShare = $state(false); let showShare = $state(false);
@@ -55,7 +55,7 @@
{#if view === 'new'} {#if view === 'new'}
<div class="detail-scroll"> <div class="detail-scroll">
<NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} /> <NewEntryForm initialCountry={newEntryCountry} onBack={() => { view = 'list'; newEntryCountry = ''; onDetailChange(false); }} onSaved={() => { onGoToMap(); }} />
</div> </div>
{:else if view === 'edit' && selected} {:else if view === 'edit' && selected}
<div class="detail-scroll"> <div class="detail-scroll">
@@ -275,38 +275,4 @@
} }
.new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); } .new-btn:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.share-nudge {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 10px;
border: 1px dashed var(--border-bright);
background: var(--bg-subtle);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-family: var(--sans);
text-align: left;
}
.share-nudge:hover {
border-color: var(--accent-light);
background: var(--accent-bg);
}
.nudge-left {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 400;
color: var(--text-h);
}
.nudge-right {
font-size: 11px;
font-weight: 300;
color: var(--text-sub);
letter-spacing: 0.02em;
}
.share-nudge:hover .nudge-right { color: var(--accent); }
</style> </style>

View File

@@ -0,0 +1,391 @@
<script>
import { onMount, onDestroy } from 'svelte';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
let { onclose, onprogress } = $props();
const HOME_CODE = '203';
const MOCK_TRIPS = [
{ countryName: 'Japan', countryCode: '392', date: '2024-03-15', city: 'Tokyo' },
{ countryName: 'France', countryCode: '191', date: '2024-06-20', city: 'Paris' },
{ countryName: 'Spain', countryCode: '724', date: '2024-09-10', city: 'Barcelona' },
{ countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' },
{ countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' },
{ countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' },
];
const HOME_COLOR = '#8b5cf6';
const VISITED_COLOR = '#22c55e';
const ARC_COLOR = '#000000';
const PLANE_COLOR = '#7c3aed';
const PLANE_PATH = 'M14,0 L4,-3 L0,-7 L-3,-5 L0,-2 L-5,-1 L-9,-5 L-11,-4 L-7,0 L-11,4 L-9,5 L-5,1 L0,2 L-3,5 L0,7 L4,3 Z';
const UNVISITED = '#ffffff';
const TERRITORY_PARENT = {
'016': '840', '060': '826', '086': '826', '092': '826', '136': '826',
'184': '554', '234': '208', '238': '826', '239': '826', '248': '246',
'258': '250', '260': '250', '304': '208', '316': '840', '334': '036',
'446': '156', '500': '826', '531': '528', '533': '528', '534': '528',
'540': '250', '570': '554', '574': '036', '580': '840', '612': '826',
'630': '840', '652': '250', '654': '826', '660': '826', '663': '250',
'666': '250', '796': '826', '831': '826', '832': '826', '833': '826',
'850': '840', '876': '250',
};
function effId(d) {
return TERRITORY_PARENT[d.id] || d.id;
}
let frameEl;
let svg, g, pathFn, projection;
let countryPaths;
let homeFeature;
let featuresById = {};
let isCancelled = false;
let isPlaying = $state(false);
let isFinished = $state(false);
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
const s = proj.scale() * 1.5;
proj.scale(s).translate([w / 2, h * 0.70]);
}
function computeArc(p1, p2) {
const interp = d3.geoInterpolate(p1, p2);
const steps = 80;
const raw = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const geo = interp(t);
const pt = projection(geo);
if (!pt) continue;
raw.push({ t, x: pt[0], y: pt[1] });
}
if (raw.length < 2) return [];
const first = raw[0];
const last = raw[raw.length - 1];
const dx = last.x - first.x;
const dy = last.y - first.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const arcH = Math.max(40, Math.min(200, dist * 0.22));
return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]);
}
function getAngleAtLength(node, len) {
const d = 0.5;
const total = node.getTotalLength();
const p1 = node.getPointAtLength(Math.max(0, len - d));
const p2 = node.getPointAtLength(Math.min(total, len + d));
return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
}
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration) {
return new Promise((resolve) => {
const node = pathEl.node();
if (!node) { resolve(); return; }
const totalLength = node.getTotalLength();
if (totalLength === 0) { resolve(); return; }
d3.timer(elapsed => {
if (isCancelled) { resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const offset = startOffset + (endOffset - startOffset) * t;
pathEl.attr('stroke-dashoffset', offset);
const drawn = totalLength - offset;
const clamped = Math.max(0, Math.min(drawn, totalLength));
try {
const pt = node.getPointAtLength(clamped);
const angle = getAngleAtLength(node, clamped);
tipEl.attr('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1);
} catch (e) {
// ignore SVG errors
}
if (t >= 1) {
resolve();
return true;
}
});
});
}
function delay(ms) {
return new Promise(resolve => {
if (isCancelled) { resolve(); return; }
const id = setTimeout(resolve, ms);
});
}
async function animateTrip(destCode, destFeature) {
if (!homeFeature || !destFeature) return;
const homeCentroid = d3.geoCentroid(homeFeature);
const destCentroid = d3.geoCentroid(destFeature);
const pts = computeArc(homeCentroid, destCentroid);
if (pts.length < 2) return;
const lineGen = d3.line().curve(d3.curveBasis);
const pathData = lineGen(pts);
if (!pathData) return;
function createArc(pathData) {
const el = g.append('path')
.attr('d', pathData)
.attr('fill', 'none')
.attr('stroke', ARC_COLOR)
.attr('stroke-width', 2.5)
.attr('stroke-opacity', 0.8)
.attr('stroke-linecap', 'round');
const tip = g.append('path')
.attr('d', PLANE_PATH)
.attr('fill', PLANE_COLOR)
.attr('opacity', 0);
return { el, tip };
}
// --- Outbound: home -> dest ---
let { el: outEl, tip: outTip } = createArc(pathData);
const outLen = outEl.node().getTotalLength();
outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen);
const homeDot = g.append('circle')
.attr('r', 4)
.attr('fill', PLANE_COLOR)
.attr('cx', pts[0][0])
.attr('cy', pts[0][1])
.attr('opacity', 1);
await animateStroke(outEl, outTip, outLen, 0, 2500);
if (isCancelled) return;
outEl.remove();
outTip.remove();
homeDot.remove();
// Color the destination country
const targetPath = countryPaths.filter(d => effId(d) === destCode);
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
g.selectAll('.micro-state-j')
.filter(d => effId(d) === destCode)
.transition().duration(500)
.attr('fill', VISITED_COLOR);
await delay(800);
if (isCancelled) return;
// --- Return: dest -> home ---
const revPts = [...pts].reverse();
const revData = d3.line().curve(d3.curveBasis)(revPts);
let { el: retEl, tip: retTip } = createArc(revData);
const retLen = retEl.node().getTotalLength();
retEl.attr('stroke-dasharray', retLen).attr('stroke-dashoffset', retLen);
const destDot = g.append('circle')
.attr('r', 4)
.attr('fill', PLANE_COLOR)
.attr('cx', revPts[0][0])
.attr('cy', revPts[0][1])
.attr('opacity', 1);
await animateStroke(retEl, retTip, retLen, 0, 2200);
if (isCancelled) return;
retEl.remove();
retTip.remove();
destDot.remove();
await delay(300);
}
async function startJourney() {
isPlaying = true;
isFinished = false;
isCancelled = false;
const trips = MOCK_TRIPS;
const total = trips.length;
for (let i = 0; i < total; i++) {
if (isCancelled) break;
const trip = trips[i];
const destFeature = featuresById[trip.countryCode];
if (!destFeature) continue;
const label = `${trip.city}, ${trip.countryName}`;
if (onprogress) onprogress({ index: i + 1, total, label });
await animateTrip(trip.countryCode, destFeature);
}
if (!isCancelled) {
isFinished = true;
isPlaying = false;
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
} else {
isPlaying = false;
}
}
function stopJourney() {
isCancelled = true;
isPlaying = false;
}
onMount(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
projection = d3.geoMercator();
fitProjection(projection, width, height);
pathFn = d3.geoPath().projection(projection);
const countries = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
countries.forEach(f => {
if (!f.id) f.id = 'XK';
});
for (const f of countries) {
featuresById[effId(f)] = f;
}
homeFeature = featuresById[HOME_CODE];
svg = d3.select(frameEl)
.append('svg')
.attr('width', width)
.attr('height', height)
.style('cursor', 'default');
g = svg.append('g');
countryPaths = g.selectAll('path')
.data(countries)
.join('path')
.attr('d', pathFn)
.attr('fill', d => effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
.attr('stroke', '#d4d4d4')
.attr('stroke-width', 0.5);
function renderMicrostates() {
g.selectAll('.micro-state-j').remove();
const threshold = 4;
countryPaths.each(function (d) {
if (effId(d) !== d.id) return;
const { width, height } = this.getBBox();
if (width < threshold && height < threshold) {
const [cx, cy] = pathFn.centroid(d);
g.append('circle')
.attr('class', 'micro-state-j')
.datum(d)
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 2)
.attr('fill', effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
}
});
}
renderMicrostates();
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height);
countryPaths.attr('d', pathFn);
renderMicrostates();
}
});
observer.observe(frameEl);
startJourney();
return () => {
stopJourney();
observer.disconnect();
if (svg) svg.remove();
};
});
</script>
<div bind:this={frameEl} class="journey-frame">
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button>
{#if isFinished}
<div class="done-badge">Journey complete!</div>
{/if}
</div>
<style>
.journey-frame {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background: #a4c8e0;
}
.journey-frame :global(svg) {
display: block;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.55);
color: #fff;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.close-btn:hover {
background: rgba(0,0,0,0.75);
}
.done-badge {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0,0,0,0.65);
color: #fff;
font-family: var(--heading, sans-serif);
font-size: 16px;
padding: 10px 24px;
border-radius: 24px;
white-space: nowrap;
letter-spacing: 0.04em;
}
</style>

View File

@@ -3,19 +3,27 @@
import * as d3 from 'd3'; import * as d3 from 'd3';
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json'; import worldData from 'world-atlas/countries-50m.json';
import { get } from 'svelte/store';
import { journals } from '../stores/journalStore.js';
import airplaneImg from '../../assets/airplane.png';
import trainImg from '../../assets/train.png';
import busImg from '../../assets/bus.png';
import carImg from '../../assets/car.png';
import shipImg from '../../assets/ship.png';
import walkImg from '../../assets/walk.png';
let { onclose, onprogress } = $props(); let { onclose, onprogress } = $props();
const HOME_CODE = '203'; const HOME_CODE = '203';
const MOCK_TRIPS = [ const TRANSPORT_IMG = {
{ countryName: 'Japan', countryCode: '392', date: '2024-03-15', city: 'Tokyo' }, flight: airplaneImg,
{ countryName: 'France', countryCode: '191', date: '2024-06-20', city: 'Paris' }, train: trainImg,
{ countryName: 'Spain', countryCode: '724', date: '2024-09-10', city: 'Barcelona' }, bus: busImg,
{ countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' }, car: carImg,
{ countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' }, ship: shipImg,
{ countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' }, walk: walkImg,
]; };
const HOME_COLOR = '#8b5cf6'; const HOME_COLOR = '#8b5cf6';
const VISITED_COLOR = '#22c55e'; const VISITED_COLOR = '#22c55e';
@@ -128,7 +136,7 @@
}); });
} }
async function animateTrip(destCode, destFeature) { async function animateTrip(destCode, destFeature, transport = 'flight') {
if (!homeFeature || !destFeature) return; if (!homeFeature || !destFeature) return;
const homeCentroid = d3.geoCentroid(homeFeature); const homeCentroid = d3.geoCentroid(homeFeature);
@@ -142,6 +150,10 @@
if (!pathData) return; if (!pathData) return;
const iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg;
const iconSize = 28;
const iconHalf = iconSize / 2;
function createArc(pathData) { function createArc(pathData) {
const el = g.append('path') const el = g.append('path')
.attr('d', pathData) .attr('d', pathData)
@@ -150,9 +162,12 @@
.attr('stroke-width', 2.5) .attr('stroke-width', 2.5)
.attr('stroke-opacity', 0.8) .attr('stroke-opacity', 0.8)
.attr('stroke-linecap', 'round'); .attr('stroke-linecap', 'round');
const tip = g.append('path') const tip = g.append('image')
.attr('d', PLANE_PATH) .attr('href', iconSrc)
.attr('fill', PLANE_COLOR) .attr('width', iconSize)
.attr('height', iconSize)
.attr('x', -iconHalf)
.attr('y', -iconHalf)
.attr('opacity', 0); .attr('opacity', 0);
return { el, tip }; return { el, tip };
} }
@@ -216,7 +231,30 @@
isFinished = false; isFinished = false;
isCancelled = false; isCancelled = false;
const trips = MOCK_TRIPS; // Build name → numeric ID map from loaded features
const nameToId = {};
for (const [id, feat] of Object.entries(featuresById)) {
if (feat.properties?.name) nameToId[feat.properties.name] = id;
}
// Use real journal entries, sorted by date
const entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date));
const trips = entries.length > 0
? entries.map(e => ({
countryName: e.location.country,
countryCode: nameToId[e.location.country] ?? null,
city: e.location.cities[0] ?? e.location.country,
transport: e.transport ?? 'flight',
})).filter(t => t.countryCode)
: [
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight' },
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight' },
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight' },
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight' },
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight' },
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight' },
];
const total = trips.length; const total = trips.length;
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
@@ -229,7 +267,7 @@
const label = `${trip.city}, ${trip.countryName}`; const label = `${trip.city}, ${trip.countryName}`;
if (onprogress) onprogress({ index: i + 1, total, label }); if (onprogress) onprogress({ index: i + 1, total, label });
await animateTrip(trip.countryCode, destFeature); await animateTrip(trip.countryCode, destFeature, trip.transport);
} }
if (!isCancelled) { if (!isCancelled) {
@@ -351,14 +389,14 @@
.close-btn { .close-btn {
position: absolute; position: absolute;
top: 12px; bottom: 24px;
right: 12px; right: 24px;
z-index: 10; z-index: 10;
width: 36px; width: 44px;
height: 36px; height: 44px;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
background: rgba(0,0,0,0.55); background: #8b5cf6;
color: #fff; color: #fff;
font-size: 18px; font-size: 18px;
line-height: 1; line-height: 1;
@@ -366,11 +404,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background 0.15s ease; box-shadow: 0 2px 12px rgba(139, 92, 246, 0.4);
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
} }
.close-btn:hover { .close-btn:hover {
background: rgba(0,0,0,0.75); background: #7c3aed;
box-shadow: 0 4px 18px rgba(139, 92, 246, 0.55);
}
.close-btn:active {
transform: scale(0.92);
} }
.done-badge { .done-badge {

View File

@@ -3,7 +3,7 @@
import * as d3 from 'd3'; import * as d3 from 'd3';
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json'; import worldData from 'world-atlas/countries-50m.json';
import { getSelected, toggle, setTotalCount, getHomeCountryCode } from '../layout/selection.svelte.js'; import { getSelected, setTotalCount } from '../layout/selection.svelte.js';
let { onCountryClick = (_name) => {} } = $props(); let { onCountryClick = (_name) => {} } = $props();
@@ -84,10 +84,9 @@
function updateAllFills() { function updateAllFills() {
const sel = getSelected(); const sel = getSelected();
const hc = getHomeCountryCode();
if (!_paths || !_g) return; if (!_paths || !_g) return;
_paths.attr('fill', d => countryColor(d, sel, hc)); _paths.attr('fill', d => countryColor(d, sel, null));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc)); _g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, null));
} }
$effect(updateAllFills); $effect(updateAllFills);
@@ -123,24 +122,14 @@
.attr('class', 'tooltip') .attr('class', 'tooltip')
.style('display', 'none'); .style('display', 'none');
function updateFill(sel) {
const s = getSelected();
const hc = getHomeCountryCode();
sel.attr('fill', d => countryColor(d, s, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, s, hc));
}
function attachEvents(sel) { function attachEvents(sel) {
sel sel
.on('click', (event, d) => { .on('click', (event, d) => {
toggle(effId(d));
updateFill(d3.select(event.currentTarget));
onCountryClick(d.properties.name); onCountryClick(d.properties.name);
}) })
.on('mouseenter', (event, d) => { .on('mouseenter', (event, d) => {
const s = getSelected(); const s = getSelected();
const hc = getHomeCountryCode(); d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, null));
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, hc));
tooltip.style('display', 'block').text(d.properties.name); tooltip.style('display', 'block').text(d.properties.name);
}) })
.on('mousemove', (event) => { .on('mousemove', (event) => {
@@ -149,8 +138,7 @@
}) })
.on('mouseleave', (event, d) => { .on('mouseleave', (event, d) => {
const s = getSelected(); const s = getSelected();
const hc = getHomeCountryCode(); d3.select(event.currentTarget).attr('fill', countryColor(d, s, null));
d3.select(event.currentTarget).attr('fill', countryColor(d, s, hc));
tooltip.style('display', 'none'); tooltip.style('display', 'none');
}); });
} }
@@ -172,14 +160,13 @@
const { width, height } = this.getBBox(); const { width, height } = this.getBBox();
if (width < threshold && height < threshold) { if (width < threshold && height < threshold) {
const [cx, cy] = path.centroid(d); const [cx, cy] = path.centroid(d);
const hc = getHomeCountryCode();
const c = _g.append('circle') const c = _g.append('circle')
.attr('class', 'micro-state') .attr('class', 'micro-state')
.datum(d) .datum(d)
.attr('cx', cx) .attr('cx', cx)
.attr('cy', cy) .attr('cy', cy)
.attr('r', 2) .attr('r', 2)
.attr('fill', countryColor(d, getSelected(), hc)) .attr('fill', countryColor(d, getSelected(), null))
.attr('stroke', '#94a3b8') .attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5); .attr('stroke-width', 0.5);
attachEvents(c); attachEvents(c);