🍜

山岡家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

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

npm install

Amplify Gen2で公開設定

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

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

データ取得と加工

スクレイピング

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

店舗名を取得する例

from playwright.sync_api import sync_playwright
import pandas as pd

def scrape_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 url in 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': name or '不明'})
            except Exception as 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 typing import Tuple
from geopy import 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 json
import pandas as pd
def create_geojson_features(df):
    features = []
    for _, row in df.iterrows():
        properties = {}
        for col in df.columns:
            if col not in ['lat', 'lon']:
                value = row[col]
                if pd.isna(value):
                    properties[col] = None
                else:
                    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 maplibregl from 'maplibre-gl';
import OpacityControl from '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 = new maplibregl.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
});

レイヤ設定

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

interface LayerConfig {
    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データをレイヤとして追加します。ズームレベルに応じてアイコンサイズが変化するよう設定しています。

function addGeoJsonLayer(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,
        }
    });
}

ポップアップ実装

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

function createPopupContent(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 = new OpacityControl({
    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

MIERUNEのZennブログ

Discussion