const { useState, useEffect } = React; const { createRoot } = ReactDOM; // 検索入力のデバウンス時間(ミリ秒) const SEARCH_DEBOUNCE_MS = 300; // 区分(kind)の表示順。サーバーの enum 定義順に合わせる const KIND_ORDER = ['todofuken', 'tokubetsu_ku', 'seirei_shitei_toshi', 'chukaku_shi', 'shikouji_tokurei_shi', 'ippan_shi', 'cho', 'mura']; // 主要区分(都道府県〜中核市)のセクションを区別するための border 色。 // その他の区分(一般市以下)は強調しない const KIND_BORDER_COLORS = { todofuken: '#0d6efd', tokubetsu_ku: '#b8860b', seirei_shitei_toshi: '#6f42c1', shikouji_tokurei_shi: '#0f766e', chukaku_shi: '#dc3545', }; // 指標のアイコン。ホバーで CSS ツールチップ(data-label の中身)に指標名を表示する const MetricIcon = ({ label, iconClass }) => ( ); // 比較モード中にカード下部へ出す操作エリア。 // カード全体が なので、preventDefault / stopPropagation でカルテへの遷移を止める const CompareControl = ({ shichoson, compareFirstId, onToggleFirst }) => { if (compareFirstId && compareFirstId !== shichoson.id) { // 1つめが選ばれていれば、他のカードは「このまちと比較」で比較画面へ return (
); } // 1つめの選択チェックボックス(選択済みカードはチェック解除で選び直せる) return (
{ e.preventDefault(); e.stopPropagation(); onToggleFirst(shichoson.id); }} >
); }; // 自治体カード。クリックで3分カルテへ遷移する const ShichosonCard = ({ shichoson, compareMode, compareFirstId, onToggleFirst }) => (

{shichoson.name}

{shichoson.top_type_name && ( {shichoson.top_type_name} )}
{Object.entries(shichoson.metrics).map(([key, metric]) => (
{metric.text === null ? '—' : metric.text}
))}
{shichoson.summary_50 &&

{shichoson.summary_50}

} {compareMode && ( )}
); // 区分(kind)ごとのサブ見出しを KIND_ORDER の順で作る const groupByKind = (shichosons) => KIND_ORDER.flatMap((kind) => { const items = shichosons.filter((shichoson) => shichoson.kind === kind); return items.length > 0 ? [{ kind, kindName: items[0].kind_name, shichosons: items }] : []; }); // 地域ブロックごとのアコーディオン。開いたときだけ中の自治体カードを描画する const RegionAccordionItem = ({ group, isOpen, onToggle, compareMode, compareFirstId, onToggleFirst }) => (

{groupByKind(group.shichosons).map((kindGroup) => { const borderColor = KIND_BORDER_COLORS[kindGroup.kind]; return (

{kindGroup.kindName}

{kindGroup.shichosons.length}
{kindGroup.shichosons.map((shichoson) => (
))}
); })}
); // サーバーの並び順(都道府県コード順・名前順)を保ったまま地域ブロックごとにまとめる。 // 都道府県コード順では同じ地域の都道府県が隣り合わないことがあるため、連続でなくても // 同じ region は同じグループにまとめる(例: 北関東甲信の ibaraki..gunma と yamanashi..nagano) const groupByRegion = (shichosons) => { const groups = []; const groupByRegion = new Map(); shichosons.forEach((shichoson) => { const group = groupByRegion.get(shichoson.region); if (group) { group.shichosons.push(shichoson); } else { const newGroup = { region: shichoson.region, regionName: shichoson.region_name, shichosons: [shichoson], }; groupByRegion.set(shichoson.region, newGroup); groups.push(newGroup); } }); return groups; }; // URL の ?compare= から比較モードの初期状態を読み取る。 // 「compare=1」はトグルONのみ、「compare=」は1つめのまちも選択済みにする(3分カルテの「くらべる」導線) const initialCompareParam = () => new URLSearchParams(window.location.search).get('compare'); // URL の ?name= から検索ワードを読み取る(LPトップの検索フォームからの遷移を受ける) const initialNameParam = () => new URLSearchParams(window.location.search).get('name') || ''; // 自治体一覧。名前・データ種別での絞り込みはリアクティブに /api/shichosons を叩く const ShichosonIndex = ({ apiUrl }) => { const [name, setName] = useState(initialNameParam); const [checkedFeatures, setCheckedFeatures] = useState({}); const compareParam = initialCompareParam(); const [compareMode, setCompareMode] = useState(compareParam !== null); const [compareFirstId, setCompareFirstId] = useState(compareParam && compareParam !== '1' ? compareParam : null); const [shichosons, setShichosons] = useState([]); const [featureLabels, setFeatureLabels] = useState({}); const [openRegions, setOpenRegions] = useState({}); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); const activeFeatures = Object.keys(checkedFeatures).filter((feature) => checkedFeatures[feature]); const isFiltering = name.trim() !== '' || activeFeatures.length > 0; // 入力からデバウンスして検索する。マウント時も空クエリで初期一覧を取得する useEffect(() => { let isCancelled = false; const timerId = setTimeout(async () => { setIsLoading(true); setError(''); try { const query = new URLSearchParams({ name: name }); activeFeatures.forEach((feature) => query.set(`feature_${feature}`, 'true')); const response = await fetch(`${apiUrl}?${query}`); if (isCancelled) return; if (response.ok) { const data = await response.json(); setShichosons(data.shichosons); setFeatureLabels(data.feature_labels); // 絞り込み中は該当地域ブロックをすべて開き、絞り込み解除で閉じる const allOpen = {}; if (isFiltering) data.shichosons.forEach((s) => { allOpen[s.region] = true; }); setOpenRegions(allOpen); } else { setError('検索に失敗しました'); } } catch (e) { if (isCancelled) return; console.error('Error:', e); setError('ネットワークエラーが発生しました'); } finally { if (!isCancelled) setIsLoading(false); } }, SEARCH_DEBOUNCE_MS); // 入力が続いた場合は前回のリクエスト結果を破棄する return () => { isCancelled = true; clearTimeout(timerId); }; }, [name, JSON.stringify(activeFeatures), apiUrl]); const toggleFeature = (feature) => { setCheckedFeatures((prev) => ({ ...prev, [feature]: !prev[feature] })); }; const toggleRegion = (region) => { setOpenRegions((prev) => ({ ...prev, [region]: !prev[region] })); }; // 比較モードのON/OFF。OFFにしたら選択もリセットする const toggleCompareMode = () => { setCompareMode((prev) => !prev); setCompareFirstId(null); }; // 1つめのまちの選択/解除(同じカードをもう一度押すと解除) const onToggleFirst = (id) => { setCompareFirstId((prev) => (prev === id ? null : id)); }; const groups = groupByRegion(shichosons); const compareFirstShichoson = shichosons.find((shichoson) => shichoson.id === compareFirstId); return (
setName(e.target.value)} />
データ種別で絞り込む
{Object.keys(featureLabels).map((feature) => (
toggleFeature(feature)} />
))}

{isLoading ? '検索中...' : `${shichosons.length}件の自治体`}

{compareMode && (
{compareFirstId ? `「${compareFirstShichoson ? compareFirstShichoson.name : '選択中のまち'}」と比較するまちの「このまちと比較」を押してください。` : 'まず1つめのまちをカードのチェックボックスで選んでください。'}
)} {error && (
{error}
)} {!error && !isLoading && shichosons.length === 0 ? (
条件に合う自治体が見つかりませんでした。検索条件を変えてお試しください。
) : (
{groups.map((group) => ( toggleRegion(group.region)} compareMode={compareMode} compareFirstId={compareFirstId} onToggleFirst={onToggleFirst} /> ))}
)}
); }; function initializeApp() { const container = document.getElementById('shichosons-root'); if (container) { try { const { apiUrl } = container.dataset; const root = createRoot(container); root.render(); } catch (error) { console.error('Error rendering ShichosonIndex component:', error); } } else { console.error('shichosons-root element not found!'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeApp); } else { initializeApp(); }