tkadkghdlf commited on
Commit
6d9bda6
ยท
verified ยท
1 Parent(s): 1b47bb6

Sync DemoService a6436ca8840eb29086594cb2f3aec3966df6a64d

Browse files
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 fetch("/demo-sample-runs.json")
717
- .then((response) => {
718
- if (!response.ok) throw new Error("Sample GPX data unavailable");
719
- return response.json() as Promise<DemoSampleRun[]>;
720
- })
721
- .then((samples) => {
722
- if (isMounted && Array.isArray(samples)) setSampleRuns(samples);
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.title,
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
- ? `์—…๋กœ๋“œํ•œ GPX ์ƒ˜ํ”Œ ยท ${selectedSample.distanceKm.toFixed(2)}km ยท ์›๋ณธ ์œ„์น˜์˜ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค.`
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="GPX ์ƒ˜ํ”Œ ๋Ÿฌ๋‹ ๋ฏธ๋ฆฌ๋ณด๊ธฐ">
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="GPX ์ƒ˜ํ”Œ ์ „์ฒด ๋ชฉ๋ก">
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="์ฃผ๋ณ€ GPX ์ƒ˜ํ”Œ ๋Ÿฌ๋‹">
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]">์ด ๊ฒฝ๋กœ๋Š” GPX ์ƒ˜ํ”Œ์ž…๋‹ˆ๋‹ค. ์‹ค์ œ ํ†ตํ–‰ ๊ฐ€๋Šฅ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜๊ณ  ์›๋ณธ ์‹œ์ž‘์  ๊ทผ์ฒ˜์—์„œ ๋‹ฌ๋ ค์ฃผ์„ธ์š”.</p>
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")