オンラインハッカソンへの応募
Devpostという海外で実施されているハッカソンの一覧が載っているサイトとGoogleの主催する、Google map APIを使うことを条件にしたハッカソンが11/14 (東海岸時間)締め切りで開催されていたので、農作物バイヤー向け農地管理アプリを作って応募してみました。
9月末にAWSのAmplifyを使うことを条件にしたハッカソンでは入賞できたので、2匹目のドジョウと行きたいところですが、前回は参加者数≒10位入賞者数だったのに対し、今度は参加者2000人ということで、入賞はかなり厳しそうですがGoogle map APIの勉強になると思い、頑張ってみました。
結果入賞ならず。。やはりGoogle Mapに新しく導入されたWebGLを活用した立体表示機能を活用することが入賞のカギだったようです。
提出した開発内容の記事
開催要項
提出記事の日本語訳
説明動画をアップロードし、フォーマットに沿って説明を記載し、コードも提出しました。
以下応募の際に提出した内容の日本語訳です。(英語で書いたものをDeepLで和訳してほとんど直していないので不自然な表現残っていてもご容赦ください。)
Crop Buyer
作物(農産物)のバイヤーのためのウェブアプリを開発しました。
ユーザーは農場をポリゴンとしてプロットし、名前、説明、ステータスを入力し、GPSデータとともに写真をアップロードし、地図上で確認することができます。
インスピレーション
ターゲットユーザー
このアプリのターゲットユーザーは、農作物(農産物)のバイヤー(=Crop Buyer)です。
私たちの食べ物のほとんどは、農場で生産されたものです。それらは工場で輸送・加工され、私たちの家庭まで生きてきます。その際、重要な役割を果たすのが「農作物の買い手」です。
バイヤーの中には商社に所属し、輸出用の米や小麦などの農作物を調達している人もいます。また、ドライポテトのような工場に所属し、原料として農作物を調達するバイヤーもいます。
問題意識と動機
農作物のバイヤーにとって、農場の状況は、調達量を予測するために重要である。
バイヤーは頻繁に農園や生産者を訪れ、生産者と話をしたり、写真を撮ったりして、農園の状況を把握する。
生産者が自分の農場を記録するためのアプリはすでにたくさんありますが、バイヤー向けのアプリはなく、この種のアプリの需要は大きいので、Google mapを使ったMVPを開発することにしました!
Crop Buyerの機能
地図上にポリゴンで農園データを記録する
マップページに、ポリゴンを書き込んで、名前、説明、最新の状態(評価)をファームとして保存することができます。
ポリゴンを書き込むには、マップの上部にあるポリゴンボタンをクリックします。
農園情報を地図上に表示する
記録された農場は、地図上にポリゴンとして表示されます。
また、"農場情報 "スイッチをONにすると、地図上に農場情報が表示されます。スイッチをオンにすると、入力した農園情報が情報ウィンドウとして地図上に表示されます。スイッチをオフにすると、ポリゴン中央の半透明なマーカーにマウスオーバーするかクリックすることで、単一のファーム情報を見ることができます。
GPSデータ付きの画像をアップロードして、タイムライン付きの地図で見る
画像追加ページでは、PCまたはモバイルから画像をアップロードすることができます。PCの場合は、画像をドラッグ&ドロップします。最近のデジタルカメラで撮影した写真の多くは、撮影した場所のGPS情報と撮影時のタイムライン情報が画像ファイルに記録されています。アップロードされた写真は、地図上に表示されます。年ごとの切り替え」をオンにすると、表示する年を選択することができます。例えば、2018年を選択した場合、2018年に撮影された画像のみが地図上に表示されます。
農園の年間状況を色分けして地図上に表示する
「農園情報」と「写真」のスイッチをオフにして、「年別」のスイッチをオンにすると、ポリゴンが色で塗りつぶされているのが見えます。
色の意味は以下の通りです。
黄色 米
緑 大豆
濃い黄色(または青色)で塗りつぶされています。状態良好
薄い黄色(または青色)で塗りつぶされたもの。状態が悪い
スライダーを動かして年を変えると、2017年、18年は大豆(緑色)が栽培され、2019年以降は米(黄色)に変わり、2022年は米だけが栽培されたことが分かります。
Add or Edit Crops, Growers, Farm, Yearly Record of Farm.
作物の追加と編集
作物ページでは、あらかじめ設定されている作物に加え、作物の追加や編集ができます。
栽培者の追加と編集
生産者ページでは、生産者の追加と編集ができます。このアイコンをクリックすると、メールや電話での問い合わせができます。アイコンをクリックすると、その生産者の農場一覧ページが表示されます。
農園の追加・編集
農場を追加するには、まずMap Pageの地図上部にあるポリゴンボタンをクリックし、農場を書き込む必要があります。その後、農場の名前、説明、面積(ヘクタール)、最新の状態(評価)を入力し、保存します。
保存した農場を編集したい場合は、Growers Page から Farm List Page を開くと、編集できます。
農園の年間記録を追加・編集する
Growers Pageで、農場を持つGrowerを選択します。
->
農園一覧ページで農園を選択します。
->
次に、Record List Pageで、農園の年間記録を追加・編集します。
どのように構築したか
フロントエンドはReactとNextjsで構築しています。
ReactでGoogle Maps JavaScript API v3を利用するために、@react-google-maps/api を利用しています(使い方 参照)。
Google Maps Platform をプロジェクトでどのように使用しましたか?
Polygon:
- 地図ページで、農場をポリゴンで表示。
- 生産者ページ→各生産者の農園リストボタンからアクセスできる農園リストページで、農園の形を表示。
地図上に農園の形状をポリゴンで表示する。
インフォウィンドウ
- 地図ページで、情報ウィンドウにピクチャを表示する。
- 地図ページで、各農場の情報を情報ウィンドウに表示する。
図面マネージャ。
- 新しい農場の形をポリゴンとして追加するために使用します。
傾きと回転。
- ベクターマップを使い、傾きと回転のボタンをドキュメントに例を示すように設置しています。
オートコンプリート。
- 任意の場所にジャンプするために、マップの上部に設置しました。
ベクターマップ、傾きと回転のボタン
地図スタイルと建物の3Dスタイルを設定します。農園が小さく町の近くにある場合、その場所を把握するのに便利です。
options={{
mapId: String(process.env.NEXT_PUBLIC_GOOGLE_MAP_ID),
}}
マップIDをベクターマップに設定することで、下部のボタンで地図の傾きや上下、左右のボタンで地図の回転ができるようになりました。この機能はドキュメントをコピーペーストするだけでも追加できますが、ドローンやヘリコプターで飛んでいる農場を見るような体験ができるため、非常に便利な機能です。
export const addTiltRotateControl = (mapInstance: google.maps.Map) => { {.
const buttons: [
string,
文字列,
数値
google.maps.ControlPosition。
文字列
][] = [
[
"Rotate Left",
"rotate",
20,
google.maps.ControlPosition.LEFT_CENTER,
"20px",
],
[
"右回転",
"rotate",
-20,
google.maps.ControlPosition.RIGHT_CENTER,
"20px",
],
[
"チルトダウン",
"tilt",
20,
google.maps.ControlPosition.BOTTOM_CENTER,
"40px",
],
["Tilt Up", "tilt", -20, google.maps.ControlPosition.BOTTOM_CENTER, "40px"],
];
buttons.forEach(([text, mode, amount, position, buttonHeight]) => { )
const controlDiv = document.createElement("div");
const controlUI = document.createElement("button")。
// controlUI.style.width = "100px"; // 幅を200pxに設定する
controlUI.style.height = buttonHeight; // 高さを200pxに設定する。
// controlUI.style.background = "teal"; // 背景色をtealに設定する。
// controlUI.style.color = "ホワイト"; // カラーをホワイトに設定する
controlUI.style.fontSize = "14px"; // フォントサイズを20pxに設定する。
controlUI.classList.add("ui-button")。
controlUI.innerText = `${text}`;
controlUI.addEventListener("クリック", () => {
adjustMap(mode, amount);
});
controlDiv.appendChild(controlUI)を追加します。
mapInstance.controls[position].push(controlDiv)。
});
const adjustMap = function (mode: string, amount: number) {
switch (mode) {
case "tilt":
mapInstance.setTilt(mapInstance.getTilt()! + amount);
ブレーク
case "rotate" (回転):
mapInstance.setHeading(mapInstance.getHeading()! + amount);
break
デフォルトでは
break
}
};
};
React コンポーネントでコードを分離する
<GoogleMap
onLoad={mapOnLoad} です。
mapContainerStyle={containerStyle} です。
center={coordinates}
zoom={15}
mapTypeId="satellite"
options={{
mapId: String(process.env.NEXT_PUBLIC_GOOGLE_MAP_ID),
}}
>
<CreateAutocomplete setCoordinates={setCoordinates}. />
<CreateDrawingManager polygons={polygons} setPolygons={setPolygons} 。/>
<CreatePolygons
isShowFarmInfo={isShowFarmInfo}とする。
isYearly={isYearly}とする。
yearToShow={yearToShow}となります。
/>
{isShowPictures && (
<CreatePictureInfoWindows isYearly={isYearly} yearToShow={yearToShow}の場合。/>
)}
</GoogleMap
描画マネージャ
<DrawingManager
onLoad={onLoadDrawingManager}。
onPolygonComplete={onPolygonComplete}。
options={{
drawingControl: true,
drawingControlOptions: {
位置: google.maps.ControlPosition.TOP_RIGHT,
drawingModes: [google.maps.drawing.OverlayType.POLYGON],
},
}}
/>
地図上に農場を表示するためのPylygon、InfoWindow、Marker
<ポリゴン
draggable={true}
editable={true}
key={`${farm?.polygonString}-ポリゴン`}。
paths={paths}
options={poligonOptions}。
/>;
{
(farm.id == idMouseOvered || isShowFarmInfo) && (
<InfoWindow
// key={${farm?.polygonString}-InfoWindow`}を指定します。
position={polygonStrToCenterLatLng(farm?.polygonString || "")}となります。
>
<FarmInfoWindowView farm={farm}の場合。/>
</InfoWindow
);
}
<マーカ
// key={${farm?.polygonString}-Marker`}となります。
position={polygonStrToCenterLatLng(farm?.polygonString || "")}。
onMouseOver={() => setIdMouseOvered(farm.id)} ●onClick={() => setIdMouseOvered(farm.id)
onClick={() => setIdMouseOvered(farm.id)} ※この場合、onClick={() => setIdMouseOvered(farm.id)} となります。
opacity={0.1}
/>;
インフォウィンドウに画像を表示する
pictures.map((picture: Picture) => {)
return !isYearly || yearToShow == picture?.createYear ? (
<情報ウィンドウ
key={picture.s3KeyResized}とする。
position={{ lat: picture.lat || 0, lng: picture.lng || 0 }} となります。
オプション={{maxWidth: 250 }}。
>
<AmplifyS3Image
style={{ height: "100px". }}
imgKey={picture.s3KeyResized || ""}.
</InfoWindow>
) : (
<></>
);
});
地図の上にオートコンプリートウィンドウを表示する
``typescript
<オートコンプリート onLoad={onLoad} onPlaceChanged={onPlaceChanged}> のようになります。
(オートコンプリート
## Drag and Drop to Upload Images
I used react-dropzone.
```typescript
<section className="container">
<div
{...getRootProps()}
style={
isDragActive ? { ...dropzoneStyles, ...dropzoneActive } : dropzoneStyles
}
>
<input {...getInputProps()} />
<p>Drag & Drop Picture or Click Here to Upload</p>
</div>
{/* <AmplifyS3Album path={"resized/"} picker={false} /> */}
{pictures?.map((picture) => (
<AmplifyS3Image
key={picture?.s3KeyResized || ""}
imgKey={picture?.s3KeyResized || ""}
/>
))}
</section>
Resize and obtain GPS, timeline data before Update Images
I used react-image-file-resizer to resize image, exifr to obtain GPS and timeline data.
const onDrop = useCallback(async (acceptedFiles: FileWithPath[]) => {
acceptedFiles.forEach(async (acceptedFile: FileWithPath) => {
const fileName = acceptedFile.name。
const resizedBlob = dataURIToBlob(await resizeFile(acceptedFile))。
resizedFile = new File([resizedBlob], acceptedFile.name)とする。
await uploadImage(acceptedFile, "raw/" + fileName);
await uploadImage(resizedFile, "resized/" + fileName); await uploadImage(resizedFile, "resized/" + fileName);
const respExifrGps = await exifr.gps(acceptedFile);
if (respExifrGps?.latitude && respExifrGps?.longitude) { (respExifrGps?
const { 緯度: lat, 経度: lng } = respExifrGps;
}
const respExifrParse = await exifr.parse(acceptedFile, {
pick: ["CreateDate"],
});
if (respExifrParse?.CreateDate) {。
const { CreateDate }. = respExifrParse;
}
console.log(
"respExifrParse?.CreateDate.toISOString():",
respExifrParse?.CreateDate.toISOString()。
);
const picture = new Picture({
s3KeyRaw: `raw/${fileName}`,
s3KeyResized: `resized/${fileName}`,
urlRaw: `https://${config.aws_user_files_s3_bucket}.s3.${config.aws_user_files_s3_bucket_region}.amazonaws.com/public/raw/${fileName}`,
urlResized: `https://${config.aws_user_files_s3_bucket}.s3.${config.aws_user_files_s3_bucket_region}.amazonaws.com/public/resized/${fileName}`,
lat: respExifrGps?.緯度,
lng: respExifrGps?.longitude。
createDate: respExifrParse?.CreateDate.toISOString(),
createYear: respExifrParse?.CreateDate?.getFullYear(),
});
console.log("picture:", picture);
await DataStore.save(picture)。
});
}, []);
困ったこと
Google map APIを使うのは初めてだったので、慣れるまで少し時間がかかりました。また、Google mapのドキュメントはReactではなくJavaScriptのみのものが多く、Google mapのドキュメントに書かれていて@react-google-maps/apiのマニュアルに書かれていないものを利用するのが少し大変だった。
ポリゴンや情報ウィンドウをこんなにたくさん表示するのはどうかと思います。PCでは問題なかったのですが、携帯のWebブラウザでは遅くなってしまいます。
高度なマーカーやアニメーションを使いたかったのですが、@react-google-maps/api には記載がなく、公式ドキュメント以外では良いチュートリアルやコード例が見つからなかったため、使用を断念しました。
成果物について誇りに出来る部分
Google mapを使った開発は初めてでしたが、ある程度実用に耐えるWebアプリケーションを作ることができたと自負しています。
Google map API以前であれば、農地の情報を記録し、その写真と撮影場所を可視化するソフトを作るには、かなりの時間と労力とお金が必要でしたが、Google map APIのおかげで、ハッカソンでそのようなアプリケーションを作ることが可能になりました。Google map APIを使い始めたことを誇りに思いますが、その前に、素晴らしいAPIを提供してくれたGoogleに感謝したいと思います。
学んだこと
今回、Google map APIのおかげで、MVPに近いものを作ることができました。しかし、他にも実現したい機能はあったのですが、基本的な機能を作るのに、予想の3倍くらいの時間がかかってしまい、なかなかそこまでたどり着けませんでした。
よく「開発には予想の3倍から5倍の時間がかかる」と言われますが、よく考えてから開発に着手してください。ただ、基本的な機能を作って提出することができたので、まずは目標の一つを達成することができました。
次は何を作るか
当初は、飛行機が飛ぶアニメーションを表示するサンプルアプリを参考に旅行アプリを作りたかったのですが、Reactライブラリがまだ公開されておらず、良いサンプルコードも見つからなかったので断念しました。今回のハッカソンで他のチームが提出したコードを見て、実現するためのヒントが得られると思うので、次回はアニメーションを使った旅行アプリを作りたいと思います。(と英文の提出記事には書きましたが、本音では地図アプリは少しおなかいっぱいです。)
私について
ペンネームはtanosugiで、日本出身で日本に住んでいます。昔、学生時代にVisual C++を使ってアルバイトをしていましたが、今は非エンジニアリング系の仕事をしています。2-3年前に日本で出版された「個人開発をはじめよう」という本を読んで実践したくなり、趣味でコーディングを再開しました。UdemyでReact、Django、AWSなどを勉強した後、自分でいろいろなWebサービスを作っています。中には子供のためのサービスもあります。
まとめ
平日の子供が寝た後や休日にコーディングしたので、記事作成、動画撮影を含めると10日間で30〜40時間くらいです。集中ハッカソンなら2〜3日です。
イメージしていたアプリが作れたり、Google map APIの有用性が確認できたりと、とても有意義な時間でした。