From ade3f76d02c388df00edf1faa1ca81f9cee9b7bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ho=C3=A0i=20Nh=E1=BB=9B?=
Date: Mon, 15 Jun 2026 22:09:48 +0700
Subject: [PATCH 1/8] fix(map): per-day locality anchoring +
Vietnam-bbox/haversine clamp for accurate venue geocoding & OSRM car routes
Bias each venue to its own day's locality (not one trip centre); reject cross-country mis-geocodes; draw OSRM driving routes through precise stops.
---
components/TripMap.tsx | 250 ++++++++++++++++++-----
services/__tests__/geocoder.test.ts | 62 ++++++
services/__tests__/routing.test.ts | 63 ++++++
services/__tests__/venueResolver.test.ts | 22 +-
services/geocoder.ts | 84 ++++----
services/routing.ts | 60 ++++++
services/venueResolver.ts | 16 ++
7 files changed, 474 insertions(+), 83 deletions(-)
create mode 100644 services/__tests__/geocoder.test.ts
create mode 100644 services/__tests__/routing.test.ts
create mode 100644 services/routing.ts
diff --git a/components/TripMap.tsx b/components/TripMap.tsx
index 9e417c5..c75f5ee 100644
--- a/components/TripMap.tsx
+++ b/components/TripMap.tsx
@@ -1,28 +1,65 @@
import { useEffect, useRef, useState } from 'react';
import type { ItineraryPlan } from '../types';
-import { computeBounds, resolveVenues, type ResolvedVenue } from '../services/venueResolver';
-import { geocodeBatch, geocodeDestination, jitterAround } from '../services/geocoder';
+import { computeBounds, resolveVenues, dayLocality, type ResolvedVenue } from '../services/venueResolver';
+import { geocode, geocodeBatch, geocodeDestination, jitterAround, inVietnamBbox, haversineKm } from '../services/geocoder';
+import { fetchRoadRoute } from '../services/routing';
import { IconMapPin, IconRoute, IconInfo } from './icons';
interface TripMapProps {
itinerary: ItineraryPlan;
}
-const OSM_STYLE = {
+// Reliable RASTER basemap (Carto Voyager). Carto's tile CDN loads in environments where the
+// OpenFreeMap vector tiles did not (the map was rendering blank). Voyager labels Vietnamese places
+// with their local (Vietnamese) name and contains NO "nine-dash line". Any China-imposed island
+// label baked into the tiles is hidden by an opaque sea-coloured mask over the disputed zone
+// (added on map load), and our own Vietnamese sovereignty labels are drawn on top.
+const BASE_STYLE = {
version: 8,
sources: {
- osm: {
+ base: {
type: 'raster',
- tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
+ tiles: [
+ 'https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
+ 'https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
+ 'https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
+ 'https://d.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
+ ],
tileSize: 256,
- attribution: '© OpenStreetMap contributors',
+ attribution: '© OpenStreetMap contributors © CARTO',
},
},
- layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
+ layers: [{ id: 'base', type: 'raster', source: 'base' }],
+};
+
+// Carto Voyager ocean colour — used to mask the disputed zone so it stays seamless sea.
+const SEA_MASK_COLOR = '#cfe1ea';
+
+// East Sea disputed zone (offshore, between Vietnam's coast and the Philippines/Borneo): covers
+// Hoàng Sa (Paracel), the Macclesfield bank, and Trường Sa (Spratly). OSM tags features here with
+// China-imposed administrative names ("Tam Sa"/Sansha, "Quận Nam Sa"/Nansha, Qilianyu, Yongle…),
+// which live in name:vi too — so we HIDE every base label inside this polygon and rely solely on
+// our own Vietnamese sovereignty labels. The box stays offshore (no mainland/coastal city labels).
+const EAST_SEA_DISPUTED = {
+ type: 'Polygon' as const,
+ coordinates: [[
+ [111.0, 7.0],
+ [117.5, 7.0],
+ [117.5, 17.5],
+ [111.0, 17.5],
+ [111.0, 7.0],
+ ]],
};
const DAY_COLORS = ['#14b8a6', '#f97316', '#a855f7', '#3b82f6', '#ef4444', '#10b981', '#f59e0b'];
+// Max distance (km) a geocoded venue may sit from the trip's destination centre and still be
+// treated as a precise location. Wide enough for regional multi-city trips (HCM↔Kiên Giang ≈ 260 km,
+// including a real travel-leg departure point) yet far below the ~1200 km mis-geocodes that
+// previously drew lines from the south up to Hà Nội. Per-day anchoring (below) does the fine
+// accuracy work via bias; this radius is just the safety net against gross cross-country errors.
+const MAX_TRIP_RADIUS_KM = 450;
+
export function TripMap({ itinerary }: TripMapProps) {
const containerRef = useRef(null);
const mapRef = useRef(null);
@@ -44,25 +81,41 @@ export function TripMap({ itinerary }: TripMapProps) {
setGeocoding(true);
setGeocodeProgress({ done: 0, total: missing.length });
- const [destResult, batchResults] = await Promise.all([
- geocodeDestination(itinerary.destination),
- geocodeBatch(
- missing.map((v) => ({ venue: v.name, destination: itinerary.destination })),
- (done, total) => {
- if (!cancelled) setGeocodeProgress({ done, total });
- },
- ),
- ]);
+ // Geocode the trip destination FIRST as an overall fallback bias/centre.
+ const destResult = await geocodeDestination(itinerary.destination);
+ if (cancelled) return;
+ const destCenter = destResult ? { lat: destResult.lat, lng: destResult.lng } : undefined;
+
+ // Then geocode EACH day's own locality (from its title, e.g. "Hà Tiên", "Quần đảo Nam Du") to
+ // get a per-day anchor. Anchoring a venue to its own town — instead of one trip-wide centre —
+ // makes the bias far stronger and lets us reject venues that mis-geocode out of the day's area.
+ const dayAnchors = new Map();
+ const dayNums = Array.from(new Set(missing.map((v) => v.day)));
+ await Promise.all(
+ dayNums.map(async (dayNum) => {
+ const locality = dayLocality(itinerary.timeline[dayNum - 1]?.title);
+ if (!locality) return;
+ const r = await geocode(locality, itinerary.destination, destCenter);
+ if (cancelled || !r || !inVietnamBbox(r)) return;
+ // Reject a day anchor that itself mis-geocoded far from the trip centre.
+ if (destCenter && haversineKm(r, destCenter) > MAX_TRIP_RADIUS_KM) return;
+ dayAnchors.set(dayNum, { lat: r.lat, lng: r.lng });
+ }),
+ );
if (cancelled) return;
- const MAX_VENUE_DISTANCE_KM = 60;
- const KM_PER_DEG_LAT = 111.32;
- const haversineKm = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) => {
- const dLat = (a.lat - b.lat) * KM_PER_DEG_LAT;
- const meanLat = ((a.lat + b.lat) / 2) * (Math.PI / 180);
- const dLng = (a.lng - b.lng) * KM_PER_DEG_LAT * Math.cos(meanLat);
- return Math.sqrt(dLat * dLat + dLng * dLng);
- };
+ const batchResults = await geocodeBatch(
+ missing.map((v) => ({
+ venue: v.name,
+ destination: itinerary.destination,
+ bias: dayAnchors.get(v.day) ?? destCenter,
+ })),
+ (done, total) => {
+ if (!cancelled) setGeocodeProgress({ done, total });
+ },
+ destCenter,
+ );
+ if (cancelled) return;
let fallbackIndex = 0;
const enriched = initial.map((v) => {
@@ -70,19 +123,21 @@ export function TripMap({ itinerary }: TripMapProps) {
const key = `${v.name.toLowerCase().trim()}|${itinerary.destination.toLowerCase().trim()}`;
const hit = batchResults.get(key);
- if (hit && destResult) {
- const dist = haversineKm({ lat: hit.lat, lng: hit.lng }, { lat: destResult.lat, lng: destResult.lng });
- if (dist > MAX_VENUE_DISTANCE_KM) {
- const j = jitterAround({ lat: destResult.lat, lng: destResult.lng }, fallbackIndex++);
- return { ...v, lat: j.lat, lng: j.lng, approximate: true };
- }
+ // The per-day anchor already biased this lookup to the right town, so most hits are accurate.
+ // Accept a hit if it is inside Vietnam and within the trip radius of the destination centre.
+ // We clamp to the TRIP centre (not the tight day centre) so a genuine travel-leg point — e.g.
+ // the HCM departure "Bến xe Miền Tây", 225 km from the Hà Tiên day — stays precise. Only gross
+ // cross-country mis-geocodes (e.g. → Hà Nội, 1260 km) are rejected here.
+ const near = !destCenter || (hit != null && haversineKm(hit, destCenter) <= MAX_TRIP_RADIUS_KM);
+ if (hit && inVietnamBbox(hit) && near) {
return { ...v, lat: hit.lat, lng: hit.lng };
}
- if (hit) return { ...v, lat: hit.lat, lng: hit.lng };
-
- if (destResult) {
- const j = jitterAround({ lat: destResult.lat, lng: destResult.lng }, fallbackIndex++);
+ // No usable hit → estimate around the day's own anchor (better than the trip centre: a
+ // failed Hà Tiên stop lands near Hà Tiên, not at the trip's geographic midpoint).
+ const anchor = dayAnchors.get(v.day) ?? destCenter;
+ if (anchor) {
+ const j = jitterAround(anchor, fallbackIndex++);
return { ...v, lat: j.lat, lng: j.lng, approximate: true };
}
return v;
@@ -116,6 +171,9 @@ export function TripMap({ itinerary }: TripMapProps) {
addSource: (id: string, src: unknown) => void;
addLayer: (layer: unknown) => void;
getSource: (id: string) => unknown;
+ getStyle: () => { layers?: Array<{ id: string; type: string; layout?: Record; filter?: unknown }> } | undefined;
+ setLayoutProperty: (layerId: string, name: string, value: unknown) => void;
+ setFilter: (layerId: string, filter: unknown) => void;
};
const mod = (await import('maplibre-gl')) as unknown as {
default: {
@@ -132,7 +190,7 @@ export function TripMap({ itinerary }: TripMapProps) {
const map = new mod.default.Map({
container: containerRef.current,
- style: OSM_STYLE,
+ style: BASE_STYLE,
center: [center.lng, center.lat],
zoom: bounds ? 10 : 5,
attributionControl: true,
@@ -145,23 +203,59 @@ export function TripMap({ itinerary }: TripMapProps) {
map.on('load', () => {
if (cancelled) return;
+ // Mask the East-Sea disputed zone with the ocean colour so any China-imposed island label
+ // baked into the raster tiles (e.g. "Tam Sa", "Quận Nam Sa") is covered. The box is entirely
+ // offshore, so the mask reads as seamless sea; our Vietnamese sovereignty labels sit on top.
+ try {
+ map.addSource('disputed-mask', {
+ type: 'geojson',
+ data: { type: 'Feature', properties: {}, geometry: EAST_SEA_DISPUTED },
+ });
+ map.addLayer({
+ id: 'disputed-mask',
+ type: 'fill',
+ source: 'disputed-mask',
+ paint: { 'fill-color': SEA_MASK_COLOR, 'fill-opacity': 1 },
+ });
+ } catch (err) {
+ console.warn('[TripMap] could not add disputed-zone mask', err);
+ }
+
const byDay = new Map();
for (const v of located) {
if (!byDay.has(v.day)) byDay.set(v.day, []);
byDay.get(v.day)!.push(v);
}
- const features: object[] = [];
+ type RouteFeature = {
+ type: 'Feature';
+ properties: { day: number; color: string; kind: 'road' | 'estimate' };
+ geometry: { type: 'LineString'; coordinates: [number, number][] };
+ };
+ const features: RouteFeature[] = [];
+ const roadDays: Array<{ day: number; points: { lat: number; lng: number }[] }> = [];
+
for (const [day, dayVenues] of byDay) {
if (dayVenues.length < 2) continue;
- features.push({
- type: 'Feature',
- properties: { day, color: DAY_COLORS[(day - 1) % DAY_COLORS.length] },
- geometry: {
- type: 'LineString',
- coordinates: dayVenues.map((v) => [v.lng as number, v.lat as number]),
- },
- });
+ const color = DAY_COLORS[(day - 1) % DAY_COLORS.length];
+ // Snap to real roads through the PRECISELY-located stops (skip jittered "approximate"
+ // ones). Only when a day has fewer than 2 precise stops do we fall back to a dashed
+ // straight line. This still draws a real car route even if a few stops couldn't geocode.
+ const precise = dayVenues.filter((v) => !v.approximate && v.lat != null && v.lng != null);
+ if (precise.length >= 2) {
+ features.push({
+ type: 'Feature',
+ properties: { day, color, kind: 'road' },
+ geometry: { type: 'LineString', coordinates: precise.map((v) => [v.lng as number, v.lat as number] as [number, number]) },
+ });
+ roadDays.push({ day, points: precise.map((v) => ({ lat: v.lat as number, lng: v.lng as number })) });
+ } else {
+ features.push({
+ type: 'Feature',
+ properties: { day, color, kind: 'estimate' },
+ geometry: { type: 'LineString', coordinates: dayVenues.map((v) => [v.lng as number, v.lat as number] as [number, number]) },
+ });
+ }
}
if (features.length > 0) {
@@ -169,17 +263,55 @@ export function TripMap({ itinerary }: TripMapProps) {
type: 'geojson',
data: { type: 'FeatureCollection', features },
});
+ // Estimated days (some stops only approximately located): dashed straight line.
map.addLayer({
- id: 'routes-line',
+ id: 'routes-estimate',
type: 'line',
source: 'routes',
+ filter: ['==', ['get', 'kind'], 'estimate'],
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-color': ['get', 'color'],
'line-width': 3,
- 'line-opacity': 0.75,
- 'line-dasharray': [2, 1.5],
+ 'line-opacity': 0.6,
+ 'line-dasharray': [2, 1.6],
},
});
+ // Precise days: solid line, upgraded from straight to the real road geometry once OSRM responds.
+ map.addLayer({
+ id: 'routes-road',
+ type: 'line',
+ source: 'routes',
+ filter: ['==', ['get', 'kind'], 'road'],
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
+ paint: {
+ 'line-color': ['get', 'color'],
+ 'line-width': 4,
+ 'line-opacity': 0.85,
+ },
+ });
+
+ // Progressive enhancement: snap each precise day's line to the actual road network.
+ if (roadDays.length > 0) {
+ void (async () => {
+ let changed = false;
+ for (const rd of roadDays) {
+ const road = await fetchRoadRoute(rd.points);
+ if (cancelled) return;
+ if (road && road.length >= 2) {
+ const f = features.find((ft) => ft.properties.day === rd.day);
+ if (f) {
+ f.geometry.coordinates = road;
+ changed = true;
+ }
+ }
+ }
+ if (changed && !cancelled) {
+ const src = map.getSource('routes') as { setData?: (d: unknown) => void } | undefined;
+ src?.setData?.({ type: 'FeatureCollection', features });
+ }
+ })();
+ }
}
for (let i = 0; i < located.length; i++) {
@@ -212,6 +344,30 @@ export function TripMap({ itinerary }: TripMapProps) {
markers.push(marker);
}
+ // Chủ quyền Việt Nam: luôn hiển thị Hoàng Sa & Trường Sa trên bản đồ.
+ // Tile nền (OSM) gán nhãn tiếng Anh/trung lập, nên ta phủ nhãn tiếng Việt cố định
+ // tại đúng toạ độ để bản đồ luôn thể hiện hai quần đảo thuộc Việt Nam.
+ const SOVEREIGNTY: { name: string; lngLat: [number, number] }[] = [
+ { name: 'Quần đảo Hoàng Sa (Việt Nam)', lngLat: [112.0, 16.5] },
+ { name: 'Quần đảo Trường Sa (Việt Nam)', lngLat: [113.8, 9.6] },
+ ];
+ for (const s of SOVEREIGNTY) {
+ const wrap = document.createElement('div');
+ wrap.className = 'flex flex-col items-center select-none';
+ wrap.style.pointerEvents = 'none';
+ const dot = document.createElement('div');
+ dot.style.cssText =
+ 'width:9px;height:9px;border-radius:50%;background:#ef4444;border:2px solid #ffffff;box-shadow:0 0 6px rgba(0,0,0,.55);';
+ const label = document.createElement('div');
+ label.textContent = s.name;
+ label.style.cssText =
+ 'margin-top:3px;font-size:10px;font-weight:700;color:#ffffff;background:rgba(220,38,38,.88);padding:1px 6px;border-radius:6px;white-space:nowrap;text-shadow:0 1px 2px rgba(0,0,0,.6);';
+ wrap.appendChild(dot);
+ wrap.appendChild(label);
+ const sMarker = new mod.default.Marker({ element: wrap }).setLngLat(s.lngLat).addTo(map);
+ markers.push(sMarker);
+ }
+
if (bounds && located.length > 1) {
map.fitBounds(
[
diff --git a/services/__tests__/geocoder.test.ts b/services/__tests__/geocoder.test.ts
new file mode 100644
index 0000000..d5185da
--- /dev/null
+++ b/services/__tests__/geocoder.test.ts
@@ -0,0 +1,62 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { geocode, inVietnamBbox, haversineKm } from '../geocoder';
+
+describe('inVietnamBbox', () => {
+ it('accepts real Vietnamese points (incl. multi-city + islands)', () => {
+ expect(inVietnamBbox({ lat: 10.7737, lng: 106.7166 })).toBe(true); // Hồ Chí Minh
+ expect(inVietnamBbox({ lat: 10.3644, lng: 104.4435 })).toBe(true); // Mũi Nai, Hà Tiên
+ expect(inVietnamBbox({ lat: 9.9949, lng: 105.0917 })).toBe(true); // Rạch Giá
+ expect(inVietnamBbox({ lat: 9.68, lng: 104.36 })).toBe(true); // Nam Du
+ expect(inVietnamBbox({ lat: 21.0285, lng: 105.8542 })).toBe(true); // Hà Nội
+ });
+ it('rejects foreign points', () => {
+ expect(inVietnamBbox({ lat: 48.8566, lng: 2.3522 })).toBe(false); // Paris
+ expect(inVietnamBbox({ lat: 13.7563, lng: 100.5018 })).toBe(false); // Bangkok
+ expect(inVietnamBbox({ lat: 1.3521, lng: 103.8198 })).toBe(false); // Singapore (below lat 8)
+ });
+});
+
+describe('haversineKm', () => {
+ const KIEN_GIANG = { lat: 9.767, lng: 104.473 };
+ it('measures regional multi-city distances as a few hundred km', () => {
+ // Real Kiên Giang-trip venues sit close to the destination centre.
+ expect(haversineKm(KIEN_GIANG, { lat: 10.378, lng: 104.489 })).toBeLessThan(120); // Hà Tiên
+ expect(haversineKm(KIEN_GIANG, { lat: 10.018, lng: 105.092 })).toBeLessThan(120); // Rạch Giá
+ expect(haversineKm(KIEN_GIANG, { lat: 10.741, lng: 106.619 })).toBeLessThan(450); // HCM (~260 km)
+ });
+ it('flags cross-country mis-geocodes as far beyond the trip radius', () => {
+ // "Khách sạn River Hà Tiên" mis-resolving to Hà Nội / Quảng Ninh — must exceed 450 km.
+ expect(haversineKm(KIEN_GIANG, { lat: 21.028, lng: 105.858 })).toBeGreaterThan(450); // Hà Nội
+ expect(haversineKm(KIEN_GIANG, { lat: 20.951, lng: 107.084 })).toBeGreaterThan(450); // Quảng Ninh
+ });
+});
+
+describe('geocode Photon request', () => {
+ beforeEach(() => {
+ try { localStorage.clear(); } catch { /* ignore */ }
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it('uses lang=en (never the unsupported lang=vi) and applies the bias', async () => {
+ const urls: string[] = [];
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ urls.push(String(url));
+ return {
+ ok: true,
+ json: async () => ({
+ features: [{ geometry: { coordinates: [108.2279, 16.0612] }, properties: { name: 'Cầu Rồng', city: 'Đà Nẵng', country: 'Vietnam' } }],
+ }),
+ };
+ }) as unknown as typeof fetch);
+
+ const r = await geocode('Cầu Rồng', 'Đà Nẵng', { lat: 16.05, lng: 108.2 });
+ expect(r).toMatchObject({ lat: 16.0612, lng: 108.2279 });
+
+ const photonUrl = urls.find((u) => u.includes('photon.komoot.io'));
+ expect(photonUrl).toBeDefined();
+ expect(photonUrl).toContain('lang=en');
+ expect(photonUrl).not.toContain('lang=vi');
+ expect(photonUrl).toContain('lat=16.05');
+ expect(photonUrl).toContain('lon=108.2');
+ });
+});
diff --git a/services/__tests__/routing.test.ts b/services/__tests__/routing.test.ts
new file mode 100644
index 0000000..130e579
--- /dev/null
+++ b/services/__tests__/routing.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { buildOsrmUrl, fetchRoadRoute } from '../routing';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('buildOsrmUrl', () => {
+ it('encodes waypoints as lng,lat pairs joined by ";" (OSRM order, not lat,lng)', () => {
+ const url = buildOsrmUrl([
+ { lat: 16.07, lng: 108.22 },
+ { lat: 16.0, lng: 108.25 },
+ ]);
+ expect(url).toContain('/driving/108.22,16.07;108.25,16');
+ expect(url).toContain('overview=full');
+ expect(url).toContain('geometries=geojson');
+ });
+});
+
+describe('fetchRoadRoute', () => {
+ it('returns the road geometry coordinates on success', async () => {
+ const coords: [number, number][] = [
+ [108.22, 16.07],
+ [108.23, 16.05],
+ [108.25, 16.0],
+ ];
+ vi.stubGlobal('fetch', vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ code: 'Ok', routes: [{ geometry: { coordinates: coords } }] }),
+ })) as unknown as typeof fetch);
+
+ const out = await fetchRoadRoute([
+ { lat: 16.07, lng: 108.22 },
+ { lat: 16.0, lng: 108.25 },
+ ]);
+ expect(out).toEqual(coords);
+ });
+
+ it('returns null when fewer than 2 points', async () => {
+ const f = vi.fn();
+ vi.stubGlobal('fetch', f as unknown as typeof fetch);
+ expect(await fetchRoadRoute([{ lat: 16, lng: 108 }])).toBeNull();
+ expect(f).not.toHaveBeenCalled();
+ });
+
+ it('returns null on a non-ok response', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })) as unknown as typeof fetch);
+ const out = await fetchRoadRoute([
+ { lat: 21.05, lng: 105.8 },
+ { lat: 21.0, lng: 105.85 },
+ ]);
+ expect(out).toBeNull();
+ });
+
+ it('returns null when fetch throws', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network'); }) as unknown as typeof fetch);
+ const out = await fetchRoadRoute([
+ { lat: 10.77, lng: 106.7 },
+ { lat: 10.78, lng: 106.72 },
+ ]);
+ expect(out).toBeNull();
+ });
+});
diff --git a/services/__tests__/venueResolver.test.ts b/services/__tests__/venueResolver.test.ts
index d87f361..aebf0b4 100644
--- a/services/__tests__/venueResolver.test.ts
+++ b/services/__tests__/venueResolver.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { buildTiktokQuery, computeBounds, resolveVenues } from '../venueResolver';
+import { buildTiktokQuery, computeBounds, resolveVenues, dayLocality } from '../venueResolver';
import type { ItineraryPlan } from '../../types';
const sampleItinerary: ItineraryPlan = {
@@ -82,6 +82,26 @@ describe('computeBounds', () => {
});
});
+describe('dayLocality', () => {
+ it('extracts the locality before a tagline separator', () => {
+ expect(dayLocality('Hà Tiên - Chốn non nước hữu tình')).toBe('Hà Tiên');
+ expect(dayLocality('Quần đảo Nam Du – Biển xanh gọi mời')).toBe('Quần đảo Nam Du');
+ expect(dayLocality('Rạch Giá: về lại đất liền')).toBe('Rạch Giá');
+ });
+ it('strips a leading "Ngày N" prefix', () => {
+ expect(dayLocality('Ngày 1: Đà Lạt mộng mơ')).toBe('Đà Lạt mộng mơ');
+ expect(dayLocality('Ngày 2 - Hội An')).toBe('Hội An');
+ });
+ it('returns the whole title when there is no separator', () => {
+ expect(dayLocality('Hà Nội')).toBe('Hà Nội');
+ });
+ it('returns null for empty/missing titles', () => {
+ expect(dayLocality(undefined)).toBeNull();
+ expect(dayLocality('')).toBeNull();
+ expect(dayLocality(' ')).toBeNull();
+ });
+});
+
describe('buildTiktokQuery', () => {
it('builds URL-encoded search', () => {
const q = buildTiktokQuery('Cà phê 6am', 'Đà Lạt');
diff --git a/services/geocoder.ts b/services/geocoder.ts
index 5ffb2d8..5a389a5 100644
--- a/services/geocoder.ts
+++ b/services/geocoder.ts
@@ -59,6 +59,13 @@ function cacheKey(query: string, destination: string): string {
return `${query.toLowerCase().trim()}|${destination.toLowerCase().trim()}`;
}
+function cacheResult(key: string, result: GeocodeResult): GeocodeResult {
+ const fresh = readCache();
+ fresh[key] = { ...result, ts: Date.now() };
+ writeCache(fresh);
+ return result;
+}
+
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -127,9 +134,12 @@ function buildQueryCandidates(venue: string, destination: string): string[] {
return Array.from(new Set(candidates.filter(Boolean)));
}
-async function tryPhoton(query: string, osmTag?: string): Promise {
+async function tryPhoton(query: string, osmTag?: string, bias?: { lat: number; lng: number }): Promise {
const tagParam = osmTag ? `&osm_tag=${encodeURIComponent(osmTag)}` : '';
- const url = `${PHOTON_BASE}?q=${encodeURIComponent(query)}&limit=1&lang=vi${tagParam}`;
+ // Photon only supports lang = default|de|en|fr (lang=vi → HTTP 400 broke every lookup). The optional
+ // lat/lon bias (the trip's destination centre) disambiguates same-named places (e.g. "Nam Du").
+ const biasParam = bias ? `&lat=${bias.lat}&lon=${bias.lng}` : '';
+ const url = `${PHOTON_BASE}?q=${encodeURIComponent(query)}&limit=1&lang=en${tagParam}${biasParam}`;
try {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
@@ -183,7 +193,28 @@ function osmTagForVenue(venue: string): string | undefined {
return undefined;
}
-export async function geocode(venue: string, destination: string): Promise {
+/** Vietnam bounding box (incl. offshore islands) — used to reject foreign/wrong geocode matches. */
+export function inVietnamBbox(p: { lat: number; lng: number }): boolean {
+ return p.lat >= 8 && p.lat <= 24 && p.lng >= 102 && p.lng <= 118;
+}
+
+/** Great-circle distance in km between two lat/lng points. */
+export function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
+ const R = 6371;
+ const toRad = (d: number) => (d * Math.PI) / 180;
+ const dLat = toRad(b.lat - a.lat);
+ const dLng = toRad(b.lng - a.lng);
+ const s =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
+ return R * 2 * Math.asin(Math.sqrt(s));
+}
+
+export async function geocode(
+ venue: string,
+ destination: string,
+ bias?: { lat: number; lng: number },
+): Promise {
const trimmed = venue.trim();
if (!trimmed) return null;
@@ -204,34 +235,19 @@ export async function geocode(venue: string, destination: string): Promise,
+ items: Array<{ venue: string; destination: string; bias?: { lat: number; lng: number } }>,
onProgress?: (done: number, total: number) => void,
+ fallbackBias?: { lat: number; lng: number },
): Promise
+ ) : !webglOk || contextLost ? (
+
) : (
- Đang tải 3D…}>
-
-
+ }>
+ Đang tải 3D…}>
+ setContextLost(true)}
+ />
+
+
)}
diff --git a/components/three/NatureScene.tsx b/components/three/NatureScene.tsx
index 8026a94..ffc15f9 100644
--- a/components/three/NatureScene.tsx
+++ b/components/three/NatureScene.tsx
@@ -2,6 +2,8 @@ import { useRef, useMemo, useEffect, useState } from 'react';
import * as THREE from 'three';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Stars, MeshReflectorMaterial, AdaptiveDpr } from '@react-three/drei';
+import { EffectComposer, Bloom } from '@react-three/postprocessing';
+import { useReducedMotion, PauseOnHidden, RenderCountProbe } from './sceneHelpers';
// ─── Inline simplex noise (2D) ───────────────────────────────────────────────
const GRAD3 = [
@@ -216,10 +218,12 @@ function getSkyColors(): SkyColors {
return blendSky(SKY_DUSK, SKY_NIGHT, (hour - 20.5) / 1.5);
}
-function lerpColor(a: string, b: string, t: number): THREE.Color {
- const ca = new THREE.Color(a);
- const cb = new THREE.Color(b);
- return ca.lerp(cb, t);
+// In-place colour lerp: writes a→b interpolation into `out` (no per-call alloc).
+// `scratch` holds the `b` colour so callers can pass module-level scratch refs.
+function lerpColorInto(out: THREE.Color, scratch: THREE.Color, a: string, b: string, t: number): void {
+ out.set(a);
+ scratch.set(b);
+ out.lerp(scratch, t);
}
// ─── Gradient Sky Sphere (5-point) ──────────────────────────────────────────
@@ -283,15 +287,17 @@ function GradientSkySphere({ zenith, midSky, horizon, lowHorizon, nadir }: {
uNadir: { value: new THREE.Color(nadir) },
}), []);
- // Smooth interpolation every frame for gentle transitions
+ // Smooth interpolation every frame for gentle transitions.
+ // Scratch colour reused for all five targets — zero allocation per frame.
+ const scratch = useMemo(() => new THREE.Color(), []);
useFrame(() => {
if (!matRef.current) return;
const u = matRef.current.uniforms;
- u.uZenith.value.lerp(new THREE.Color(zenith), 0.015);
- u.uMidSky.value.lerp(new THREE.Color(midSky), 0.015);
- u.uHorizon.value.lerp(new THREE.Color(horizon), 0.015);
- u.uLowHorizon.value.lerp(new THREE.Color(lowHorizon), 0.015);
- u.uNadir.value.lerp(new THREE.Color(nadir), 0.015);
+ u.uZenith.value.lerp(scratch.set(zenith), 0.015);
+ u.uMidSky.value.lerp(scratch.set(midSky), 0.015);
+ u.uHorizon.value.lerp(scratch.set(horizon), 0.015);
+ u.uLowHorizon.value.lerp(scratch.set(lowHorizon), 0.015);
+ u.uNadir.value.lerp(scratch.set(nadir), 0.015);
});
return (
@@ -309,34 +315,39 @@ function GradientSkySphere({ zenith, midSky, horizon, lowHorizon, nadir }: {
);
}
// ─── Camera Rig: slow orbit + mouse parallax ────────────────────────────────
-function CameraRig() {
+const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
+
+function CameraRig({ reduce }: { reduce: boolean }) {
const { camera } = useThree();
const mouse = useRef({ x: 0, y: 0 });
const angle = useRef(0);
useEffect(() => {
+ if (reduce) return;
const onMove = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth - 0.5) * 2;
mouse.current.y = (e.clientY / window.innerHeight - 0.5) * 2;
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
- }, []);
+ }, [reduce]);
useFrame((_, delta) => {
- angle.current += delta * 0.03;
- const radius = 22;
+ // Lower, tighter rig so the horizon sits in the lower third of the frame.
+ if (!reduce) angle.current += delta * 0.03;
+ const radius = 18;
const baseX = Math.sin(angle.current) * radius;
const baseZ = Math.cos(angle.current) * radius;
- const baseY = 7;
+ const baseY = 4.2;
- const parallaxX = mouse.current.x * 3;
- const parallaxY = -mouse.current.y * 1.5;
+ // Clamp parallax so the camera never swings past the framing.
+ const parallaxX = reduce ? 0 : clamp(mouse.current.x, -1, 1) * 2.2;
+ const parallaxY = reduce ? 0 : clamp(-mouse.current.y, -1, 1) * 1.1;
camera.position.x += (baseX + parallaxX - camera.position.x) * 0.02;
camera.position.y += (baseY + parallaxY - camera.position.y) * 0.02;
camera.position.z += (baseZ - camera.position.z) * 0.02;
- camera.lookAt(0, 1, 0);
+ camera.lookAt(0, 2.4, 0);
});
return null;
@@ -431,8 +442,9 @@ function WaterPlane() {
}
// ─── Firefly Particles ───────────────────────────────────────────────────────
-function Fireflies({ brightness }: { brightness: number }) {
+function Fireflies({ brightness, daylight, reduce }: { brightness: number; daylight: number; reduce: boolean }) {
const pointsRef = useRef(null);
+ const frame = useRef(0);
const positions = useMemo(() => {
const count = 200;
@@ -447,6 +459,11 @@ function Fireflies({ brightness }: { brightness: number }) {
useFrame(({ clock }) => {
if (!pointsRef.current) return;
+ // Static under reduced motion; skip the costly buffer update when daylight is
+ // bright (fireflies are near-invisible) and throttle to every 2nd frame.
+ if (reduce || daylight > 0.7) return;
+ frame.current++;
+ if (frame.current % 2 !== 0) return;
const pos = pointsRef.current.geometry.attributes.position;
const time = clock.elapsedTime;
for (let i = 0; i < pos.count; i++) {
@@ -483,29 +500,33 @@ function DynamicLighting({ daylight }: { daylight: number }) {
const fillRef = useRef(null);
const hemiRef = useRef(null);
+ // Scratch colours — one persistent pair reused for every in-place lerp.
+ const out = useMemo(() => new THREE.Color(), []);
+ const tmp = useMemo(() => new THREE.Color(), []);
+
useFrame(() => {
// Ambient: bright white during day, dim blue at night
if (ambientRef.current) {
ambientRef.current.intensity = 0.15 + daylight * 0.5;
- ambientRef.current.color.copy(lerpColor('#1a2040', '#ffffff', daylight));
+ lerpColorInto(ambientRef.current.color, tmp, '#1a2040', '#ffffff', daylight);
}
// Sun / Moon directional
if (sunRef.current) {
sunRef.current.intensity = 0.3 + daylight * 1.2;
- sunRef.current.color.copy(lerpColor('#4466aa', '#fff5e6', daylight));
+ lerpColorInto(sunRef.current.color, tmp, '#4466aa', '#fff5e6', daylight);
// Sun high during day, low at night
sunRef.current.position.set(50, 10 + daylight * 60, 30);
}
// Fill light
if (fillRef.current) {
fillRef.current.intensity = 0.15 + daylight * 0.35;
- fillRef.current.color.copy(lerpColor('#223355', '#cce0ff', daylight));
+ lerpColorInto(fillRef.current.color, tmp, '#223355', '#cce0ff', daylight);
}
// Hemisphere: sky + ground
if (hemiRef.current) {
hemiRef.current.intensity = 0.2 + daylight * 0.5;
- hemiRef.current.color.copy(lerpColor('#0a1628', '#87ceeb', daylight));
- hemiRef.current.groundColor.copy(lerpColor('#0a1210', '#4a9e3f', daylight));
+ lerpColorInto(hemiRef.current.color, tmp, '#0a1628', '#87ceeb', daylight);
+ lerpColorInto(hemiRef.current.groundColor, out, '#0a1210', '#4a9e3f', daylight);
}
});
@@ -522,18 +543,21 @@ function DynamicLighting({ daylight }: { daylight: number }) {
// ─── Dynamic Fog ─────────────────────────────────────────────────────────────
function DynamicFog({ fogColor }: { fogColor: string }) {
const fogRef = useRef(null);
+ const scratch = useMemo(() => new THREE.Color(), []);
useFrame(() => {
if (fogRef.current) {
- fogRef.current.color.lerp(new THREE.Color(fogColor), 0.02);
+ fogRef.current.color.lerp(scratch.set(fogColor), 0.02);
}
});
- return ;
+ // Near plane pulled in (≈12) so distant terrain recedes into the daylight haze.
+ return ;
}
// ─── Scene Content ─────────────────────────────────────────────────────────────
function SceneContent() {
+ const reduce = useReducedMotion();
const [sky, setSky] = useState(getSkyColors);
// Recalculate every 30 seconds for smooth time transitions
@@ -546,7 +570,9 @@ function SceneContent() {
return (
<>
-
+
+
+
@@ -562,13 +588,18 @@ function SceneContent() {
factor={5}
saturation={0.3}
fade
- speed={0.3}
+ speed={reduce ? 0 : 0.3}
/>
{/* Landscape */}
-
+
+
+ {/* Bloom lifts emissive highlights (water sheen, firefly glow) into real light. */}
+
+
+
>
);
}
@@ -588,7 +619,12 @@ export function NatureScene() {
zIndex: 0,
pointerEvents: 'none',
}}
- gl={{ antialias: false, alpha: false }}
+ gl={{
+ antialias: false,
+ alpha: false,
+ toneMapping: THREE.ACESFilmicToneMapping,
+ toneMappingExposure: 1.1,
+ }}
>
diff --git a/components/three/PersonalWorldCanvas.tsx b/components/three/PersonalWorldCanvas.tsx
index 820c8e2..f83ec4e 100644
--- a/components/three/PersonalWorldCanvas.tsx
+++ b/components/three/PersonalWorldCanvas.tsx
@@ -1,8 +1,10 @@
-import { useMemo, useState } from 'react';
+import { useMemo } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stars, Sparkles, Float, GradientTexture, Line } from '@react-three/drei';
-import { BackSide, AdditiveBlending } from 'three';
+import { EffectComposer, Bloom } from '@react-three/postprocessing';
+import { BackSide, AdditiveBlending, ACESFilmicToneMapping } from 'three';
import { PersonalWorldMonuments } from './PersonalWorldMonuments';
+import { useReducedMotion, PauseOnHidden, RenderCountProbe } from './sceneHelpers';
import type { Monument } from '../../services/personalWorldScene';
interface PersonalWorldCanvasProps {
@@ -11,34 +13,43 @@ interface PersonalWorldCanvasProps {
tripCount: number;
}
-function prefersReducedMotion(): boolean {
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-}
-
-export default function PersonalWorldCanvas({ monuments, ringRadius, tripCount }: PersonalWorldCanvasProps) {
- const [reduce] = useState(prefersReducedMotion);
+export default function PersonalWorldCanvas({ monuments, ringRadius, tripCount, onContextLost }: PersonalWorldCanvasProps & { onContextLost?: () => void }) {
+ const reduce = useReducedMotion();
const camDist = ringRadius * 1.85 + 2;
return (
);
}
@@ -113,7 +129,7 @@ function JourneyArcs({ monuments }: { monuments: Monument[] }) {
return (
{arcs.map((a) => (
-
+
))}
);
diff --git a/components/three/PersonalWorldMonuments.tsx b/components/three/PersonalWorldMonuments.tsx
index 0ad3e4e..dac0e04 100644
--- a/components/three/PersonalWorldMonuments.tsx
+++ b/components/three/PersonalWorldMonuments.tsx
@@ -1,4 +1,4 @@
-import { Float, Html } from '@react-three/drei';
+import { Float, Html, Instances, Instance } from '@react-three/drei';
import { AdditiveBlending, DoubleSide } from 'three';
import type { Monument } from '../../services/personalWorldScene';
@@ -22,6 +22,32 @@ const ACCENT_BY_KIND: Record = {
export function PersonalWorldMonuments({ monuments, reduceMotion }: PersonalWorldMonumentsProps) {
return (
+ {/* Shared-geometry instancing for the repeated mound + glow ring (1 draw call each). */}
+
+
+
+ {monuments.map((m) => (
+
+ ))}
+
+
+
+
+ {monuments.map((m) => (
+
+ ))}
+
+
{monuments.map((m) => (
))}
@@ -35,17 +61,7 @@ function MonumentNode({ monument, reduceMotion }: { monument: Monument; reduceMo
return (
- {/* grassy mound the model sits on */}
-
-
-
-
- {/* glowing base ring */}
-
-
-
-
-
+ {/* mound + glow ring rendered via the shared above */}
@@ -141,7 +157,7 @@ function MonumentModel({ kind, accent }: { kind: string; accent: string }) {
-
+
{[
{ y: 0.42, r: 0.62, h: 0.26 },
@@ -152,12 +168,12 @@ function MonumentModel({ kind, accent }: { kind: string; accent: string }) {
{i > 0 && (
-
+
)}
-
+
))}
@@ -174,14 +190,14 @@ function MonumentModel({ kind, accent }: { kind: string; accent: string }) {
{/* string */}
{/* caps */}
-
+
{/* body — elongated glowing silk */}
-
+
{/* bottom cap + tassel */}
-
+
@@ -207,17 +223,17 @@ function MonumentModel({ kind, accent }: { kind: string; accent: string }) {
return (
{/* parasol */}
-
+
{/* table */}
-
-
+
+
{/* two stools */}
{[-0.34, 0.34].map((x, i) => (
-
+
))}
{/* cup */}
-
+
);
@@ -225,14 +241,14 @@ function MonumentModel({ kind, accent }: { kind: string; accent: string }) {
return (
{/* hull */}
-
+
{/* upturned bow & stern */}
-
-
+
+
{/* arched roof */}
-
+
);
@@ -246,15 +262,15 @@ function MonumentModel({ kind, accent }: { kind: string; accent: string }) {
{ y: 0.5, r0: 0.22, r1: 0.18, c: '#dc2626' },
{ y: 0.78, r0: 0.18, r1: 0.15, c: '#f8fafc' },
].map((s, i) => (
-
+
))}
{/* gallery ring */}
-
+
{/* lamp room */}
-
+
{/* roof */}
-
+
{/* light beam */}
diff --git a/components/three/sceneHelpers.tsx b/components/three/sceneHelpers.tsx
new file mode 100644
index 0000000..b397666
--- /dev/null
+++ b/components/three/sceneHelpers.tsx
@@ -0,0 +1,128 @@
+import { Component, useEffect, useState, type ReactNode } from 'react';
+import { useThree, useFrame } from '@react-three/fiber';
+
+// ─── Shared error boundary ───────────────────────────────────────────────────
+// Catches Three.js crashes without killing the whole app. Used by both the
+// fullscreen NatureScene background (App.tsx) and the PersonalWorld modal.
+export class SceneErrorBoundary extends Component<
+ { children: ReactNode; fallback?: ReactNode },
+ { hasError: boolean }
+> {
+ constructor(props: { children: ReactNode; fallback?: ReactNode }) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error) {
+ console.warn('[MoodTrip] 3D scene error (non-fatal):', error.message);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return this.props.fallback ?? null;
+ }
+ return this.props.children;
+ }
+}
+
+// ─── Reduced-motion live subscription ────────────────────────────────────────
+export function prefersReducedMotion(): boolean {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
+
+// Live subscription (not one-shot): respects an OS reduce-motion toggle while the scene is open.
+export function useReducedMotion(): boolean {
+ const [reduce, setReduce] = useState(prefersReducedMotion);
+ useEffect(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const onChange = () => setReduce(mq.matches);
+ mq.addEventListener('change', onChange);
+ return () => mq.removeEventListener('change', onChange);
+ }, []);
+ return reduce;
+}
+
+// ─── WebGL availability guard ────────────────────────────────────────────────
+// Cached + leak-free: the probe context is RELEASED immediately (WEBGL_lose_context) and the result is
+// memoised module-level. The old version leaked a context on every call — and React StrictMode double-invokes
+// useState initializers in dev, so repeated modal opens exhausted the GPU context limit and the probe began
+// reporting "no WebGL" on devices that actually support it (false negative).
+let _webglAvailable: boolean | undefined;
+export function isWebGLAvailable(): boolean {
+ if (_webglAvailable !== undefined) return _webglAvailable;
+ if (typeof document === 'undefined') return true;
+ try {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
+ if (ctx) {
+ (ctx as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
+ _webglAvailable = true;
+ } else {
+ _webglAvailable = false;
+ }
+ } catch {
+ _webglAvailable = false;
+ }
+ return _webglAvailable;
+}
+
+// ─── Pause-when-hidden ───────────────────────────────────────────────────────
+// Sits inside a Canvas. Flips the R3F frameloop to 'never' while the tab is
+// hidden (document.visibilitychange) and back to 'always' when visible — so the
+// render loop stops doing work the user cannot see. We deliberately do NOT use a
+// permanent frameloop="demand" (that would freeze auto-rotate / orbit / fog).
+export function PauseOnHidden() {
+ const setFrameloop = useThree((s) => s.setFrameloop);
+ const invalidate = useThree((s) => s.invalidate);
+ useEffect(() => {
+ if (typeof document === 'undefined') return;
+ const onVisibility = () => {
+ if (document.hidden) {
+ setFrameloop('never');
+ } else {
+ setFrameloop('always');
+ invalidate();
+ }
+ };
+ document.addEventListener('visibilitychange', onVisibility);
+ // Apply current state immediately (tab may already be hidden).
+ onVisibility();
+ return () => {
+ document.removeEventListener('visibilitychange', onVisibility);
+ setFrameloop('always');
+ };
+ }, [setFrameloop, invalidate]);
+ return null;
+}
+
+// ─── Dev/test render counter ─────────────────────────────────────────────────
+// Only active under import.meta.env.DEV (or an explicit window flag). Writes the
+// renderer's per-frame draw count to window.__r3fRenderCount each frame so E2E
+// can assert the loop is paused when the scene isn't seen.
+declare global {
+ interface Window {
+ __r3fRenderCount?: number;
+ __r3fForceRenderCount?: boolean;
+ }
+ interface ImportMeta {
+ readonly env?: { readonly DEV?: boolean };
+ }
+}
+
+export function RenderCountProbe() {
+ const gl = useThree((s) => s.gl);
+ const enabled =
+ (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.DEV) ||
+ (typeof window !== 'undefined' && window.__r3fForceRenderCount === true);
+ useFrame(() => {
+ if (!enabled || typeof window === 'undefined') return;
+ window.__r3fRenderCount = gl.info.render.frame;
+ });
+ return null;
+}
diff --git a/e2e/three-d.spec.ts b/e2e/three-d.spec.ts
new file mode 100644
index 0000000..c15c51d
--- /dev/null
+++ b/e2e/three-d.spec.ts
@@ -0,0 +1,63 @@
+import { test, expect } from '@playwright/test';
+import { preacceptConsent, gotoHome } from './_helpers';
+
+// G002 3D perf + robustness. NatureScene renders as the landing backdrop, so these run without a modal.
+// RenderCountProbe writes the renderer frame count to window.__r3fRenderCount when window.__r3fForceRenderCount
+// is set (or in DEV). PauseOnHidden flips the R3F frameloop to 'never' while the tab is hidden.
+test.describe('3D scene perf + robustness (G002)', () => {
+ test('render loop pauses when the tab is hidden', async ({ page }) => {
+ await page.addInitScript(() => {
+ (window as unknown as { __r3fForceRenderCount?: boolean }).__r3fForceRenderCount = true;
+ });
+ await preacceptConsent(page);
+ await gotoHome(page);
+ await page.waitForTimeout(1500); // scene mounts + advances the counter
+
+ const before = await page.evaluate(() => (window as unknown as { __r3fRenderCount?: number }).__r3fRenderCount ?? null);
+ test.skip(before === null, '__r3fRenderCount hook not exposed in this build');
+
+ // Simulate the tab going hidden → PauseOnHidden should set frameloop 'never'.
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'hidden', { value: true, configurable: true });
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await page.waitForTimeout(300); // let the last queued frame flush
+ const settled = await page.evaluate(() => (window as unknown as { __r3fRenderCount?: number }).__r3fRenderCount ?? 0);
+ await page.waitForTimeout(1500); // window during which a paused loop must NOT advance
+ const after = await page.evaluate(() => (window as unknown as { __r3fRenderCount?: number }).__r3fRenderCount ?? 0);
+
+ expect((after as number) - (settled as number)).toBeLessThanOrEqual(1); // paused (allow 1 flush frame)
+ });
+
+ test('no uncaught page errors when the 3D scene mounts', async ({ page }) => {
+ const errors: string[] = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await preacceptConsent(page);
+ await gotoHome(page);
+ await page.waitForTimeout(2000);
+ expect(errors).toEqual([]);
+ });
+
+ test('WebGL unavailable → graceful fallback, app does not crash', async ({ page }) => {
+ await preacceptConsent(page);
+ // Force every webgl context to null so the scene must fall back via SceneErrorBoundary / isWebGLAvailable.
+ await page.addInitScript(() => {
+ const orig = HTMLCanvasElement.prototype.getContext;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, type: string, ...args: any[]) {
+ if (typeof type === 'string' && type.includes('webgl')) return null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (orig as any).call(this, type, ...args);
+ };
+ });
+ const errors: string[] = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await gotoHome(page); // waits for the Hero CTA — proves the app rendered, not a white screen
+ await expect(page.locator('button:has-text("Khám phá ngay")').first()).toBeVisible();
+ // Forcing WebGL off legitimately emits the browser's own "Error creating WebGL context." — that is the
+ // trigger, not an app crash. The app must not throw anything ELSE (graceful degradation).
+ const unexpected = errors.filter((e) => !/webgl context|creating webgl|getcontext/i.test(e));
+ expect(unexpected).toEqual([]);
+ });
+});
diff --git a/e2e/visual-3d.spec.ts b/e2e/visual-3d.spec.ts
new file mode 100644
index 0000000..29c658d
--- /dev/null
+++ b/e2e/visual-3d.spec.ts
@@ -0,0 +1,20 @@
+import { test, expect } from '@playwright/test';
+import { preacceptConsent, gotoHome, freezeScene } from './_helpers';
+
+// @visual — runs ONLY on the chromium-visual project (software GL via --use-gl=swiftshader).
+// Baselines are seeded with `npm run test:e2e:update` AFTER a human approves the aesthetic (the swiftshader
+// render, which differs from hardware GL). CI keeps @visual non-blocking until baselines prove stable.
+//
+// Determinism: freezeScene() seeds Math.random + reduced-motion (must precede goto); page.clock pins the
+// time-of-day so NatureScene's day/night cycle renders the same phase every run.
+test.describe('3D visual baselines @visual', () => {
+ test('landing NatureScene @visual', async ({ page }) => {
+ await page.clock.install({ time: new Date('2026-06-17T12:00:00') }); // noon phase, fixed
+ await preacceptConsent(page);
+ await freezeScene(page);
+ await gotoHome(page);
+ await page.waitForTimeout(1800); // mount + settle (loop pauses under reduced-motion)
+ // Hero region over the NatureScene backdrop — fog depth, ACES tone, warm/indigo light.
+ await expect(page).toHaveScreenshot('nature-landing.png', { maxDiffPixelRatio: 0.05 });
+ });
+});
diff --git a/e2e/visual-3d.spec.ts-snapshots/nature-landing-chromium-visual-darwin.png b/e2e/visual-3d.spec.ts-snapshots/nature-landing-chromium-visual-darwin.png
new file mode 100644
index 0000000000000000000000000000000000000000..fe38be0c80c3947ccc78a0820ae705a3ba2fc5f1
GIT binary patch
literal 454463
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