Movatterモバイル変換


[0]ホーム

URL:


MIERUNEのZennブログMIERUNEのZennブログ
MIERUNEのZennブログPublicationへの投稿
🍜

山岡家Map(非公式)を作った話

に公開
!

https://qiita.com/advent-calendar/2025/mierune

img

山岡家Map(非公式)について

みなさんは、ラーメン山岡家という素晴らしいラーメンチェーンを知っていますでしょうか?

ラーメン山岡家は、1988年に茨城県牛久市で創業した全国展開しているラーメンチェーンです。濃厚な豚骨スープと、自由にカスタマイズできるのが特徴で、24時間営業の店舗も多く、トラックドライバーや深夜に働く方々にも利用されています。私自身も20年以上通い続けるファンで、ホームは札幌の伝説的な名店「南2条店」です。いつも醤油ラーメンのアブラ少なめで注文しています。

https://x.com/dayjournal_nori/status/1999078121426030680

今年のAWS Summit Japan 2025をきっかけに、私も自分の専門領域で何か応援できないかと考えていました。

https://x.com/dayjournal_nori/status/1937737622954381592

そして、非公式の山岡家Mapというアプリケーションを構築してみました。このマップを利用することで、全国のラーメン山岡家の店舗情報を地図上でシームレスに確認できます。

https://yama.dayjournal.dev

PWA対応もしているのでスマートフォンのホーム画面にも追加可能です。
追加方法:
・iOS(Safari):共有ボタン → 「ホーム画面に追加」
・Android(Chrome):メニュー → 「ホーム画面に追加」

ラーメン山岡家の店舗タイプ

ラーメン山岡家には、実は4つの店舗タイプがあります。

1. ラーメン山岡家
通常の山岡家で、豚骨ベースの定番メニューを提供しています。全国に150店舗以上展開されています。私はいつも醤油ラーメンを注文します。

2. 煮干しラーメン山岡家
煮干しをベースにしたラーメンを提供する専門店です。通常の山岡家とは異なる味わいが楽しめます。私は煮干しが苦手なので実は1回も行ったことがありません。

3. 味噌ラーメン山岡家
味噌ラーメンに特化した店舗で、濃厚な味噌スープが特徴です。ここでは、あえて醤油ラーメンを注文するのがおすすめです。実は北海道のみに3店舗しかありません。

4. 餃子の山岡家
餃子をメインにした新業態の店舗です。この店舗は日本で1店舗だけで、札幌にあります。実はここにも1回も行ったことがありません。

今回公開したマップでは、この4種類の店舗をアイコン表示し、レイヤ切り替えで表示/非表示できるようにしています。

事前準備

公式サイトに問い合わせ

今回、スクレイピングを行うので事前に公式サイトに確認をしました。事前相談したところ、大変温かいお返事をいただき、またすぐに食べに行きたくなりました。

img

データ取得と加工

今回、スクレイピングにはPythonを利用します。Playwrightとpandasとgeopyを組み合わせてデータ取得と加工をします。

  • スクレイピング: Playwright
  • データ加工: pandas
  • DMS→DD変換: geopy

全体構成

yamaokaya-data└── script    ├── scrape_yamaokaya.py# スクレイピング    ├── latlon_yamaokaya.py# DMS→DD変換    ├── column_yamaokaya.py# カラム名変更    ├── csv2geojson.py# CSV→GeoJSON変換

マップアプリケーション

事前に、Amazon Location Service v2のスターターをforkします。その後、山岡家Mapに必要なファイルとコードを追記します。

https://github.com/mug-jp/maplibregljs-amazon-location-service-starter

実行環境

  • node v24.4.1
  • npm v11.4.2

全体構成

yamaokaya-map├── LICENSE├── README.md├── dist│   └── index.html├── img│   ├── README01.gif│   ├── README02.png│   └── README03.png├── index.html├── package-lock.json├── package.json├── public│   ├── manifest.json│   ├── data│   │   ├── yama.geojson│   │   ├── niboshi.geojson│   │   ├── miso.geojson│   │   └── gyouza.geojson│   └── icons│       ├── yama.png│       ├── niboshi.png│       ├── miso.png│       └── gyouza.png├── src│   ├── main.ts│   ├── style.css│   └── vite-env.d.ts├── tsconfig.json└── vite.config.ts

パッケージをインストールします。

npminstall

Amplify Gen2で公開設定

以前、書いた記事を参考にAmplify Console(Gen2)でGitHubを利用した公開をします。リポジトリはforkしたスターターを利用します。

https://memo.dayjournal.dev/memo/aws-amplify-016

データ取得と加工

スクレイピング

公式サイトから店舗情報をスクレイピングします。公式サイトは動的にコンテンツを生成しているため、Playwrightを利用しブラウザを操作しながらデータを取得しています。各店舗の詳細ページから、店舗名・住所・電話番号・営業時間・駐車場・座席の種類・シャワー室の有無・詳細ページのURL・店舗の位置情報などを取得しています。

店舗名を取得する例

from playwright.sync_apiimport sync_playwrightimport pandasas pddefscrape_yamaokaya_shops():    shops=[]with sync_playwright()as p:        browser= p.chromium.launch(headless=True)        context= browser.new_context(            user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',)        context.set_default_timeout(10000)        context.set_default_navigation_timeout(10000)         page= context.new_page()        main_url="https://www.yamaokaya.com/shops/"        page.goto(main_url, wait_until='networkidle', timeout=10000)        page.wait_for_timeout(5000)        shop_links= page.eval_on_selector_all('a[href*="/shops/"]','els => [...new Set(els.map(el => el.href).filter(href => /shops\\/\\d+/.test(href)))]')for urlin shop_links:try:                page.goto(url, wait_until='domcontentloaded', timeout=10000)                page.wait_for_timeout(5000)                name= page.evaluate("""() => {                    const h = document.querySelector('h2, h1, .shop-name');                    return h?.innerText?.trim() || document.title.split('|')[0].trim();                }""")                shops.append({'url': url,'name': nameor'不明'})except Exceptionas e:                shops.append({'url': url,'name':'エラー'})        browser.close()return pd.DataFrame(shops)if __name__=="__main__":    df= scrape_yamaokaya_shops()    df.to_csv('yamaokaya_shops.csv', index=False, encoding='utf-8-sig')

DMS→DD変換

スクレイピングで取得した位置情報は、DMS形式(度分秒)で取得されているため、マップライブラリで表示するためにDD形式(十進度)に変換します。geopyも利用し、複数の変換パターンに対応しています。

DMS→DD変換例

from typingimport Tuplefrom geopyimport Point# 変換前 "43°03'28.6""N 141°21'22.2""E"def_convert_with_geopy(dms_string:str)-> Tuple[float,float]:    cleaned= dms_string.replace('""','"')    point= Point(cleaned)return point.latitude, point.longitude

カラム名変更

データをGeoJSONに変換する前に、日本語のカラム名を英語に変更します。

カラム名変更例

column_mapping={'店舗名':'store_name','住所':'address','電話番号':'phone_number','営業時間':'business_hours','駐車場':'parking','座席の種類':'seating_types','シャワー室':'shower_room','その他':'other_info'}df_renamed= df.rename(columns=column_mapping)

CSV→GeoJSON変換

最後に、CSVをGeoJSON形式に変換します。店舗タイプごとにファイルを分けて出力しています。

CSV→GeoJSON変換例

import jsonimport pandasas pddefcreate_geojson_features(df):    features=[]for _, rowin df.iterrows():        properties={}for colin df.columns:if colnotin['lat','lon']:                value= row[col]if pd.isna(value):                    properties[col]=Noneelse:                    properties[col]=str(value)        feature={"type":"Feature","geometry":{"type":"Point","coordinates":[row['lon'], row['lat']]},"properties": properties}        features.append(feature)return features

GeoJSONの出力結果

{"type":"Feature","geometry":{"type":"Point","coordinates":[141.3561,43.0579]},"properties":{"store_name":"ラーメン山岡家 南2条店","details":"https://www.yamaokaya.com/shops/1102/","address":"札幌市中央区南2条西1丁目6-1","phone_number":"(011) 242-4636","business_hours":"5:00-翌4:00","parking":"なし","seating_types":"カウンター席: 13","shower_room":"なし","other_info":"まちなかのちいさなお店です。"}},

マップアプリケーション作成

背景地図設定

今回、マップライブラリはMapLibre GL JS、背景地図はAmazon Location Serviceを利用し組み合わせています。

import'./style.css';import'maplibre-gl/dist/maplibre-gl.css';import'maplibre-gl-opacity/dist/maplibre-gl-opacity.css';import maplibreglfrom'maplibre-gl';import OpacityControlfrom'maplibre-gl-opacity';const region=import.meta.env.VITE_REGION;const mapApiKey=import.meta.env.VITE_MAP_API_KEY;const mapName=import.meta.env.VITE_MAP_NAME;const map=newmaplibregl.Map({    container:'map',    style:`https://maps.geo.${region}.amazonaws.com/maps/v0/maps/${mapName}/style-descriptor?key=${mapApiKey}`,    center:[138.0000,38.5000],    zoom: baseZoom,    maxZoom:20});

レイヤ設定

店舗タイプごとにレイヤを設定し、カスタムアイコンを設定します。

interfaceLayerConfig{    name:string;    iconPath:string;    iconId:string;    visible:boolean;}const layerConfigs: Record<string, LayerConfig>={'gyouza':{        name:'餃子の山岡家',        iconPath:'icons/gyouza.png',        iconId:'gyouza-icon',        visible:true},'miso':{        name:'味噌ラーメン山岡家',        iconPath:'icons/miso.png',        iconId:'miso-icon',        visible:true},'niboshi':{        name:'煮干しラーメン山岡家',        iconPath:'icons/niboshi.png',        iconId:'niboshi-icon',        visible:true},'yama':{        name:'ラーメン山岡家',        iconPath:'icons/yama.png',        iconId:'yama-icon',        visible:true}};

GeoJSONレイヤ追加

GeoJSONデータをレイヤとして追加します。ズームレベルに応じてアイコンサイズが変化するよう設定しています。

functionaddGeoJsonLayer(id:string, config: LayerConfig, data: GeoJSONData):void{    map.addSource(id,{        type:'geojson',        data: data});    map.addLayer({        id: id,        type:'symbol',        source: id,        layout:{'icon-image': config.iconId,'icon-size':['interpolate',['linear'],['zoom'],6, baseIconSize*0.5,10, baseIconSize*0.6,14, baseIconSize*0.7,18, baseIconSize*0.8],'icon-allow-overlap':true,'icon-ignore-placement':false,},        paint:{'icon-opacity':1.0,}});}

ポップアップ実装

店舗をクリックすると、店舗情報をポップアップで表示します。住所・電話番号・営業時間・駐車場・座席の種類などの情報を表示しています。

functioncreatePopupContent(props: StoreProperties):string{const contentParts:string[]=[];if(props.store_name){        contentParts.push(`<h3>${props.store_name}</h3>`);}const details:string[]=[];if(props.address){        details.push(`<strong>住所:</strong>${props.address}`);}if(props.phone_number){        details.push(`<strong>電話:</strong> <a href="tel:${props.phone_number}">${props.phone_number}</a>`);}// ...}

レイヤ切り替え

maplibre-gl-opacityを利用し、レイヤの表示/非表示を実装しています。

const overLayers={'yama':'ラーメン山岡家','niboshi':'煮干しラーメン山岡家','miso':'味噌ラーメン山岡家','gyouza':'餃子の山岡家',};const opacityControl=newOpacityControl({    overLayers: overLayers,    opacityControl:false});map.addControl(opacityControl,'bottom-left');

まとめ

今回、Playwrightでのスクレイピング、geopyでのDMS→DD変換、CSV→GeoJSON変換、MapLibre GL JS & Amazon Location Serviceでのマップアプリケーションという構成で「山岡家Map(非公式)」を構築しました。地図で可視化してみると新たな発見があります。最北端は稚内、東京は周辺に出店、九州には進出しているのに四国には店舗が無い、そして餃子の山岡家は全国でたった1店舗。ラーメン山岡家の出店戦略が見えてきます。

img

ぜひ近くの店舗を探したり、旅行先でラーメン山岡家を見つける時にご利用ください!

https://x.com/dayjournal_nori/status/1993092273698226233

あと、最近知ったのですが、山岡家商店という公式通販サイトがありまして、チャーシューを注文してみようと思っています。どんぶりやレンゲも販売しているようです!

https://shop.yamaokaya.jp


!

他にも記事を書いています。よろしければぜひ。

https://zenn.dev/mierune/articles/try-117-amplify-location

https://memo.dayjournal.dev/tags/maplibregljs/

https://day-journal.com/memo/tags/Try

!

PodcastとYouTubeやってます。よろしければぜひ。

MIERUNEのZennブログ により固定

MIERUNE (みえるね) は、GIS (地理情報システム) を専門とする会社です。❤️ #FOSS4G !

Yasunori Kirimoto

Co-Founder and CEO of MIERUNE|Geospatial Architect|AWS DevTools Hero|Community - MapLibre, Amplify, Notion

MIERUNEのZennブログ

MIERUNEはオープンソースコミュニティから生まれた位置情報技術のプロフェッショナル集団です。お客様の課題に伴走し、「位置」の力を「価値」に変えるソリューションを生み出します。また、コミュニティへの貢献を続けることで、技術と文化を共有し次世代の育成に努めています。

バッジを贈って著者を応援しよう

バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。


[8]ページ先頭

©2009-2025 Movatter.jp