Spaces:
Running
Running
Sync DemoService a6436ca8840eb29086594cb2f3aec3966df6a64d
Browse files- api/ai-service/tests/test_tshirt_sample_runs.py +20 -0
- main/app/_components/LeafletMap.tsx +1 -1
- main/app/_components/LeafletMapClient.tsx +3 -3
- main/app/route-search/page.tsx +22 -24
- main/app/saved-routes/page.tsx +8 -6
- main/public/tshirt-sample-runs.json +0 -0
- router/ai/scripts/build_tshirt_sample_runs.py +55 -0
api/ai-service/tests/test_tshirt_sample_runs.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The published T-shirt GPX samples must be complete runnable routes."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
ROOT = Path(__file__).resolve().parents[3]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_tshirt_samples_are_runnable_and_uniquely_identified():
|
| 11 |
+
path = ROOT / "main" / "public" / "tshirt-sample-runs.json"
|
| 12 |
+
samples = json.loads(path.read_text(encoding="utf-8"))
|
| 13 |
+
assert len(samples) == 50
|
| 14 |
+
assert len({sample["id"] for sample in samples}) == 50
|
| 15 |
+
for sample in samples:
|
| 16 |
+
assert sample["label"] == "t_shirt"
|
| 17 |
+
assert sample["title"].startswith("ํฐ์
์ธ ์ํ ๋ฌ๋ ")
|
| 18 |
+
assert sample["distanceKm"] > 0
|
| 19 |
+
assert len(sample["route"]) >= 12
|
| 20 |
+
assert all(33 <= lat <= 39 and 124 <= lon <= 132 for lat, lon in sample["route"])
|
main/app/_components/LeafletMap.tsx
CHANGED
|
@@ -14,7 +14,7 @@ export type MapMarker = {
|
|
| 14 |
position: [number, number];
|
| 15 |
title: string;
|
| 16 |
count?: number;
|
| 17 |
-
kind?: "location" | "routeStart" | "communityPost" | "sampleDog" | "sampleStar";
|
| 18 |
};
|
| 19 |
|
| 20 |
export type LeafletMapProps = {
|
|
|
|
| 14 |
position: [number, number];
|
| 15 |
title: string;
|
| 16 |
count?: number;
|
| 17 |
+
kind?: "location" | "routeStart" | "communityPost" | "sampleDog" | "sampleStar" | "sampleTshirt";
|
| 18 |
};
|
| 19 |
|
| 20 |
export type LeafletMapProps = {
|
main/app/_components/LeafletMapClient.tsx
CHANGED
|
@@ -24,7 +24,7 @@ type MapMarker = {
|
|
| 24 |
position: [number, number];
|
| 25 |
title: string;
|
| 26 |
count?: number;
|
| 27 |
-
kind?: "location" | "routeStart" | "communityPost" | "sampleDog" | "sampleStar";
|
| 28 |
};
|
| 29 |
|
| 30 |
function isMapContainerReady(map: ReturnType<typeof useMap>): boolean {
|
|
@@ -225,12 +225,12 @@ export default function LeafletMapClient({
|
|
| 225 |
const count = Math.max(0, Math.round(marker.count ?? 0));
|
| 226 |
const isRouteStart = marker.kind === "routeStart";
|
| 227 |
const isCommunityPost = marker.kind === "communityPost";
|
| 228 |
-
const isSample = marker.kind === "sampleDog" || marker.kind === "sampleStar";
|
| 229 |
const label = isRouteStart ? String(count || "") : String(count);
|
| 230 |
const html = isRouteStart
|
| 231 |
? `<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #ffffff;background:#f05a28;color:#ffffff;font-size:12px;font-weight:800;box-shadow:0 3px 10px rgba(0,0,0,.25)">${label}</div>`
|
| 232 |
: isSample
|
| 233 |
-
? `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid ${selected ? "#111111" : "#ffffff"};background:${marker.kind === "sampleDog" ? "#f97316" : "#a855f7"};color:#ffffff;font-size:17px;font-weight:800;box-shadow:0 3px 10px rgba(0,0,0,.24)">${marker.kind === "sampleDog" ? "๐" : "โ
"}</div>`
|
| 234 |
: isCommunityPost
|
| 235 |
? `<div style="display:flex;align-items:center;justify-content:center;min-width:34px;height:34px;padding:0 8px;border-radius:17px;border:2px solid ${selected ? "#111111" : "#ffffff"};background:${selected ? "#111111" : "#2563eb"};color:#ffffff;font-size:12px;font-weight:800;box-shadow:0 3px 10px rgba(0,0,0,.24)">P${count}</div>`
|
| 236 |
: `<div style="display:flex;align-items:center;justify-content:center;min-width:34px;height:34px;padding:0 8px;border-radius:17px;border:2px solid ${selected ? "#111111" : "#ffffff"};background:${selected ? "#111111" : "#bfdcff"};color:${selected ? "#ffffff" : "#111111"};font-size:12px;font-weight:700;box-shadow:0 3px 10px rgba(0,0,0,.24)">${count}</div>`;
|
|
|
|
| 24 |
position: [number, number];
|
| 25 |
title: string;
|
| 26 |
count?: number;
|
| 27 |
+
kind?: "location" | "routeStart" | "communityPost" | "sampleDog" | "sampleStar" | "sampleTshirt";
|
| 28 |
};
|
| 29 |
|
| 30 |
function isMapContainerReady(map: ReturnType<typeof useMap>): boolean {
|
|
|
|
| 225 |
const count = Math.max(0, Math.round(marker.count ?? 0));
|
| 226 |
const isRouteStart = marker.kind === "routeStart";
|
| 227 |
const isCommunityPost = marker.kind === "communityPost";
|
| 228 |
+
const isSample = marker.kind === "sampleDog" || marker.kind === "sampleStar" || marker.kind === "sampleTshirt";
|
| 229 |
const label = isRouteStart ? String(count || "") : String(count);
|
| 230 |
const html = isRouteStart
|
| 231 |
? `<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #ffffff;background:#f05a28;color:#ffffff;font-size:12px;font-weight:800;box-shadow:0 3px 10px rgba(0,0,0,.25)">${label}</div>`
|
| 232 |
: isSample
|
| 233 |
+
? `<div style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:2px solid ${selected ? "#111111" : "#ffffff"};background:${marker.kind === "sampleDog" ? "#f97316" : marker.kind === "sampleTshirt" ? "#0d9488" : "#a855f7"};color:#ffffff;font-size:17px;font-weight:800;box-shadow:0 3px 10px rgba(0,0,0,.24)">${marker.kind === "sampleDog" ? "๐" : marker.kind === "sampleTshirt" ? "๐" : "โ
"}</div>`
|
| 234 |
: isCommunityPost
|
| 235 |
? `<div style="display:flex;align-items:center;justify-content:center;min-width:34px;height:34px;padding:0 8px;border-radius:17px;border:2px solid ${selected ? "#111111" : "#ffffff"};background:${selected ? "#111111" : "#2563eb"};color:#ffffff;font-size:12px;font-weight:800;box-shadow:0 3px 10px rgba(0,0,0,.24)">P${count}</div>`
|
| 236 |
: `<div style="display:flex;align-items:center;justify-content:center;min-width:34px;height:34px;padding:0 8px;border-radius:17px;border:2px solid ${selected ? "#111111" : "#ffffff"};background:${selected ? "#111111" : "#bfdcff"};color:${selected ? "#ffffff" : "#111111"};font-size:12px;font-weight:700;box-shadow:0 3px 10px rgba(0,0,0,.24)">${count}</div>`;
|
main/app/route-search/page.tsx
CHANGED
|
@@ -16,7 +16,7 @@ type Point = [number, number];
|
|
| 16 |
|
| 17 |
type DemoSampleRun = {
|
| 18 |
id: string;
|
| 19 |
-
label: "dog" | "symbol_star";
|
| 20 |
title: string;
|
| 21 |
distanceKm: number;
|
| 22 |
center: Point;
|
|
@@ -626,15 +626,15 @@ function RouteSearchPageContent() {
|
|
| 626 |
id: sample.id,
|
| 627 |
position: sample.center,
|
| 628 |
title: sample.title,
|
| 629 |
-
kind: sample.label === "dog" ? "sampleDog" : "sampleStar",
|
| 630 |
}));
|
| 631 |
const selectedMapRoutes: MapRouteLayer[] =
|
| 632 |
selectedSample?.route && selectedSample.route.length > 1
|
| 633 |
-
? [{ points: selectedSample.route, color: selectedSample.label === "dog" ? "#f97316" : "#a855f7", weight: 5, opacity: 0.95 }]
|
| 634 |
: sampleGalleryLabel
|
| 635 |
? galleryMapSamples.map((sample) => ({
|
| 636 |
points: sample.route,
|
| 637 |
-
color: sample.label === "dog" ? "#f97316" : "#a855f7",
|
| 638 |
weight: 3,
|
| 639 |
opacity: 0.65,
|
| 640 |
}))
|
|
@@ -713,15 +713,13 @@ function RouteSearchPageContent() {
|
|
| 713 |
|
| 714 |
useEffect(() => {
|
| 715 |
let isMounted = true;
|
| 716 |
-
void
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
.
|
| 722 |
-
|
| 723 |
-
})
|
| 724 |
-
.catch(() => undefined);
|
| 725 |
return () => { isMounted = false; };
|
| 726 |
}, []);
|
| 727 |
|
|
@@ -929,7 +927,7 @@ function RouteSearchPageContent() {
|
|
| 929 |
const distance = selectedSample.distanceKm.toFixed(2);
|
| 930 |
const params = new URLSearchParams({
|
| 931 |
runKey,
|
| 932 |
-
animal: selectedSample.
|
| 933 |
routeTitle: selectedSample.title,
|
| 934 |
distance,
|
| 935 |
targetDistanceKm: distance,
|
|
@@ -965,7 +963,7 @@ function RouteSearchPageContent() {
|
|
| 965 |
markers={[...sampleMarkers, ...postMarkers]}
|
| 966 |
selectedMarkerId={selectedSampleId ?? (selectedPostId ? `post-${selectedPostId}` : null)}
|
| 967 |
onMarkerClick={(marker) => {
|
| 968 |
-
if (marker.kind === "sampleDog" || marker.kind === "sampleStar") {
|
| 969 |
setSelectedPostId(null);
|
| 970 |
setSelectedSampleId((current) => current === marker.id ? null : marker.id);
|
| 971 |
} else if (marker.kind === "communityPost") {
|
|
@@ -987,11 +985,11 @@ function RouteSearchPageContent() {
|
|
| 987 |
<div className="flex items-start justify-between gap-3">
|
| 988 |
<div className="min-w-0">
|
| 989 |
<p className="truncate text-sm font-black text-black">
|
| 990 |
-
{selectedSample ? selectedSample.title : sampleGalleryLabel ? `${sampleGalleryLabel === "dog" ? "๊ฐ์์ง" : "๋ณ"} ์ํ ${gallerySamples.length}๊ฐ` : selectedPost ? selectedPost.title || "๊ฒ์๋ฌผ ๋ฃจํธ" : "์ฃผ๋ณ ๋ฌ๋ ๊ฒฝ๋ก"}
|
| 991 |
</p>
|
| 992 |
<p className="mt-1 text-xs leading-5 text-[#666]">
|
| 993 |
{selectedSample
|
| 994 |
-
? `
|
| 995 |
: sampleGalleryLabel
|
| 996 |
? `ํ์ฌ ์ง๋ ์ฃผ๋ณ 30km ์์ ์ํ ${galleryMapSamples.length}๊ฐ๋ฅผ ํจ๊ป ํ์ํฉ๋๋ค. ์๋ ๋ชฉ๋ก์์ ๋ค๋ฅธ ์ง์ญ ์ํ๋ ์ ํํ ์ ์์ต๋๋ค.`
|
| 997 |
: selectedPost
|
|
@@ -1005,8 +1003,8 @@ function RouteSearchPageContent() {
|
|
| 1005 |
</div>
|
| 1006 |
</div>
|
| 1007 |
{sampleRuns.length > 0 && (
|
| 1008 |
-
<div className="mt-3 flex gap-2" aria-label="
|
| 1009 |
-
{(["dog", "symbol_star"] as const).map((label) => (
|
| 1010 |
<button
|
| 1011 |
key={label}
|
| 1012 |
type="button"
|
|
@@ -1017,13 +1015,13 @@ function RouteSearchPageContent() {
|
|
| 1017 |
}}
|
| 1018 |
className={`rounded-full border px-3 py-2 text-xs font-bold ${sampleGalleryLabel === label ? "border-black bg-black text-white" : "border-black/10 bg-[#f7f7f7] text-black"}`}
|
| 1019 |
>
|
| 1020 |
-
{label === "dog" ? "๐ ๊ฐ์์ง" : "โ
๋ณ"} ์ํ {sampleRuns.filter((sample) => sample.label === label).length}๊ฐ ๋ณด๊ธฐ
|
| 1021 |
</button>
|
| 1022 |
))}
|
| 1023 |
</div>
|
| 1024 |
)}
|
| 1025 |
{sampleGalleryLabel && (
|
| 1026 |
-
<div className="mt-3 max-h-52 overflow-y-auto rounded-2xl border border-black/10 p-2" aria-label="
|
| 1027 |
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
| 1028 |
{gallerySamples.map((sample) => (
|
| 1029 |
<button
|
|
@@ -1040,7 +1038,7 @@ function RouteSearchPageContent() {
|
|
| 1040 |
</div>
|
| 1041 |
)}
|
| 1042 |
{!sampleGalleryLabel && nearbySamples.length > 0 && (
|
| 1043 |
-
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label="์ฃผ๋ณ
|
| 1044 |
{nearbySamples.map(({ sample }) => (
|
| 1045 |
<button
|
| 1046 |
key={sample.id}
|
|
@@ -1051,14 +1049,14 @@ function RouteSearchPageContent() {
|
|
| 1051 |
}}
|
| 1052 |
className={`shrink-0 rounded-full border px-3 py-2 text-xs font-bold ${selectedSampleId === sample.id ? "border-black bg-black text-white" : "border-black/10 bg-white text-black"}`}
|
| 1053 |
>
|
| 1054 |
-
{sample.label === "dog" ? "๐" : "โ
"} {sample.distanceKm.toFixed(1)}km
|
| 1055 |
</button>
|
| 1056 |
))}
|
| 1057 |
</div>
|
| 1058 |
)}
|
| 1059 |
{selectedSample ? (
|
| 1060 |
<>
|
| 1061 |
-
<p className="mt-3 text-xs text-[#666]">์ด ๊ฒฝ๋ก๋
|
| 1062 |
<button
|
| 1063 |
type="button"
|
| 1064 |
onClick={handleStartSelectedSampleRoute}
|
|
|
|
| 16 |
|
| 17 |
type DemoSampleRun = {
|
| 18 |
id: string;
|
| 19 |
+
label: "dog" | "symbol_star" | "t_shirt";
|
| 20 |
title: string;
|
| 21 |
distanceKm: number;
|
| 22 |
center: Point;
|
|
|
|
| 626 |
id: sample.id,
|
| 627 |
position: sample.center,
|
| 628 |
title: sample.title,
|
| 629 |
+
kind: sample.label === "dog" ? "sampleDog" : sample.label === "t_shirt" ? "sampleTshirt" : "sampleStar",
|
| 630 |
}));
|
| 631 |
const selectedMapRoutes: MapRouteLayer[] =
|
| 632 |
selectedSample?.route && selectedSample.route.length > 1
|
| 633 |
+
? [{ points: selectedSample.route, color: selectedSample.label === "dog" ? "#f97316" : selectedSample.label === "t_shirt" ? "#0d9488" : "#a855f7", weight: 5, opacity: 0.95 }]
|
| 634 |
: sampleGalleryLabel
|
| 635 |
? galleryMapSamples.map((sample) => ({
|
| 636 |
points: sample.route,
|
| 637 |
+
color: sample.label === "dog" ? "#f97316" : sample.label === "t_shirt" ? "#0d9488" : "#a855f7",
|
| 638 |
weight: 3,
|
| 639 |
opacity: 0.65,
|
| 640 |
}))
|
|
|
|
| 713 |
|
| 714 |
useEffect(() => {
|
| 715 |
let isMounted = true;
|
| 716 |
+
void Promise.allSettled(["/demo-sample-runs.json", "/tshirt-sample-runs.json"].map(async (url) => {
|
| 717 |
+
const response = await fetch(url);
|
| 718 |
+
if (!response.ok) throw new Error("Sample GPX data unavailable");
|
| 719 |
+
return response.json() as Promise<DemoSampleRun[]>;
|
| 720 |
+
})).then((results) => {
|
| 721 |
+
if (isMounted) setSampleRuns(results.flatMap((result) => result.status === "fulfilled" ? result.value : []));
|
| 722 |
+
});
|
|
|
|
|
|
|
| 723 |
return () => { isMounted = false; };
|
| 724 |
}, []);
|
| 725 |
|
|
|
|
| 927 |
const distance = selectedSample.distanceKm.toFixed(2);
|
| 928 |
const params = new URLSearchParams({
|
| 929 |
runKey,
|
| 930 |
+
animal: selectedSample.label,
|
| 931 |
routeTitle: selectedSample.title,
|
| 932 |
distance,
|
| 933 |
targetDistanceKm: distance,
|
|
|
|
| 963 |
markers={[...sampleMarkers, ...postMarkers]}
|
| 964 |
selectedMarkerId={selectedSampleId ?? (selectedPostId ? `post-${selectedPostId}` : null)}
|
| 965 |
onMarkerClick={(marker) => {
|
| 966 |
+
if (marker.kind === "sampleDog" || marker.kind === "sampleStar" || marker.kind === "sampleTshirt") {
|
| 967 |
setSelectedPostId(null);
|
| 968 |
setSelectedSampleId((current) => current === marker.id ? null : marker.id);
|
| 969 |
} else if (marker.kind === "communityPost") {
|
|
|
|
| 985 |
<div className="flex items-start justify-between gap-3">
|
| 986 |
<div className="min-w-0">
|
| 987 |
<p className="truncate text-sm font-black text-black">
|
| 988 |
+
{selectedSample ? selectedSample.title : sampleGalleryLabel ? `${sampleGalleryLabel === "dog" ? "๊ฐ์์ง" : sampleGalleryLabel === "t_shirt" ? "ํฐ์
์ธ " : "๋ณ"} ์ํ ${gallerySamples.length}๊ฐ` : selectedPost ? selectedPost.title || "๊ฒ์๋ฌผ ๋ฃจํธ" : "์ฃผ๋ณ ๋ฌ๋ ๊ฒฝ๋ก"}
|
| 989 |
</p>
|
| 990 |
<p className="mt-1 text-xs leading-5 text-[#666]">
|
| 991 |
{selectedSample
|
| 992 |
+
? `AI ์ํ ยท ${selectedSample.distanceKm.toFixed(2)}km ยท ์๋ณธ ์์น์ ๊ฒฝ๋ก์
๋๋ค.`
|
| 993 |
: sampleGalleryLabel
|
| 994 |
? `ํ์ฌ ์ง๋ ์ฃผ๋ณ 30km ์์ ์ํ ${galleryMapSamples.length}๊ฐ๋ฅผ ํจ๊ป ํ์ํฉ๋๋ค. ์๋ ๋ชฉ๋ก์์ ๋ค๋ฅธ ์ง์ญ ์ํ๋ ์ ํํ ์ ์์ต๋๋ค.`
|
| 995 |
: selectedPost
|
|
|
|
| 1003 |
</div>
|
| 1004 |
</div>
|
| 1005 |
{sampleRuns.length > 0 && (
|
| 1006 |
+
<div className="mt-3 flex flex-wrap gap-2" aria-label="AI ์ํ ๋ฌ๋ ๋ฏธ๋ฆฌ๋ณด๊ธฐ">
|
| 1007 |
+
{(["dog", "symbol_star", "t_shirt"] as const).map((label) => (
|
| 1008 |
<button
|
| 1009 |
key={label}
|
| 1010 |
type="button"
|
|
|
|
| 1015 |
}}
|
| 1016 |
className={`rounded-full border px-3 py-2 text-xs font-bold ${sampleGalleryLabel === label ? "border-black bg-black text-white" : "border-black/10 bg-[#f7f7f7] text-black"}`}
|
| 1017 |
>
|
| 1018 |
+
{label === "dog" ? "๐ ๊ฐ์์ง" : label === "t_shirt" ? "๐ ํฐ์
์ธ " : "โ
๋ณ"} ์ํ {sampleRuns.filter((sample) => sample.label === label).length}๊ฐ ๋ณด๊ธฐ
|
| 1019 |
</button>
|
| 1020 |
))}
|
| 1021 |
</div>
|
| 1022 |
)}
|
| 1023 |
{sampleGalleryLabel && (
|
| 1024 |
+
<div className="mt-3 max-h-52 overflow-y-auto rounded-2xl border border-black/10 p-2" aria-label="AI ์ํ ์ ์ฒด ๋ชฉ๋ก">
|
| 1025 |
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
| 1026 |
{gallerySamples.map((sample) => (
|
| 1027 |
<button
|
|
|
|
| 1038 |
</div>
|
| 1039 |
)}
|
| 1040 |
{!sampleGalleryLabel && nearbySamples.length > 0 && (
|
| 1041 |
+
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label="์ฃผ๋ณ AI ์ํ ๋ฌ๋">
|
| 1042 |
{nearbySamples.map(({ sample }) => (
|
| 1043 |
<button
|
| 1044 |
key={sample.id}
|
|
|
|
| 1049 |
}}
|
| 1050 |
className={`shrink-0 rounded-full border px-3 py-2 text-xs font-bold ${selectedSampleId === sample.id ? "border-black bg-black text-white" : "border-black/10 bg-white text-black"}`}
|
| 1051 |
>
|
| 1052 |
+
{sample.label === "dog" ? "๐" : sample.label === "t_shirt" ? "๐" : "โ
"} {sample.distanceKm.toFixed(1)}km
|
| 1053 |
</button>
|
| 1054 |
))}
|
| 1055 |
</div>
|
| 1056 |
)}
|
| 1057 |
{selectedSample ? (
|
| 1058 |
<>
|
| 1059 |
+
<p className="mt-3 text-xs text-[#666]">์ด ๊ฒฝ๋ก๋ AI ์ํ์
๋๋ค. ์ค์ ํตํ ๊ฐ๋ฅ ์ฌ๋ถ๋ฅผ ํ์ธํ๊ณ ์๋ณธ ์์์ ๊ทผ์ฒ์์ ๋ฌ๋ ค์ฃผ์ธ์.</p>
|
| 1060 |
<button
|
| 1061 |
type="button"
|
| 1062 |
onClick={handleStartSelectedSampleRoute}
|
main/app/saved-routes/page.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import { sendNativeWearRouteSelection } from "../_utils/nativeWearBridge";
|
|
| 12 |
import { getAppPosition } from "../_utils/nativeLocation";
|
| 13 |
|
| 14 |
type Point = [number, number];
|
| 15 |
-
type RouteTab = "posts" | "dog" | "star";
|
| 16 |
type Course = {
|
| 17 |
id: string;
|
| 18 |
title: string;
|
|
@@ -25,7 +25,7 @@ type Course = {
|
|
| 25 |
};
|
| 26 |
type SampleRun = {
|
| 27 |
id: string;
|
| 28 |
-
label: "dog" | "symbol_star";
|
| 29 |
title: string;
|
| 30 |
distanceKm: number;
|
| 31 |
center: Point;
|
|
@@ -104,7 +104,7 @@ export default function SavedRoutesPage() {
|
|
| 104 |
const syncSaved = () => {
|
| 105 |
if (!active) return;
|
| 106 |
setSaved(readSavedRoutesFromStorage()
|
| 107 |
-
.filter((route) => (route.label === "dog" || route.label === "symbol_star") && route.routeCoords.length > 1)
|
| 108 |
.map((route) => ({
|
| 109 |
id: `saved-${route.id}`,
|
| 110 |
title: route.title,
|
|
@@ -126,6 +126,7 @@ export default function SavedRoutesPage() {
|
|
| 126 |
void Promise.allSettled([
|
| 127 |
loadSamples("/demo-sample-runs.json"),
|
| 128 |
loadSamples("/approved-road-runs.json"),
|
|
|
|
| 129 |
]).then((results) => {
|
| 130 |
if (!active) return;
|
| 131 |
setSamples(results.flatMap((result) => result.status === "fulfilled" ? result.value : [])
|
|
@@ -147,7 +148,7 @@ export default function SavedRoutesPage() {
|
|
| 147 |
|
| 148 |
const visibleCourses = useMemo(() => {
|
| 149 |
if (tab === "posts") return posts;
|
| 150 |
-
const label = tab === "dog" ? "dog" : "symbol_star";
|
| 151 |
return [...saved, ...samples].filter((course) => course.label === label);
|
| 152 |
}, [tab, posts, saved, samples]);
|
| 153 |
const listedCourses = showAllCourses ? visibleCourses : visibleCourses.slice(0, 4);
|
|
@@ -157,7 +158,7 @@ export default function SavedRoutesPage() {
|
|
| 157 |
position: course.center,
|
| 158 |
title: course.title,
|
| 159 |
count: index + 1,
|
| 160 |
-
kind: course.kind === "post" ? "communityPost" : course.label === "dog" ? "sampleDog" : "sampleStar",
|
| 161 |
}));
|
| 162 |
const selectedMapRoutes = selected ? [{
|
| 163 |
points: selected.route,
|
|
@@ -221,6 +222,7 @@ export default function SavedRoutesPage() {
|
|
| 221 |
["posts", "์ ์ฒด ๊ฒ์๋ฌผ"],
|
| 222 |
["dog", "๊ฐ์์ง ์ฝ์ค"],
|
| 223 |
["star", "๋ณ ์ฝ์ค"],
|
|
|
|
| 224 |
] as const).map(([value, label]) => (
|
| 225 |
<button key={value} type="button" role="tab" aria-selected={tab === value}
|
| 226 |
onClick={() => { setTab(value); setSelectedId(null); setShowAllCourses(false); }}
|
|
@@ -275,7 +277,7 @@ export default function SavedRoutesPage() {
|
|
| 275 |
|
| 276 |
<section className="order-1 rounded-[24px] border border-black/10 bg-white p-3 shadow-sm lg:order-2 lg:p-4" role="tabpanel">
|
| 277 |
<div className="mb-3 flex items-center justify-between">
|
| 278 |
-
<h2 className="text-lg font-bold text-black">{tab === "posts" ? "์ ์ฒด ๊ฒ์๋ฌผ" : tab === "dog" ? "๊ฐ์์ง ์ฝ์ค" : "๋ณ ์ฝ์ค"}</h2>
|
| 279 |
<span className="text-sm text-[#666]">{visibleCourses.length}๊ฐ</span>
|
| 280 |
</div>
|
| 281 |
{isLoading ? <p className="py-8 text-center text-sm text-[#666]">์ฝ์ค๋ฅผ ๋ถ๋ฌ์ค๋ ์ค์
๋๋ค.</p> : null}
|
|
|
|
| 12 |
import { getAppPosition } from "../_utils/nativeLocation";
|
| 13 |
|
| 14 |
type Point = [number, number];
|
| 15 |
+
type RouteTab = "posts" | "dog" | "star" | "tshirt";
|
| 16 |
type Course = {
|
| 17 |
id: string;
|
| 18 |
title: string;
|
|
|
|
| 25 |
};
|
| 26 |
type SampleRun = {
|
| 27 |
id: string;
|
| 28 |
+
label: "dog" | "symbol_star" | "t_shirt";
|
| 29 |
title: string;
|
| 30 |
distanceKm: number;
|
| 31 |
center: Point;
|
|
|
|
| 104 |
const syncSaved = () => {
|
| 105 |
if (!active) return;
|
| 106 |
setSaved(readSavedRoutesFromStorage()
|
| 107 |
+
.filter((route) => (route.label === "dog" || route.label === "symbol_star" || route.label === "t_shirt") && route.routeCoords.length > 1)
|
| 108 |
.map((route) => ({
|
| 109 |
id: `saved-${route.id}`,
|
| 110 |
title: route.title,
|
|
|
|
| 126 |
void Promise.allSettled([
|
| 127 |
loadSamples("/demo-sample-runs.json"),
|
| 128 |
loadSamples("/approved-road-runs.json"),
|
| 129 |
+
loadSamples("/tshirt-sample-runs.json"),
|
| 130 |
]).then((results) => {
|
| 131 |
if (!active) return;
|
| 132 |
setSamples(results.flatMap((result) => result.status === "fulfilled" ? result.value : [])
|
|
|
|
| 148 |
|
| 149 |
const visibleCourses = useMemo(() => {
|
| 150 |
if (tab === "posts") return posts;
|
| 151 |
+
const label = tab === "dog" ? "dog" : tab === "star" ? "symbol_star" : "t_shirt";
|
| 152 |
return [...saved, ...samples].filter((course) => course.label === label);
|
| 153 |
}, [tab, posts, saved, samples]);
|
| 154 |
const listedCourses = showAllCourses ? visibleCourses : visibleCourses.slice(0, 4);
|
|
|
|
| 158 |
position: course.center,
|
| 159 |
title: course.title,
|
| 160 |
count: index + 1,
|
| 161 |
+
kind: course.kind === "post" ? "communityPost" : course.label === "dog" ? "sampleDog" : course.label === "t_shirt" ? "sampleTshirt" : "sampleStar",
|
| 162 |
}));
|
| 163 |
const selectedMapRoutes = selected ? [{
|
| 164 |
points: selected.route,
|
|
|
|
| 222 |
["posts", "์ ์ฒด ๊ฒ์๋ฌผ"],
|
| 223 |
["dog", "๊ฐ์์ง ์ฝ์ค"],
|
| 224 |
["star", "๋ณ ์ฝ์ค"],
|
| 225 |
+
["tshirt", "ํฐ์
์ธ ์ฝ์ค"],
|
| 226 |
] as const).map(([value, label]) => (
|
| 227 |
<button key={value} type="button" role="tab" aria-selected={tab === value}
|
| 228 |
onClick={() => { setTab(value); setSelectedId(null); setShowAllCourses(false); }}
|
|
|
|
| 277 |
|
| 278 |
<section className="order-1 rounded-[24px] border border-black/10 bg-white p-3 shadow-sm lg:order-2 lg:p-4" role="tabpanel">
|
| 279 |
<div className="mb-3 flex items-center justify-between">
|
| 280 |
+
<h2 className="text-lg font-bold text-black">{tab === "posts" ? "์ ์ฒด ๊ฒ์๋ฌผ" : tab === "dog" ? "๊ฐ์์ง ์ฝ์ค" : tab === "star" ? "๋ณ ์ฝ์ค" : "ํฐ์
์ธ ์ฝ์ค"}</h2>
|
| 281 |
<span className="text-sm text-[#666]">{visibleCourses.length}๊ฐ</span>
|
| 282 |
</div>
|
| 283 |
{isLoading ? <p className="py-8 text-center text-sm text-[#666]">์ฝ์ค๋ฅผ ๋ถ๋ฌ์ค๋ ์ค์
๋๋ค.</p> : null}
|
main/public/tshirt-sample-runs.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
router/ai/scripts/build_tshirt_sample_runs.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build runnable T-shirt map samples from the supplied GPX tracks.
|
| 2 |
+
|
| 3 |
+
Usage: python build_tshirt_sample_runs.py GPX_DIRECTORY OUTPUT_JSON
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import math
|
| 8 |
+
import re
|
| 9 |
+
import sys
|
| 10 |
+
import xml.etree.ElementTree as ET
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def distance_km(a, b):
|
| 15 |
+
lat1, lon1 = map(math.radians, a)
|
| 16 |
+
lat2, lon2 = map(math.radians, b)
|
| 17 |
+
half = math.sin((lat2 - lat1) / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2
|
| 18 |
+
return 12742 * math.asin(min(1, math.sqrt(half)))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def build(source: Path):
|
| 22 |
+
paths = sorted(source.glob("tshirt_osm_*.gpx"))
|
| 23 |
+
if not paths:
|
| 24 |
+
raise ValueError("No tshirt_osm_*.gpx files found")
|
| 25 |
+
samples = []
|
| 26 |
+
for path in paths:
|
| 27 |
+
match = re.fullmatch(r"tshirt_osm_(\d{3})_[\d.]+km\.gpx", path.name)
|
| 28 |
+
if not match:
|
| 29 |
+
raise ValueError(f"Unexpected T-shirt GPX filename: {path.name}")
|
| 30 |
+
route = [
|
| 31 |
+
[round(float(node.attrib["lat"]), 6), round(float(node.attrib["lon"]), 6)]
|
| 32 |
+
for node in ET.parse(path).iter()
|
| 33 |
+
if node.tag.rsplit("}", 1)[-1] == "trkpt"
|
| 34 |
+
]
|
| 35 |
+
if len(route) < 12 or any(not 33 <= lat <= 39 or not 124 <= lon <= 132 for lat, lon in route):
|
| 36 |
+
raise ValueError(f"Invalid Korean GPX route: {path.name}")
|
| 37 |
+
samples.append({
|
| 38 |
+
"id": f"tshirt-{match.group(1)}",
|
| 39 |
+
"label": "t_shirt",
|
| 40 |
+
"title": f"ํฐ์
์ธ ์ํ ๋ฌ๋ {match.group(1)}",
|
| 41 |
+
"distanceKm": round(sum(distance_km(a, b) for a, b in zip(route, route[1:])), 2),
|
| 42 |
+
"center": [round(sum(point[i] for point in route) / len(route), 6) for i in (0, 1)],
|
| 43 |
+
"route": route,
|
| 44 |
+
})
|
| 45 |
+
return samples
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
if len(sys.argv) != 3:
|
| 50 |
+
raise SystemExit("Usage: build_tshirt_sample_runs.py GPX_DIRECTORY OUTPUT_JSON")
|
| 51 |
+
output = Path(sys.argv[2])
|
| 52 |
+
samples = build(Path(sys.argv[1]))
|
| 53 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
output.write_text(json.dumps(samples, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
|
| 55 |
+
print(f"Built {len(samples)} T-shirt sample runs")
|