
グローカルアプリ開発(第12話)
個人事業主にとって最高のホテル
それは間違いなくコンフォートホテルです。
なぜなら、宿泊するとライブラリーカフェ無料券が月5回分付いてくるから。
以前全国を回りながらサブスくらしをしていたのですが、コンフォートホテルさんは全国チェーンの為、行く先々でライブラリーカフェのお世話になりました。
正直スタバのようなカフェで仕事をするより、捗ります。ホテルに宿泊される方は、だいたいうるさくしない方が多いので。
最近はインバウンド勢が強いので、夕食の時間帯は微妙ですが、比較的落ち着いています。
家で仕事すればいいじゃないか?と思う方もいらっしゃるかもしれませんが、家ってベッドが近くにありますよね?その重力に大抵人間は勝てません。
デジタルマーケティング現況
閑話休題で、デジタルマーケティングをはじめて12日目に入りました。コード記載をする背景から、マークダウンでコンテンツマーケティングをしたかったので、Zenn・Note・Qiitaにてランダム公開しました。Lがライクで、Fがフォロー、Vがビューです。全部で11話通し620人の方にご覧になっていただきました。

そこから18人ホームページに来てくれて、営業の人からお問い合わせいただいた感じです。お客さんが来る日はおそらく1年後かな(笑)。
尚、各プラットフォームの現在の印象は以下です。
Zenn:Qiitaよりは若いメディアなので、割と気軽にライクがある。自前でコミュニティを運営している人が、おそらく下心があってか、いつもいいねしてくる。
Note:プログラムにはあまり興味はないが、少ないビューでライクやフォローが比較的多く、ライクマインドな人が多い。
Qiita:絶対数が多めでビューも多いが、そんな簡単にライクやフォローはしない、玄人勢が多い。
まだ迷いはありますが、どこかのタイミングで一つに絞っていきたいと思います。
AIアプリをつくる
現在路線アプリを開発しています。AIエージェント元年と言われる今年(2025)ですから、人の役に立つAIを活用した路線アプリにしたいと思います。本日はすすきののライブラリーカフェにおりますが、いい感じで機能していて、GPSで最寄り駅を取得し、徒歩時間を逆算。そして、乗車予定時刻を表示できています。

インバウンド向けに改良
札幌中心部は本当に中国人が多いです。中国語を聞かない日はないくらい。中国本土では、土地を所有することができないので、近くの北海道の土地を富裕層が買い漁ってるそうです。
あとは、雪とか、映画のロケ地とかも北海道は多く、魅力的なのかもしれません。
先日観光に来ていた香港の中国人留学生と話していた時に、中国と香港の生活は違う?と聞いてみたら、「香港は中国です。」と回答され、ビビりました(笑)。
需要が伸びているところに張るのが、ビジネスの基本ですから、この札幌地下鉄システムを多言語対応させていきたいと思います。
https://wwwtb.mlit.go.jp/hokkaido/content/000284820.pdf
ちなみに、国別観光客数は上記によると以下ランキングでした。
韓国
台湾
中国
香港
タイ
フィリピン
シンガポール
ベトナム
カナダ
イギリス
フランス
オーストラリア
今日の成果物
政治的事情を考慮すると、こんな感じですね。









ソース
全部i18にすると、駅名とかワヤになるので、現時点ではGoogle APIに依存します。
// React Native
import React, { useEffect, useState } from "react";
import * as Location from "expo-location";
import { ThemedText } from "./ThemedText";
import { Alert, Button, View, Text, TouchableOpacity } from "react-native";
import Constants from "expo-constants";
import { TranslateService } from "../services/translateService";
import LanguageSelector from "./LanguageSelector";
// インターフェース定義
interface Station {
coords: {
lat: number;
lng: number;
};
name: string;
}
interface Coordinates {
latitude: number;
longitude: number;
}
interface APIError {
message: string;
status?: number;
}
interface SubwaySchedule {
direction: string;
linetype: string;
nearestStation: string;
weekdayOrEnd: string;
currentHour: string;
nextHour: string;
currentHourTimes: string;
nextHourTimes: string;
nextTrains: string[];
searchInfo: {
queryTime: string;
arrivalTime: string;
walkingMinutes: number;
};
calculatedDirection: number;
colored: boolean;
}
interface UIText {
loading: string;
error: string;
nearestStation: string;
walkingTime: string;
minutes: string;
calculating: string;
showOthers: string;
hideOthers: string;
endOfService: string;
errorTitle: string;
unexpectedError: string;
}
type Languages =
| "ja"
| "ko"
| "zh-TW"
| "zh-CN"
| "th"
| "fil"
| "vi"
| "en"
| "fr";
const uiText: Record<Languages, UIText> = {
ja: {
loading: "読み込み中",
error: "エラーが発生しました",
nearestStation: "最寄り駅",
walkingTime: "徒歩時間",
minutes: "分",
calculating: "計算中",
showOthers: "他の人を表示",
hideOthers: "他の人を非表示",
endOfService: "サービス終了",
errorTitle: "エラー",
unexpectedError: "予期しないエラーが発生しました",
},
ko: {
loading: "로딩 중",
error: "오류가 발생했습니다",
nearestStation: "가장 가까운 역",
walkingTime: "도보 시간",
minutes: "분",
calculating: "계산 중",
showOthers: "다른 사람 보기",
hideOthers: "다른 사람 숨기기",
endOfService: "서비스 종료",
errorTitle: "오류",
unexpectedError: "예상치 못한 오류가 발생했습니다",
},
"zh-TW": {
loading: "載入中",
error: "發生錯誤",
nearestStation: "最近車站",
walkingTime: "步行時間",
minutes: "分鐘",
calculating: "計算中",
showOthers: "顯示其他人",
hideOthers: "隱藏其他人",
endOfService: "服務結束",
errorTitle: "錯誤",
unexpectedError: "發生了意外錯誤",
},
"zh-CN": {
loading: "加载中",
error: "发生错误",
nearestStation: "最近车站",
walkingTime: "步行时间",
minutes: "分钟",
calculating: "计算中",
showOthers: "显示其他人",
hideOthers: "隐藏其他人",
endOfService: "服务结束",
errorTitle: "错误",
unexpectedError: "发生了意外错误",
},
th: {
loading: "กำลังโหลด",
error: "เกิดข้อผิดพลาด",
nearestStation: "สถานีที่ใกล้ที่สุด",
walkingTime: "เวลาเดิน",
minutes: "นาที",
calculating: "กำลังคำนวณ",
showOthers: "แสดงผู้อื่น",
hideOthers: "ซ่อนผู้อื่น",
endOfService: "บริการสิ้นสุด",
errorTitle: "ข้อผิดพลาด",
unexpectedError: "เกิดข้อผิดพลาดที่ไม่คาดคิด",
},
fil: {
loading: "Naglo-load",
error: "Nagkaroon ng error",
nearestStation: "Pinakamalapit na Istasyon",
walkingTime: "Oras ng Paglakad",
minutes: "minuto",
calculating: "Nagkakalculate",
showOthers: "Ipakita ang Iba",
hideOthers: "Itago ang Iba",
endOfService: "Pagtatapos ng Serbisyo",
errorTitle: "Error",
unexpectedError: "Nagkaroon ng hindi inaasahang error",
},
vi: {
loading: "Đang tải",
error: "Đã xảy ra lỗi",
nearestStation: "Ga gần nhất",
walkingTime: "Thời gian đi bộ",
minutes: "phút",
calculating: "Đang tính toán",
showOthers: "Hiển thị người khác",
hideOthers: "Ẩn người khác",
endOfService: "Kết thúc dịch vụ",
errorTitle: "Lỗi",
unexpectedError: "Đã xảy ra lỗi ngoài ý muốn",
},
en: {
loading: "Loading",
error: "An error occurred",
nearestStation: "Nearest Station",
walkingTime: "Walking Time",
minutes: "minutes",
calculating: "Calculating",
showOthers: "Show Others",
hideOthers: "Hide Others",
endOfService: "End of Service",
errorTitle: "Error",
unexpectedError: "An unexpected error occurred",
},
fr: {
loading: "Chargement",
error: "Une erreur est survenue",
nearestStation: "Gare la plus proche",
walkingTime: "Temps de marche",
minutes: "minutes",
calculating: "Calcul en cours",
showOthers: "Afficher les autres",
hideOthers: "Cacher les autres",
endOfService: "Fin du service",
errorTitle: "Erreur",
unexpectedError: "Une erreur inattendue est survenue",
},
};
const SubwayTimer: React.FC = () => {
const [location, setLocation] = useState<Location.LocationObject | null>(
null
);
const [nearestStation, setNearestStation] = useState<Station | null>(null);
const [walkingTime, setWalkingTime] = useState<number | null>(null);
const [subwaySchedules, setSubwaySchedules] = useState<SubwaySchedule[]>([]);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [log, setLog] = useState<string[]>([]);
const [showOtherDirections, setShowOtherDirections] = useState(false);
const [currentLanguage, setCurrentLanguage] = useState<Languages>("ja");
const [translatedStationName, setTranslatedStationName] =
useState<string>("");
const [translatedSchedules, setTranslatedSchedules] = useState<
SubwaySchedule[]
>([]);
const apiKey = Constants.expoConfig?.extra?.API_KEY;
const subwayApiKey = Constants.expoConfig?.extra?.SUBWAY_API_KEY;
const translateApiKey = Constants.expoConfig?.extra?.TRANSLATE_API_KEY;
const translateService = new TranslateService(translateApiKey || "");
const requestLocationPermission = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
throw new Error(
currentLanguage === "ja"
? "位置情報の許可が必要です"
: "Location permission is required"
);
}
};
const getCurrentLocation = async () => {
try {
return await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
} catch (error) {
throw new Error(
currentLanguage === "ja"
? "現在位置を取得できませんでした"
: "Could not get current location"
);
}
};
const findNearestSubwayStation = async (
lat: number,
lng: number
): Promise<Station> => {
try {
const response = await fetch(
`https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${lat},${lng}&radius=1000&type=subway_station&key=${apiKey}`
);
const data = await response.json();
if (!response.ok) {
throw new Error(
`${
currentLanguage === "ja"
? "最寄り駅の検索に失敗しました"
: "Failed to search nearest station"
}: ${data.status || response.status} - ${
data.error_message || "Unknown error"
}`
);
}
if (!data.results || data.results.length === 0) {
throw new Error(
currentLanguage === "ja"
? "近くに地下鉄駅が見つかりませんでした"
: "No subway stations found nearby"
);
}
const station = data.results[0];
if (
!station.geometry?.location?.lat ||
!station.geometry?.location?.lng
) {
throw new Error(
currentLanguage === "ja"
? "駅の座標情報が不正です"
: "Invalid station coordinate information"
);
}
return {
name: station.name,
coords: {
lat: station.geometry.location.lat,
lng: station.geometry.location.lng,
},
};
} catch (error) {
if (error instanceof Error) {
console.error("Station search error:", error.message);
if (__DEV__) {
console.error("Full error:", error);
}
throw error;
}
throw new Error(
currentLanguage === "ja"
? "駅情報の取得に失敗しました"
: "Failed to get station information"
);
}
};
const calculateWalkingTime = async (
origin: Coordinates,
destination: Station["coords"]
): Promise<number> => {
try {
const response = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?origin=${origin.latitude},${origin.longitude}&destination=${destination.lat},${destination.lng}&mode=walking&key=${apiKey}`
);
if (!response.ok) {
throw new Error(
currentLanguage === "ja"
? "経路の取得に失敗しました"
: "Failed to get route information"
);
}
const data = await response.json();
if (
!data.routes?.length ||
!data.routes[0].legs?.length ||
!data.routes[0].legs[0].duration
) {
throw new Error(
currentLanguage === "ja"
? "経路情報が不正です"
: "Invalid route information"
);
}
return Math.ceil(data.routes[0].legs[0].duration.value / 60);
} catch (error) {
throw new Error(
currentLanguage === "ja"
? "徒歩時間の計算に失敗しました"
: "Failed to calculate walking time"
);
}
};
const postNearestStationAndWalkingTime = async (
stationName: string,
walkingMinutes: number,
latitude: number,
longitude: number
): Promise<void> => {
try {
if (!subwayApiKey) {
throw new Error(
currentLanguage === "ja"
? "SUBWAY_API_KEYが設定されていません"
: "SUBWAY_API_KEY is not configured"
);
}
const requestBody = JSON.stringify({
nearestStation: stationName,
walkingMinutes: walkingMinutes,
location: {
latitude: latitude,
longitude: longitude,
},
});
const response = await fetch(
"https://udtetq5gol.execute-api.ap-northeast-1.amazonaws.com/staging",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": subwayApiKey.trim(),
},
body: requestBody,
}
);
const responseText = await response.text();
if (!response.ok) {
let errorMessage =
currentLanguage === "ja"
? "データの送信に失敗しました"
: "Failed to send data";
try {
const errorData = JSON.parse(responseText) as APIError;
errorMessage = errorData.message || errorMessage;
} catch (e) {
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
const jsonResponse = JSON.parse(responseText);
const scheduleData = JSON.parse(jsonResponse.body);
setSubwaySchedules(scheduleData);
} catch (error) {
if (error instanceof Error) {
throw new Error(
`${
currentLanguage === "ja"
? "データ送信エラー"
: "Data transmission error"
}: ${error.message}`
);
}
throw new Error(
currentLanguage === "ja"
? "データの送信に失敗しました"
: "Failed to send data"
);
}
};
const logLocation = (location: Location.LocationObject) => {
const logEntry =
currentLanguage === "ja"
? `位置情報: 緯度: ${location.coords.latitude}, 経度: ${location.coords.longitude}`
: `Location: Latitude: ${location.coords.latitude}, Longitude: ${location.coords.longitude}`;
setLog((prevLog) => [...prevLog, logEntry]);
};
const hasColoredSchedules = (schedules: SubwaySchedule[]): boolean => {
return schedules.some((schedule) => schedule.colored);
};
const formatDepartureInfo = (schedule: SubwaySchedule) => {
if (!schedule.nextTrains || schedule.nextTrains.length === 0) {
return uiText[currentLanguage].endOfService;
}
const [first, ...rest] = schedule.nextTrains;
return `${schedule.currentHour} ${first}${
uiText[currentLanguage].minutes
} ${
rest.length > 0
? `(${rest.join(uiText[currentLanguage].minutes + " ")}${
uiText[currentLanguage].minutes
})`
: ""
}`;
};
const initializeSubwayTimer = async () => {
try {
setIsLoading(true);
// Ensure API keys are available
if (!apiKey || !subwayApiKey || !translateApiKey) {
throw new Error(
currentLanguage === "ja"
? "APIキーが設定されていません"
: "API keys are not configured"
);
}
// Request location permission
await requestLocationPermission();
// Get the current location
const currentLocation = await getCurrentLocation();
logLocation(currentLocation);
// Find the nearest subway station
const station = await findNearestSubwayStation(
currentLocation.coords.latitude,
currentLocation.coords.longitude
);
setNearestStation(station);
// Calculate walking time
const walkingMinutes = await calculateWalkingTime(
currentLocation.coords,
station.coords
);
setWalkingTime(walkingMinutes);
// Post the nearest station and walking time to the API
await postNearestStationAndWalkingTime(
station.name,
walkingMinutes,
currentLocation.coords.latitude,
currentLocation.coords.longitude
);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
if (error instanceof Error) {
setError(error.message);
}
}
};
const translateSchedule = async (
schedule: SubwaySchedule,
targetLang: Languages
): Promise<SubwaySchedule> => {
if (!translateService || targetLang === "ja") {
return schedule;
}
try {
const [translatedLinetype, translatedDirection, translatedHour] =
await translateService.translateBatch(
[
schedule.linetype,
schedule.direction,
schedule.currentHour, // 「時」を含めたまま翻訳
],
targetLang
);
return {
...schedule,
linetype: translatedLinetype,
direction: translatedDirection,
currentHour: translatedHour, // 翻訳された時刻をそのまま使用
};
} catch (error) {
console.error("Schedule translation error:", error);
return schedule;
}
};
const translateSchedules = async (
schedules: SubwaySchedule[],
targetLang: Languages
): Promise<SubwaySchedule[]> => {
if (!translateService || targetLang === "ja") {
return schedules;
}
try {
return await Promise.all(
schedules.map((schedule) => translateSchedule(schedule, targetLang))
);
} catch (error) {
console.error("Schedules translation error:", error);
return schedules;
}
};
useEffect(() => {
initializeSubwayTimer();
}, []);
useEffect(() => {
const translateStation = async () => {
if (nearestStation?.name) {
try {
// `currentLanguage` に基づいて翻訳
const translated = await translateService.translate(
nearestStation.name,
currentLanguage
);
setTranslatedStationName(translated);
} catch (error) {
console.error("Station name translation error:", error);
}
} else {
// nearestStation?.name がない場合は翻訳しない
setTranslatedStationName("");
}
};
translateStation();
}, [currentLanguage, nearestStation?.name]);
useEffect(() => {
const translateCurrentSchedules = async () => {
const translated = await translateSchedules(
subwaySchedules,
currentLanguage
);
setTranslatedSchedules(translated);
};
translateCurrentSchedules();
}, [subwaySchedules, currentLanguage]);
if (isLoading) {
return <ThemedText>{uiText[currentLanguage].loading}</ThemedText>;
}
if (error) {
return (
<ThemedText>
{uiText[currentLanguage].error}
{error}
</ThemedText>
);
}
const hasColored = hasColoredSchedules(subwaySchedules);
return (
<View>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10,
}}
>
{/* LanguageSelector コンポーネントをここに埋め込む */}
<LanguageSelector
currentLanguage={currentLanguage}
setCurrentLanguage={setCurrentLanguage}
/>
{/* タイマーリセットボタン */}
<Button title="🔁" onPress={initializeSubwayTimer} />
</View>
<ThemedText>
{uiText[currentLanguage].nearestStation}
{translatedStationName ||
nearestStation?.name ||
uiText[currentLanguage].calculating}
</ThemedText>
<ThemedText>
{uiText[currentLanguage].walkingTime}
{walkingTime
? `${walkingTime}${uiText[currentLanguage].minutes}`
: uiText[currentLanguage].calculating}
</ThemedText>
{Array.isArray(translatedSchedules) &&
translatedSchedules
.filter((schedule) => schedule.colored)
.map((schedule, index) => (
<Text
key={`colored-${index}`}
style={{
color: "#4A90E2",
marginVertical: 4,
fontWeight: "bold",
}}
>
{schedule.linetype} {schedule.direction}:
{formatDepartureInfo(schedule)}
</Text>
))}
{hasColored && (
<TouchableOpacity
onPress={() => setShowOtherDirections(!showOtherDirections)}
style={{
padding: 8,
marginVertical: 8,
backgroundColor: "#f0f0f0",
borderRadius: 4,
}}
>
<Text style={{ color: "gray" }}>
{showOtherDirections
? uiText[currentLanguage].hideOthers
: uiText[currentLanguage].showOthers}
</Text>
</TouchableOpacity>
)}
{Array.isArray(translatedSchedules) &&
(!hasColored || showOtherDirections) &&
translatedSchedules
.filter((schedule) => !schedule.colored)
.map((schedule, index) => (
<Text
key={`other-${index}`}
style={{
color: "black",
marginVertical: 4,
}}
>
{schedule.linetype} {schedule.direction}:
{formatDepartureInfo(schedule)}
</Text>
))}
</View>
);
};
export default SubwayTimer;
まとめ
これでこのアプリも世界レベルに近づきましたので、次は地下鉄以外の乗り物に挑戦したいと思います。
それではまた、ごきげんよう🍀