出生率が2.07に戻っても、町の人口はしばらく減り続ける

高齢の人が多い町では、合計特殊出生率を人口置換水準の2.07まで上げても、亡くなる人が生まれる子を50年以上上回る。6,000人の住民を1人ずつ動かす町で確かめられる。

記事

出生率が2.07に戻っても、町の人口はしばらく減り続ける

高齢の人が多い町では、合計特殊出生率を人口置換水準の2.07まで上げても、亡くなる人が生まれる子を50年以上上回る。6,000人の住民を1人ずつ動かす町で確かめられる。

作成日 2026年11月3日・更新日 2026年11月3日・執筆・監修 佐々木 航

現象

生まれる子1人に、亡くなる人5人

2024年に秋田県で生まれた子は3,282人だった。同じ年に亡くなった人は17,421人で、生まれた子の5倍を超える。人口1,000人あたりの出生3.7人は全国でいちばん少なく、死亡19.6人はいちばん多い1。人口に占める65歳以上の人の割合(高齢化率)も39.5%で、全国でいちばん高い2。

全国でも、2024年に生まれた子は68万6173人と、1899年に人口動態の調査が始まってからいちばん少なかった。1人の女性が一生に生む子の数にあたる合計特殊出生率は1.15で過去最低になり、亡くなった160万5378人との差は91万9205人に広がった。

画面の町は、秋田県の2024年の男女・年齢の形を、そのまま6,000人に縮めた架空の町である。地図の塗りの四角が人の住む家、小さな点(大きな画面では白抜きの四角)が空き家で、朝と夕方に道を流れる点は、学校や職場へ通う人を表す。人口ピラミッドは、1歳ごとの人数を男女に分けて横棒で積み上げたもので、細い線が始まりの2025年の形だ。

子が少ないから人口が減るのなら、子が増えれば減少は止まるのか。工場や駅ができると、町の何が変わるのか。住民1人ずつの出生・死亡・引っ越しを1年ごとに数えていくと、答えが見えてくる。

仕組み

人口は4つの数で動く

1年で町の人口がどれだけ変わるかは、生まれた子、亡くなった人、町に来た人、町を出た人の4つで決まる。

は年の総人口(人)、は出生数、は死亡数、は転入した人の数、は転出した人の数である。を自然増減、を社会増減と呼ぶ。2024年の秋田県は、自然増減だけで人口の1.6%にあたる1万4139人を減らした。

生まれる子の数は、出生率だけでは決まらない。その年に歳の女性から生まれた子の数を、歳の女性の人数で割った値を、年齢別出生率と呼ぶ。2024年の30〜34歳なら、女性11人が1年に1人ほどの子を生んだ計算になる。15歳から49歳までの年齢別出生率を足したものが、合計特殊出生率(TFR)である。

その年の出生数は、年齢別出生率に、その年齢の女性の人数を掛けて足したものだ。

つまり、合計特殊出生率が同じでも、30歳前後の女性が少ない町では、生まれる子は少なくなる。

0.2 0.4 0.6 0.8 1.0 15〜19 20〜24 25〜29 30〜34 35〜39 40〜44 45〜49 母の年齢(歳) 合計特殊出生率の内訳(5歳ごとに各歳の出生率を足した値) 1985年(合計1.76) 2024年(合計1.15) 0.89 0.44 棒の高さを全部足すと、その年の合計特殊出生率になる
母の年齢ごとの合計特殊出生率の内訳(1985年と2024年)

1985年は25〜29歳の内訳が0.89でいちばん大きく、合計は1.76だった。2024年は30〜34歳の0.44がいちばん大きく、20歳代が大きく縮んで合計は1.15になった3。シミュレーションの出生は、この2024年の形のまま、つまみの合計特殊出生率に合わせて全体を伸び縮みさせる。

死亡の起こりやすさは、生命表で分かる。生命表は、各年齢の人が1年のうちに亡くなる確率などを、男女別に並べた表である。2024年の簡易生命表では、70歳の人が71歳になるまでに亡くなる確率は男性1.7%、女性0.7%で、90歳では男性15%、女性9.5%になる。平均寿命は男性81.09年、女性87.13年だ4。シミュレーションは毎年、住民1人ずつにこの確率で乱数を引き、亡くなるかどうかを決めている。

仕組み

2.07という数と、止まらない減少

親の世代と同じ数の子の世代を残すには、合計特殊出生率がいくつ要るか。2人の親から2人の子と考えると2.00になりそうだが、実際は2.07ほど要る。この値を人口置換水準と呼ぶ。

2024年 女性1人が生む子 1.15人 そのうち女の子 0.56人 母になる年まで生きる 0.56人 女の子が母になる年まで生き残る割合は0.99。1人の母が1人の母を残す合計特殊出生率(人口置換水準)は2.07 1925年 女性1人が生む子 5.10人 そのうち女の子 2.51人 母になる年まで生きる 1.65人 女の子が母になる年まで生き残る割合は0.66。1人の母が1人の母を残す合計特殊出生率(人口置換水準)は3.10
女性1人が生む子のうち、次に母となる女の子が何人残るか(2024年と1925年)

差が出る理由は2つある。1つは、生まれる子は男の子のほうが少し多いこと。2024年は女の子100人あたり男の子が105.0人で、女の子は生まれた子の48.8%だった。もう1つは、母になる年齢までに亡くなる女の子がいることだ。女性1人が生む女の子のうち、母になる年齢まで生きる人数を純再生産率と呼ぶ。

は女の子が母になる年齢まで生き残る割合で、2024年は0.99だった。になる合計特殊出生率が人口置換水準で、を計算すると、およそ2.07になる。2024年の純再生産率は0.56なので、この生み方が続けば、母の世代が1回入れ替わるごとに、母になる人は56%に減っていく。生き残る割合が0.66だった1925年には、人口置換水準は3.10だった5。

全国 65歳以上29.3%・15歳未満11.2% 0 20 40 60 80 85+ 3% 3% 6% 6% ← 男女 → 秋田県 65歳以上39.5%・15歳未満8.8% 0 20 40 60 80 85+ 3% 3% 6% 6% ← 男女 → 2024年10月1日の人口に占める割合(5歳ごと、上の段は85歳以上)。点線は15歳と65歳の境
2024年の全国と秋田県の人口ピラミッド(人口に占める割合)

では、合計特殊出生率がいきなり2.07に戻れば、人口は減らなくなるのか。答えは、減り続ける、である。出生数の式のとおり、母になる年代の女性が少なければ、生まれる子も少ない。秋田県の形の町では、25〜39歳の女性は人口の5.5%しかいないのに、80歳以上の人は14.3%いる。だから、出生率を2倍近くにしても、生まれる子は亡くなる人に届かない。親になる世代が薄い年齢の形は、そのあと何十年も人口を減らす向きに働き続ける。人口学では、年齢の形が持つこの勢いを人口モメンタムと呼ぶ6。

引っ越しも、町の人口を大きく動かす。2024年に市区町村の境を越えて移った人は520万7746人で、年齢で見ると20歳代が飛び抜けて多い。20〜24歳ではその年齢の人口の16%がこの1年に移り、70歳代では1%に届かない7。

シミュレーションの転出は、この年齢の形に、町の事情で決まる倍率を掛けている。町に仕事が足りないと、働く世代、とくに50歳未満の転出が増える。駅から1.5 kmの中に住む人は市の仕事に通えるので、出ていきにくい。町には大学も専門学校も無いので、18〜24歳の転出は全国の1.5倍とした。転入は、人口に比べた数に、埋まらない仕事や駅の近くの空き家の数に応じた分を足す。

試す

5つの町を動かす

時間は1秒に1年進み、場面を選ぶと町は2025年の姿からやり直す。グラフの実線が総人口、破線が高齢化率だ。画面が横長なら、学校の閉校などのできごとが地図の下に残る。地図をたたくと、工場や保育所、駅、住宅地を自分で置ける。

  1. 合計特殊出生率1.15のまま進めると、生まれる子は1年に10〜20人、亡くなる人は100人近くで、人口は毎年100人ほど減る。減る分の大半は、亡くなる人と生まれる子の差だ。2034年に5,000人、2046年に4,000人を下回り、2050年には3,656人になる。2035年には北小が閉校し、2045年には空き家が家の35%を超える。
  2. 町の東に400人分の仕事がある工場ができる。しかし、町の人だけでは働く人が足りず、埋まらない仕事に引かれて転入する世帯が増えていく。2050年の人口は4,053人で、工場の無い町より400人ほど多い。差は時間がたつほど開き、2075年には3,280人と2,115人になる。それでも2055年の時点で、工場の仕事はまだ169人分が埋まらない。
  3. 線路に駅ができる。駅から1.5 kmの中に住む人は市の仕事に通えるので町を出ていきにくくなり、駅の東の新しい住宅地には、市へ通う世帯が越してくる。朝は駅へ向かう点の流れができ、電車が東の市へ走っていく。2050年の人口は4,435人で駅の無い町より約2割多く、高齢化率は35.6%にとどまる。
  4. 転入と転出を止め、合計特殊出生率を人口置換水準の2.07にする。生まれる子は1年に40人前後に増えるが、亡くなる人は100人前後のままで、2050年の人口は4,395人まで減る。始めてすぐにつまみを1.15に戻すと2050年は3,925人で、25年間の差は470人にとどまる。母になる年代の女性が少ないからだ。生まれる子が亡くなる人を初めて上回るのは2079年で、人口は2090年代に3,000人あまりでほぼ止まり、高齢化率は25%ほどに下がる。
  5. 町の仕事を1,600人分に減らし、転入のしやすさを0.7倍にする。仕事の足りない若い世代が次々に町を出て、親になる世代が減るので、2033年に生まれた子は7人だけになる。2040年に南小が閉校して小学校は東小1つになり、2050年の人口は2,122人、高齢化率は61.4%に達する。
現実では

日本の人口の行方

国立社会保障・人口問題研究所の2023年の推計では、日本の総人口は2020年の1億2615万人から、2070年には8700万人に減る。高齢化率は28.6%から38.7%に上がる。合計特殊出生率が長い目で見て1.36に落ち着くと置いた推計で、1.64と置くと2070年の人口は9549万人、1.13と置くと8024万人になる8。つまり、出生率の仮定が0.5違っても、50年後の人口の差は1500万人あまりで、総人口の2割に届かない。2070年に50歳以上になる人は、どの仮定でも、推計を始めた2020年にはもう生まれているからだ。

2024年10月1日の総人口は1億2380万2千人で、15歳未満の割合は11.2%と過去最低、65歳以上は29.3%と過去最高だった。1年で人口が1%以上減った県は18あり、減り方がいちばん大きいのは秋田県の1.87%である2。地域ごとの推計では、秋田県の高齢化率は2050年に49.9%に達し、2050年の人口が2020年の半分を割る市区町村は全体の約2割(341)にのぼる9。

若い世代の移り先は、大都市に偏っている。2024年に東京圏は13万5843人の転入超過で、そのうち8万6908人が20〜24歳だった7。

人口置換水準そのものは、母になる前に亡くなる女の子が減るにつれて下がってきた。1950年は2.43、1975年は2.10で、1995年からは2.06〜2.08の間にある。純再生産率は1974年に0.97となってから、1を上回った年が無い5。

市区町村ごとに見ても、人口置換水準に届く所はわずかだ。2018〜2022年の合計特殊出生率が2.07以上だったのは、1,887市区町村のうち鹿児島県と沖縄県の7町村だけで、約6割の市区町村は1.2以上1.5未満だった10。

使われなくなった家と学校の数も、統計に表れている。2023年の空き家は全国で900万2千戸、住宅の13.8%で、どちらも過去最多・最高だった。そのうち、貸す予定も売る予定もなく、別荘などにも使われていない空き家が385万6千戸ある11。公立学校は、2004〜2023年度の20年間に延べ8,850校が廃校になった12。

省いていること

このシミュレーションが省いていること

  • 死亡率は、2024年の全国の簡易生命表のまま変えていない。実際の死亡率は下がり続けていて、将来推計は2070年の平均寿命を男性85.89年、女性91.94年と見込む8。都道府県による死亡率の違いも入れていない。
  • 出生は、2024年の年齢別出生率の形を合計特殊出生率に合わせて伸び縮みさせただけで、結婚や夫婦、何人目の子かは扱わない。出生率を2.07にしても、子を生む年齢の中心は2024年と同じ30歳前後のままである。
  • 引っ越しの年齢の形は全国の平均である。仕事や駅、学校、保育所、大学の有無が転出と転入を変える倍率と、1年に転入する世帯の数を決める重みは、統計から求めたものではなく、この画面で決めた値だ。外国から来る人と日本人も分けていない。市の仕事はいくらでもあることにした。
  • 最初の町は、秋田県の5歳ごとの人口(千人単位)の割合を6,000人に縮めたものである。夫婦や親子、ひとり暮らしへの分け方も、この画面で決めた。1戸の家に1世帯が住み、家はひとりでに建ったり壊れたりしない。
  • 学校は、通う子どもが3年続けて小学校40人、中学校30人を下回ると閉じ、二度と開かない。町に1校だけ残ったときは、その半分の人数で閉じる。この人数もこの画面の決まりで、実際に学校を統合するか閉じるかは、学校を置く市町村などが決める。朝夕の点の流れは、ある1日を10秒で見せる絵で、年の進みとは別に動く。
参考文献

参考文献

  1. 厚生労働省「令和6年(2024)人口動態統計(確定数)の概況」, 2025. https://www.mhlw.go.jp/toukei/saikin/hw/jinkou/kakutei24/index.html
  2. 総務省統計局「人口推計(2024年(令和6年)10月1日現在)」, 2025. https://www.stat.go.jp/data/jinsui/2024np/index.html
  3. 厚生労働省「令和6年簡易生命表の概況」, 2025. https://www.mhlw.go.jp/toukei/saikin/hw/life/life24/index.html
  4. 国立社会保障・人口問題研究所「人口統計資料集(2026)表4-3 女性の人口再生産に関する主要指標」, 2026. https://www.ipss.go.jp/syoushika/tohkei/Popular/P_Detail2026.asp?fname=T04-03.htm
  5. Keyfitz, N.「On the momentum of population growth」『Demography』8(1), 71-80, 1971.
  6. 総務省統計局「住民基本台帳人口移動報告 2024年(令和6年)結果」, 2025. https://www.stat.go.jp/data/idou/2024np/jissu/youyaku/index.html
  7. 国立社会保障・人口問題研究所「日本の将来推計人口(令和5年推計)」, 2023. https://www.ipss.go.jp/pp-zenkoku/j/zenkoku2023/pp_zenkoku2023.asp
  8. 国立社会保障・人口問題研究所「日本の地域別将来推計人口(令和5年推計)」, 2023. https://www.ipss.go.jp/pp-shicyoson/j/shicyoson23/t-page.asp

注

  1. 参考文献1の都道府県別の統計表と参考表による。出生と死亡は、人口1,000人あたりの数。 ↩

  2. 参考文献2の結果の要約による。 ↩ ↩2

  3. 参考文献1の第7表。内訳は、各歳の年齢別出生率を5歳ごとに足した値。 ↩

  4. 参考文献3。シミュレーションは0〜105歳の各歳の死亡率をそのまま使っている。 ↩

  5. 参考文献4。人口置換水準は、合計特殊出生率を純再生産率で割った値。 ↩ ↩2

  6. 参考文献5。出生率が急に人口置換水準まで下がった人口が、その後も増え続ける大きさを示した。減っていく人口でも同じ考え方が当てはまる。 ↩

  7. 参考文献6。年齢ごとの割合は、都道府県間と都道府県内の移動者数の和を、参考文献2の同じ年齢の人口で割って求めた。 ↩ ↩2

  8. 参考文献7。出生中位・高位・低位(いずれも死亡中位)の推計。 ↩ ↩2

  9. 参考文献8。市区町村の数は、政令指定都市を1市として数えた1,728のうちの数。 ↩

  10. 厚生労働省「平成30年~令和4年 人口動態保健所・市区町村別統計の概況」, 2024. https://www.mhlw.go.jp/toukei/saikin/hw/jinkou/other/hoken24/index.html 。値は、都道府県の出生の状況も使ったベイズ推定による。 ↩

  11. 総務省統計局「令和5年住宅・土地統計調査 住宅及び世帯に関する基本集計(確報集計)結果」, 2024. https://www.stat.go.jp/data/jyutaku/2023/tyousake.html ↩

  12. 文部科学省「令和6年度廃校施設活用状況実態調査の結果について」, 2025. https://www.mext.go.jp/a_menu/shotou/zyosei/yoyuu_00002.htm ↩

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、町の住民1人ずつを「年齢・性別・住む家」の3つの数で持っている。1年が過ぎるたびに、亡くなるかどうか、子が生まれるかどうかを決めて年を1つ足し、そのあと町を出る人と町に来る世帯を決める。人口ピラミッドとグラフは、その結果を数えて描いているだけである。地図の点の流れは、同じ住民の1日の行き先を動きにしたものだ。

統計の表をそのまま持つ

出生・死亡・移動の確率は、公表されている統計の表から作る。死亡は、2024年の簡易生命表の死亡率を、男女別に0〜105歳まで並べた。は、歳の人が1年のうちに亡くなる確率である。出生は、母の年齢5歳ごとの合計特殊出生率の内訳を各歳に均等に分け、合計が1になるように割ってある。これに合計特殊出生率のつまみの値を掛けると、その年齢の女性が1年に子を生む確率になる。

定数と統計(src/sims/town-population.ts の 13〜63 行)
const START = 2025;               // 最初の年const N0 = 6000;                  // 最初の人口const CAP = 24000, LOTS = 9000;   // 住民と区画の配列の大きさconst AMAX = 105;                 // 生命表の最後の年齢(105 歳の人はその年のうちに亡くなる)const W_KM = 7.2, H_KM = 4.6;     // 地図の広さ(km)const SP = 0.034;                 // 家の区画の間隔(km)const R_ST = 1.5;                 // 駅まで歩くか自転車で行ける距離(km)。この中に住む人は市の仕事に通えるconst FACTORY_JOBS = 400, DAYCARE_CAP = 90, DAYCARE_JOBS = 15, SCHOOL_JOBS = 20;const DAYCARE_USE = 0.55;  // 1〜5 歳の子のうち、保育所に通う割合(この画面の近似)const CITY = 0.35;        // 駅から 1.5 km の中に住む働く人のうち、市の仕事を選ぶ割合(この画面の近似)const ES_MIN = 40, JHS_MIN = 30, LOW_YEARS = 3;   // 学校が閉じる子どもの数と年数(この画面の決まり)const WORK = 0.8, WORK_OLD = 0.35;                // 働く人の割合(18〜64 歳・65〜74 歳。この画面の近似)const OUT_JOB = 3;        // 仕事の足りない割合 u があると、50 歳未満の転出は (1 + 3u) 倍になるconst YOUTH = 1.5;        // 町に大学や専門学校が無いので、18〜24 歳の転出は全国の割合の 1.5 倍const IN_BASE = 0.0105;    // 転入する世帯の数の基準(1 年・住民 1 人あたり)const IN_JOB = 0.01;      // 埋まっていない仕事 1 人分あたり、1 年に転入する世帯の数(工場の仕事は IN_FAC)const IN_FAC = 0.1;       // 埋まっていない工場の仕事 1 人分あたり、1 年に転入する世帯の数const IN_ST = 0.06;       // 駅から 1.5 km の中の空き家 1 戸あたり、1 年に転入する世帯の数(市へ通う人)const NO_ES = 1.6, NO_JHS = 1.25, DC_STAY = 0.85; // 町に小学校が無い・中学校が無い・近くに保育所がある家族の転出の倍率const DAY_S = 10;         // 1 日の動きを見せる秒数 // 令和6年簡易生命表の死亡率 nqx(x 歳の人が x+1 歳になるまでに亡くなる確率)。0〜104 歳。105 歳は 1const Q_M = [0.00182, 0.00025, 0.00018, 0.00013, 0.0001, 0.00009, 0.00008, 0.00008, 0.00008, 0.00008, 0.00008, 0.00009, 0.0001, 0.00012, 0.00014, 0.00018, 0.00022, 0.00026, 0.00031, 0.00036, 0.00042, 0.00047, 0.00049, 0.00049, 0.00047, 0.00047, 0.00047, 0.00047, 0.00048, 0.0005, 0.00053, 0.00056, 0.00059, 0.00062, 0.00066, 0.00071, 0.00075, 0.0008, 0.00084, 0.0009, 0.00097, 0.00107, 0.00117, 0.00125, 0.00134, 0.00144, 0.00157, 0.00174, 0.00194, 0.00216, 0.00238, 0.00263, 0.00289, 0.00321, 0.00355, 0.00394, 0.00437, 0.00482, 0.00531, 0.00583, 0.00639, 0.007, 0.00771, 0.00854, 0.00948, 0.01051, 0.01161, 0.01277, 0.01407, 0.01555, 0.01724, 0.01917, 0.0213, 0.02369, 0.0263, 0.02894, 0.03171, 0.03498, 0.03893, 0.0435, 0.04861, 0.05417, 0.06002, 0.06687, 0.07513, 0.08467, 0.09544, 0.1074, 0.12076, 0.13561, 0.15175, 0.16524, 0.18235, 0.20147, 0.22276, 0.2464, 0.27252, 0.30126, 0.33271, 0.36691, 0.40384, 0.44341, 0.4854, 0.52952, 0.57531, 1];const Q_F = [0.00182, 0.00025, 0.00019, 0.00014, 0.00011, 0.00008, 0.00007, 0.00007, 0.00006, 0.00006, 0.00006, 0.00007, 0.00009, 0.00011, 0.00014, 0.00017, 0.0002, 0.00022, 0.00025, 0.00026, 0.00028, 0.00029, 0.0003, 0.0003, 0.00029, 0.00029, 0.0003, 0.0003, 0.0003, 0.0003, 0.0003, 0.00031, 0.00032, 0.00034, 0.00037, 0.00041, 0.00045, 0.00048, 0.0005, 0.00053, 0.00057, 0.00061, 0.00066, 0.00072, 0.00079, 0.00087, 0.00096, 0.00106, 0.00116, 0.00128, 0.00141, 0.00155, 0.00169, 0.00182, 0.00196, 0.00209, 0.00224, 0.0024, 0.00256, 0.00276, 0.00297, 0.00318, 0.00343, 0.00374, 0.00409, 0.00446, 0.00484, 0.00524, 0.00571, 0.00631, 0.00704, 0.00785, 0.00874, 0.00975, 0.01094, 0.01224, 0.01366, 0.01536, 0.01743, 0.01994, 0.02294, 0.02633, 0.03007, 0.03448, 0.03973, 0.04602, 0.0533, 0.06158, 0.0712, 0.08239, 0.0952, 0.10915, 0.12444, 0.14014, 0.15761, 0.18355, 0.20866, 0.23588, 0.26528, 0.29688, 0.33068, 0.36663, 0.40463, 0.44451, 0.48604, 1];// 母の年齢 5 歳ごとの合計特殊出生率の内訳(2024 年。15〜19 歳から 45〜49 歳)。足すと 1.1475const ASFR5 = [0.0082, 0.0764, 0.3064, 0.4369, 0.2566, 0.0608, 0.0022];const TFR24 = ASFR5.reduce((a, b) => a + b, 0);// 2024 年に生まれた子のうち男の子の割合(男 351,451 人・女 334,722 人。女 100 人に男 105.0 人)const BOY = 351451 / (351451 + 334722);// 市区町村の境を越えて移った人(2024 年。都道府県の間と、都道府県の中の合計)と、全国の人口(2024 年 10 月 1 日・千人)。5 歳ごと、最後は 85 歳以上const MOVERS5 = [250444, 114934, 66446, 254207, 1003802, 1103688, 685569, 412321, 277003, 224257, 208506, 157926, 108568, 75274, 65236, 58717, 54916, 85920];const POP5 = [3934, 4710, 5185, 5457, 6260, 6519, 6395, 6871, 7631, 8745, 9788, 8490, 7571, 7268, 8198, 7886, 6138, 6754];// 秋田県の人口(2024 年 10 月 1 日・千人)。5 歳ごと、最後は 85 歳以上const AKITA_M = [10, 14, 17, 18, 16, 15, 17, 21, 26, 31, 31, 29, 32, 35, 39, 31, 21, 22];const AKITA_F = [10, 13, 16, 17, 13, 13, 16, 20, 25, 29, 31, 30, 34, 38, 44, 38, 33, 53];// 全国の各歳の人口(2024 年 10 月 1 日・千人。最後は 100 歳以上)。5 歳の中の形に使う(58 歳の丙午、78・79 歳の終戦の前後のへこみが入る)const NAT_M = [367, 388, 409, 424, 427, 446, 469, 481, 502, 515, 515, 526, 528, 542, 546, 549, 559, 557, 558, 576, 611, 623, 651, 670, 676, 666, 676, 671, 669, 674, 670, 653, 656, 649, 660, 669, 688, 704, 713, 735, 758, 767, 766, 773, 806, 826, 855, 878, 918, 954, 1008, 1021, 1003, 971, 939, 921, 897, 891, 691, 851, 794, 771, 743, 723, 722, 731, 703, 678, 706, 721, 712, 743, 772, 804, 842, 905, 873, 808, 483, 496, 582, 541, 530, 487, 414, 333, 323, 300, 267, 220, 177, 148, 119, 91, 67, 50, 36, 22, 14, 8, 11];const NAT_F = [350, 369, 388, 404, 409, 426, 447, 459, 477, 490, 488, 502, 504, 515, 519, 522, 532, 528, 531, 545, 577, 584, 610, 627, 631, 629, 636, 633, 630, 635, 630, 614, 619, 616, 628, 639, 656, 674, 683, 710, 734, 745, 746, 751, 784, 803, 833, 854, 893, 930, 982, 1002, 981, 955, 925, 912, 890, 887, 695, 855, 800, 780, 754, 739, 745, 757, 736, 715, 750, 771, 770, 817, 861, 906, 971, 1056, 1038, 975, 605, 645, 778, 738, 743, 704, 621, 520, 533, 521, 491, 437, 381, 346, 300, 254, 206, 168, 135, 90, 66, 44, 77]; /** 1 年に子を生む確率の年齢の形(合計特殊出生率 1 あたり)。5 歳ごとの内訳を、その中の各歳に均等に分ける */const FERT = new Float32Array(AMAX + 1);for (let a = 15; a <= 49; a++) FERT[a] = ASFR5[Math.floor((a - 15) / 5)] / 5 / TFR24;/** 1 年に町の外へ移る確率の年齢の形 */const MOVE = new Float32Array(AMAX + 1);for (let a = 0; a <= AMAX; a++) { const g = Math.min(17, Math.floor(a / 5)); MOVE[a] = MOVERS5[g] / (POP5[g] * 1000); }// 15〜19 歳の移動は進学と就職の 18・19 歳に寄せる(15〜17 歳は、家族と動く 10〜14 歳と同じ割合)MOVE[15] = MOVE[16] = MOVE[17] = MOVE[10];MOVE[18] = MOVE[19] = (5 * MOVE[19] - 3 * MOVE[10]) / 2;/** 生まれた女の子が、母になる年齢まで生き残る割合(出生率の重みで平均)。人口置換水準 = 1 ÷ (女の子の割合 × この割合) */const RHO = (() => { let l = 1, s = 0; for (let a = 0; a < 50; a++) { s += FERT[a] * l * (1 - Q_F[a] / 2); l *= 1 - Q_F[a]; } return s; })();const REPL = 1 / ((1 - BOY) * RHO);

町の外へ移る確率の形MOVEは、2024年に市区町村の境を越えて移った人の数を、同じ年齢の全国の人口で割ったものである。20〜24歳では1年に16%が移り、70歳代では1%に届かない。15〜19歳の分は、進学と就職で動く18・19歳に寄せた。最後のREPLは人口置換水準で、本文の式のとおり、女の子の割合と母になる年まで生き残る割合から計算すると2.07になる。冒頭の定数のうち、働く人の割合や転入の重み、学校が閉じる人数は、統計ではなく、この画面で決めた値である。

最初の町をつくる

秋田県の2024年の人口を男女・5歳ごとに6,000人へ縮め、5歳の中は全国の各歳の人数の形で分ける。人口ピラミッドで58歳(1966年生まれ)と78・79歳(1945・46年生まれ)の段が短いのは、全国の人口にあるへこみをそのまま持ち込んでいるからだ。

町を作る(src/sims/town-population.ts の 109〜279 行)
const roadY = (x: number) => 2.36 + 0.07 * Math.sin(x * 0.9 + 0.3);    // 東西の国道const roadX = (y: number) => 3.48 + 0.05 * Math.sin(y * 1.7);          // 南北の県道const railY = (x: number) => 3.98 - 0.16 * Math.sin((x + 0.6) * 0.42); // 線路(東の端の先に市がある)const riverY = (x: number) => 1.08 + 0.13 * Math.sin(1.25 * x + 0.4) + 0.025 * x;/** 山と丘の高さ(0〜1)。等高線と、家を建てられない所を決める */function hill(x: number, y: number) {  const n = (0.82 - y + 0.12 * Math.sin(x * 1.7) + 0.07 * Math.sin(x * 4.3 + 1)) / 0.82;  const se = 1 - Math.hypot((x - 7.4) / 1.2, (y - 4.7) / 0.8);  const w = 1 - Math.hypot((x + 0.35) / 0.6, (y - 3.0) / 0.7);  return Math.max(n, se * 0.85, w * 0.7);}/** 家を建てられる所か(山・川・線路・広い道の上には建てない) */function buildable(x: number, y: number) {  return x > 0.06 && x < W_KM - 0.06 && y > 0.06 && y < H_KM - 0.06 && hill(x, y) < 0.06 && Math.abs(y - riverY(x)) > 0.09 &&    Math.abs(y - railY(x)) > 0.09 && Math.abs(y - roadY(x)) > 0.04 && Math.abs(x - roadX(y)) > 0.04;}const ZONES0: [string, number, number, number, number][] = [  ["本町", 2.75, 2.02, 1.55, 0.72], ["新町", 3.0, 2.9, 1.5, 0.6], ["東団地", 5.0, 1.62, 0.85, 0.62], ["西町", 0.95, 2.15, 1.2, 0.6],  ["北町", 2.2, 1.38, 1.15, 0.42], ["東町", 4.6, 2.46, 1.7, 0.38], ["南西", 1.6, 3.15, 1.0, 0.45], ["北東", 6.0, 1.62, 0.9, 0.42],  ["集落", 0.45, 1.5, 0.36, 0.2], ["集落", 1.2, 1.5, 0.3, 0.2], ["集落", 4.1, 1.42, 0.34, 0.2], ["集落", 6.7, 2.75, 0.3, 0.3],  ["集落", 0.55, 3.55, 0.3, 0.2], ["集落", 2.8, 3.55, 0.3, 0.2], ["集落", 6.25, 3.2, 0.36, 0.2], ["集落", 4.65, 3.45, 0.3, 0.2], ["南町", 5.3, 2.95, 0.85, 0.45],];let CONTOURS: Float32Array | null = null, PADDY: Float32Array | null = null, FARMS: Float32Array | null = null;/** 等高線(マーチング・スクエア法: 格子の 4 つの角の高さから、同じ高さの点を結ぶ線分を作る) */function contours() {  const st = 0.05, nx = Math.ceil(W_KM / st) + 1, ny = Math.ceil(H_KM / st) + 1, g = new Float32Array(nx * ny), out: number[] = [];  for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) g[j * nx + i] = hill(i * st, j * st);  for (const lv of [0.12, 0.27, 0.42, 0.57, 0.72, 0.87]) for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) {    const a = g[j * nx + i], b = g[j * nx + i + 1], c = g[(j + 1) * nx + i + 1], d = g[(j + 1) * nx + i], x = i * st, y = j * st, p: number[] = [];    const e = (v0: number, v1: number, x0: number, y0: number, x1: number, y1: number) => { if ((v0 > lv) !== (v1 > lv)) { const t = (lv - v0) / (v1 - v0); p.push(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t); } };    e(a, b, x, y, x + st, y); e(b, c, x + st, y, x + st, y + st); e(c, d, x + st, y + st, x, y + st); e(d, a, x, y + st, x, y);    if (p.length >= 4) for (let t = 0; t < Math.min(8, p.length); t++) out.push(p[t]);  }  return new Float32Array(out);}function terrain() {  if (CONTOURS) return;  CONTOURS = contours();  // 田の記号の位置(家の無い平らな所)と、田畑で働く人の行き先  const p: number[] = [];  for (let j = 0; j * 0.15 < H_KM; j++) for (let i = 0; i * 0.17 < W_KM; i++) {    const x = 0.08 + i * 0.17 + (j % 2) * 0.085, y = 0.08 + j * 0.15;    if (x > W_KM - 0.05 || hill(x, y) > 0.02 || Math.abs(y - riverY(x)) < 0.12 || Math.abs(y - railY(x)) < 0.1 || Math.abs(y - roadY(x)) < 0.07 || Math.abs(x - roadX(y)) < 0.07) continue;    if (ZONES0.some(([, zx, zy, zw, zh]) => x > zx - 0.06 && x < zx + zw + 0.06 && y > zy - 0.06 && y < zy + zh + 0.06)) continue;    p.push(x, y);  }  PADDY = new Float32Array(p);  const f: number[] = [];  for (let k = 0; k < p.length; k += 2 * 13) f.push(p[k], p[k + 1]);  FARMS = new Float32Array(f);}function addFac(s: S, kind: Kind, name: string, x: number, y: number, user: boolean, zone: Zone | null = null): Fac {  const f: Fac = { id: s.nextId++, kind, name, x, y, open: true, low: 0, users: 0, filled: 0, user, zone };  s.facs.push(f);  return f;}const nearFac = (s: S, x: number, y: number) => s.facs.some((f) => f.kind !== "zone" && f.kind !== "station" && Math.abs(f.x - x) < FOOT[f.kind] + 0.02 && Math.abs(f.y - y) < FOOT[f.kind] * 0.8 + 0.02);/** 住宅地: 長方形の中に区画を並べる。4 行目ごとと 12 列目ごとを道にする */function makeZone(s: S, name: string, x: number, y: number, w: number, h: number, user: boolean): Zone {  const z: Zone = { name, x, y, w, h, user, streets: [], lots: [] };  const ni = Math.floor(w / SP), nj = Math.floor(h / SP);  for (let j = 0; j < nj; j++) for (let i = 0; i < ni; i++) {    if (j % 4 === 3 || i % 12 === 11 || s.nl >= LOTS) continue;    const px = x + (i + 0.5) * SP, py = y + (j + 0.5) * SP;    if (!buildable(px, py) || nearFac(s, px, py)) continue;    if (user) { let hit = false; for (let l = 0; l < s.nl && !hit; l++) if (s.lst[l] !== 2 && Math.abs(s.lx[l] - px) < SP * 0.9 && Math.abs(s.ly[l] - py) < SP * 0.9) hit = true; if (hit) continue; }    const l = s.nl++; s.lx[l] = px; s.ly[l] = py; s.lst[l] = 0; s.locc[l] = 0; s.lvac[l] = 0; z.lots.push(l);  }  for (let j = 3; j < nj; j += 4) z.streets.push(x, y + (j + 0.5) * SP, x + ni * SP, y + (j + 0.5) * SP);  for (let i = 11; i < ni; i += 12) z.streets.push(x + (i + 0.5) * SP, y, x + (i + 0.5) * SP, y + nj * SP);  s.zones.push(z);  return z;}function addAgent(s: S, age: number, sex: number, house: number) {  if (s.n >= CAP) return -1;  const i = s.n++;  s.age[i] = age; s.sex[i] = sex; s.house[i] = house; s.sd[i] = hash(Math.imul(s.nextSd++, 2654435761)); s.dk[i] = 0; s.gone[i] = 0; s.cw[i] = 0;  return i;}/** 最初の住民と世帯: 子を母に、母に夫、残りの大人を夫婦かひとりに、若い人の一部は親の家に、高齢でひとりの人の一部は子の家族の家に */function populate(s: S, houses: number) {  const r = s.rnd, k = N0 / (AKITA_M.reduce((a, b) => a + b, 0) + AKITA_F.reduce((a, b) => a + b, 0));  const ages: number[] = [], sexes: number[] = [];  for (let sx = 0; sx < 2; sx++) {    const AK = sx ? AKITA_F : AKITA_M, NAT = sx ? NAT_F : NAT_M;    for (let g = 0; g < 18; g++) {      const a0 = g * 5, a1 = g < 17 ? a0 + 4 : 100;      // 5 歳ごとの人数を決めてから、各歳に全国の形で分ける(端数は大きい順に 1 人ずつ足す)      let ws = 0; for (let a = a0; a <= a1; a++) ws += NAT[a];      const T = Math.round(AK[g] * k), es: number[] = [], cs: number[] = [];      for (let a = a0; a <= a1; a++) { const e = (T * NAT[a]) / ws; es.push(e - Math.floor(e)); cs.push(Math.floor(e)); }      let rest = T - cs.reduce((x, y) => x + y, 0);      for (const t of [...es.keys()].sort((x, y) => es[y] - es[x])) { if (rest <= 0) break; cs[t]++; rest--; }      cs.forEach((c, t) => { for (let m = 0; m < c; m++) { ages.push(a0 + t === 100 ? 100 + Math.floor(r() * 4) : a0 + t); sexes.push(sx); } });    }  }  const P = ages.length, hh = new Int32Array(P).fill(-1), groups: number[][] = [], kids = new Int32Array(P), isMom = new Uint8Array(P);  const byAge: number[][][] = [[], []];  for (let sx = 0; sx < 2; sx++) for (let a = 0; a <= AMAX; a++) byAge[sx][a] = [];  for (let p = 0; p < P; p++) byAge[sexes[p]][ages[p]].push(p);  const join = (p: number, g: number) => { hh[p] = g; groups[g].push(p); };  const newG = (p: number) => { groups.push([p]); hh[p] = groups.length - 1; return groups.length - 1; };  const find = (sx: number, a: number, spread: number, ok: (p: number) => boolean) => {    for (let d = 0; d <= spread; d++) for (const sg of d ? [1, -1] : [1]) {      const aa = a + d * sg; if (aa < 0 || aa > AMAX) continue;      const L = byAge[sx][aa]; if (!L.length) continue;      const st = Math.floor(r() * L.length);      for (let t = 0; t < L.length; t++) { const p = L[(st + t) % L.length]; if (ok(p)) return p; }    }    return -1;  };  const free = (p: number) => hh[p] < 0;  const idx = shuffle([...Array(P).keys()], r);  for (const c of idx) {    if (ages[c] >= 18) continue;    const mo = find(1, ages[c] + birthAge(r), 3, (p) => ages[p] >= 18 && kids[p] < 4 && (hh[p] < 0 || isMom[p] === 1));    if (mo < 0) continue;    if (hh[mo] < 0) { newG(mo); isMom[mo] = 1; }    join(c, hh[mo]); kids[mo]++;  }  for (let g = 0; g < groups.length; g++) {    const mo = groups[g][0];    if (r() < 0.88) { const f = find(0, ages[mo] + 2, 4, free); if (f >= 0) join(f, g); }  }  for (const p of idx) {    if (!free(p) || sexes[p] !== 0 || ages[p] < 35) continue;    if (r() < 0.78) { const w = find(1, ages[p] - 2, 4, free); if (w >= 0) { const g = newG(p); join(w, g); } }  }  for (const p of idx) {    if (!free(p) || ages[p] < 18 || ages[p] > 34) continue;    if (r() < (ages[p] < 25 ? 0.8 : 0.55)) { const m = find(1, ages[p] + 30, 8, (q) => hh[q] >= 0); if (m >= 0) { join(p, hh[m]); continue; } }    if (ages[p] >= 25 && sexes[p] === 0 && r() < 0.5) { const w = find(1, ages[p] - 2, 3, free); if (w >= 0) { const g = newG(p); join(w, g); continue; } }  }  for (const p of idx) {    if (!free(p) || ages[p] < 72) continue;    if (r() < 0.4) { const m = find(1, ages[p] - 30, 6, (q) => hh[q] >= 0 && groups[hh[q]].length < 6); if (m >= 0) join(p, hh[m]); }  }  const fams = groups.map((_, g) => g).filter((g) => isMom[groups[g][0]] === 1);  for (const p of idx) if (free(p)) { if (ages[p] < 18 && fams.length) join(p, fams[Math.floor(r() * fams.length)]); else newG(p); }  // 家を建てる区画を選び、世帯を家に入れる(家が足りなければ、ほかの世帯と同じ家に住む)  const all = shuffle([...Array(s.nl).keys()], r), built = all.slice(0, Math.min(houses, s.nl));  for (const l of built) { s.lst[l] = 1; s.lvac[l] = 1 + Math.floor(r() * 14); }  const order = shuffle([...Array(groups.length).keys()], r);  order.forEach((g, t) => {    const l = built[t < built.length ? t : Math.floor(r() * built.length)];    for (const p of groups[g]) addAgent(s, ages[p], sexes[p], l);  });}function initState(api: Api): S {  terrain();  const s: S = {    n: 0, age: new Uint8Array(CAP), sex: new Uint8Array(CAP), house: new Int32Array(CAP), sd: new Uint32Array(CAP), dk: new Uint8Array(CAP), dx: new Float32Array(CAP), dy: new Float32Array(CAP), gone: new Uint8Array(CAP),    nl: 0, lx: new Float32Array(LOTS), ly: new Float32Array(LOTS), lst: new Uint8Array(LOTS), locc: new Uint16Array(LOTS), lvac: new Uint16Array(LOTS), near: new Uint8Array(LOTS), dcn: new Uint8Array(LOTS),    hs: new Int32Array(LOTS + 1), hf: new Int32Array(LOTS), hl: new Int32Array(CAP),    zones: [], facs: [], nextId: 1, nextSd: 1, year: START, rnd: api.rng(20251001), step: stepper(1, 4),    hist: { pop: new Float32Array(401), old: new Float32Array(401), n: 0 }, pyr0: new Float32Array(2 * (AMAX + 1)), pyr: new Float32Array(2 * (AMAX + 1)),    cur: { births: 0, deaths: 0, ins: 0, outs: 0, rejected: 0, done: false }, extraOut: 0,    job: { LF: 0, J: 0, u: 0, open: 0, openF: 0, city: 0 }, st: { pop: 0, old: 0, young: 0, houses: 0, vac: 0 },    phase: 0.2, drag: null, sel: null, fired: {}, notes: [], log: [], noteT: -99, lay: null, cw: new Uint8Array(CAP),  };  addFac(s, "es", "北小", 2.0, 1.92, false); addFac(s, "es", "南小", 2.78, 3.0, false); addFac(s, "es", "東小", 4.72, 1.98, false);  addFac(s, "jhs", "中学校", 4.0, 1.8, false); addFac(s, "hall", "役場", 3.66, 2.17, false); addFac(s, "hospital", "病院", 4.08, 2.6, false);  addFac(s, "daycare", "保育所", 3.08, 2.58, false);  for (const [name, x, y, w, h] of ZONES0) makeZone(s, name, x, y, w, h, false);  populate(s, api.num("houses"));  countSchools(s);  refresh(api, s);  s.pyr0.set(s.pyr);  record(s);  return s;}

住民がそろったら世帯にまとめる。18歳未満の子は、2024年の出生率の形で選んだ年齢の母に付け、母には夫を、残りの大人は夫婦かひとりにする。若い人の一部は親の家に、ひとりで暮らす高齢の人の一部は子の家族の家に入れる。世帯の数は3,300戸の家より少なく、入る世帯の無かった家が最初の空き家(約12%)になる。

1年を進める

yearStepは住民を1人ずつ見て、乱数が生命表の死亡率より小さければ亡くなったことにし、女性なら同じやり方で出生を決め、残った人の年齢に1を足す。本文の式のとが、コードのbirthsとdeathsにあたり、とは、次の章のmigrateが返すinsとoutsにあたる。全員を1回ずつ見るだけなので、計算の量は住民の数に比例する。

1年の計算(src/sims/town-population.ts の 281〜389 行)
/** 家ごとの住民の一覧(区画の番号で並べ替えた住民の番号) */function buildHH(s: S) {  const hs = s.hs; hs.fill(0, 0, s.nl + 1);  for (let i = 0; i < s.n; i++) hs[s.house[i] + 1]++;  for (let l = 0; l < s.nl; l++) { hs[l + 1] += hs[l]; s.hf[l] = hs[l]; }  for (let i = 0; i < s.n; i++) s.hl[s.hf[s.house[i]]++] = i;  for (let l = 0; l < s.nl; l++) s.locc[l] = hs[l + 1] - hs[l];}function compact(s: S) {  let j = 0;  for (let i = 0; i < s.n; i++) {    if (s.gone[i]) continue;    if (i !== j) { s.age[j] = s.age[i]; s.sex[j] = s.sex[i]; s.house[j] = s.house[i]; s.sd[j] = s.sd[i]; s.dk[j] = s.dk[i]; s.dx[j] = s.dx[i]; s.dy[j] = s.dy[i]; s.cw[j] = s.cw[i]; }    s.gone[j] = 0; j++;  }  s.n = j;}function yearStep(api: Api, s: S) {  const r = s.rnd, tfr = api.num("tfr");  let births = 0, deaths = 0;  const babies: number[] = [];  // 死亡(生命表の死亡率)・出生(年齢ごとの出生率 × 合計特殊出生率)・年を 1 つ取る  for (let i = 0; i < s.n; i++) {    const a = s.age[i], q = (s.sex[i] ? Q_F : Q_M)[Math.min(a, AMAX)];    if (r() < q) { s.gone[i] = 1; deaths++; continue; }    if (s.sex[i] && FERT[a] > 0 && r() < FERT[a] * tfr) { babies.push(s.house[i], r() < BOY ? 0 : 1); births++; }    s.age[i] = Math.min(AMAX, a + 1);  }  compact(s);  for (let k = 0; k < babies.length; k += 2) addAgent(s, 0, babies[k + 1], babies[k]);  assign(api, s);  const m = api.bool("move") ? migrate(api, s) : { ins: 0, outs: 0, rejected: 0 };  // 大人がいなくなった家の子は、親類の所へ移る  buildHH(s);  let orphans = 0;  for (let l = 0; l < s.nl; l++) {    const a = s.hs[l], b = s.hs[l + 1]; if (a === b) continue;    let adult = false; for (let t = a; t < b; t++) if (s.age[s.hl[t]] >= 18) adult = true;    if (!adult) for (let t = a; t < b; t++) { s.gone[s.hl[t]] = 1; orphans++; }  }  if (orphans) compact(s);  s.year++;  buildHH(s);  for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) s.lvac[l] = s.locc[l] ? 0 : Math.min(999, s.lvac[l] + 1);  s.cur = { births, deaths, ins: m.ins, outs: m.outs + orphans + s.extraOut, rejected: m.rejected, done: true };  s.extraOut = 0;  schools(api, s);  refresh(api, s);  record(s);  events(api, s);}/** 町の子(6〜11 歳・12〜14 歳)を、家からいちばん近い開いている学校に数える */function countSchools(s: S) {  for (const kind of ["es", "jhs"] as const) {    const open = s.facs.filter((f) => f.kind === kind && f.open);    for (const f of open) f.users = 0;    const a0 = kind === "es" ? 6 : 12, a1 = kind === "es" ? 11 : 14;    if (open.length) for (let i = 0; i < s.n; i++) { const a = s.age[i]; if (a < a0 || a > a1) continue; const f = nearest(open, s.lx[s.house[i]], s.ly[s.house[i]]); if (f) f.users++; }  }}/** 学校を閉じる: 通う子が決まった数を 3 年続けて下回ると閉校(町に 1 つだけ残った学校は、その半分の数で) */function schools(api: Api, s: S) {  countSchools(s);  for (const kind of ["es", "jhs"] as const) {    const open = s.facs.filter((f) => f.kind === kind && f.open), min = kind === "es" ? ES_MIN : JHS_MIN;    let worst: Fac | null = null;    for (const f of open) {      f.low = f.users < (open.length > 1 ? min : Math.ceil(min / 2)) ? f.low + 1 : 0;      if (f.low >= LOW_YEARS && (!worst || f.users < worst.users)) worst = f;    }    if (worst) {      worst.open = false;      const to = nearest(open.filter((f) => f !== worst), worst.x, worst.y);      say(api, s, `close-${worst.id}`, `${worst.name}が閉校しました(通う子ども ${worst.users} 人)。${to ? `これからは${to.name}へ通います` : "子どもはバスで隣の市の学校へ通います"}`, true);    }  }  countSchools(s);}function nearest(list: Fac[], x: number, y: number) {  let best: Fac | null = null, bd = Infinity;  for (const f of list) { const d = (f.x - x) ** 2 + (f.y - y) ** 2; if (d < bd) { bd = d; best = f; } }  return best;}/** 数え直し: 家ごとの人数・行き先と仕事・人口ピラミッド・高齢化率・空き家 */function refresh(api: Api, s: S) {  buildHH(s);  geo(s);  assign(api, s);  const P = s.pyr; P.fill(0);  let old = 0, young = 0;  for (let i = 0; i < s.n; i++) { const a = s.age[i]; P[s.sex[i] * (AMAX + 1) + a]++; if (a >= 65) old++; else if (a < 15) young++; }  let houses = 0, vac = 0;  for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) { houses++; if (!s.locc[l]) vac++; }  s.st = { pop: s.n, old: s.n ? old / s.n : 0, young: s.n ? young / s.n : 0, houses, vac };}/** 区画ごとに、駅から 1.5 km の中か・保育所から 3 km の中か */function geo(s: S) {  const st = s.facs.filter((f) => f.kind === "station"), dc = s.facs.filter((f) => f.kind === "daycare");  for (let l = 0; l < s.nl; l++) {    const x = s.lx[l], y = s.ly[l];    s.near[l] = st.some((f) => (f.x - x) ** 2 + (f.y - y) ** 2 < R_ST * R_ST) ? 1 : 0;    s.dcn[l] = dc.some((f) => (f.x - x) ** 2 + (f.y - y) ** 2 < 9) ? 1 : 0;  }}function record(s: S) {  const k = s.year - START;  if (k < 0 || k > 400) return;  s.hist.pop[k] = s.st.pop; s.hist.old[k] = s.st.old; s.hist.n = k + 1;}

大人のいなくなった家の子どもは、親類の家へ移ったことにして転出に数える。乱数の種は決めてあるので、同じ場面を同じ操作で動かせば、何度でも同じ町の歴史になる。学校は、通う子どもが3年続けて決まった数を下回ると閉じる。小学校は40人、中学校は30人で、町に1校だけ残ったときはその半分にした。この数は法令や基準ではなく、この画面の決まりである。

町を出る人、町に来る人

転出は2通りに分けた。親と住む18〜29歳は1人ずつ進学や就職で出ていき、それ以外は世帯ごとに引っ越す。確率はMOVEに倍率を掛けたもので、町に仕事の足りない割合をとすると、50歳未満では倍、50〜64歳では倍になる。駅から1.5 kmの中に住む人は市の仕事に通えるので、の効き目を4分の1にしてある。24歳以下は、町に大学や専門学校が無いぶん、さらに1.5倍にした。

転入と転出(src/sims/town-population.ts の 391〜456 行)
/** 仕事の足りない割合 u で、転出が何倍になるか。駅の近くの人は市の仕事に通えるので、u の 4 分の 1 だけ効く */const pushOf = (age: number, u: number, near: boolean) => { if (age >= 65) return 1; const uu = near ? u * 0.25 : u; return (age < 50 ? 1 + OUT_JOB * uu : 1 + uu) * (age <= 24 ? YOUTH : 1); };/** 転出: 親と住む 18〜29 歳は 1 人ずつ(進学・就職)、ほかは世帯ごと。転入: 世帯ごとに空き家へ */function migrate(api: Api, s: S) {  const r = s.rnd, u = s.job.u, esOpen = s.facs.some((f) => f.kind === "es" && f.open), jhsOpen = s.facs.some((f) => f.kind === "jhs" && f.open);  let outs = 0;  buildHH(s);  for (let l = 0; l < s.nl; l++) {    const a = s.hs[l], b = s.hs[l + 1]; if (a === b) continue;    let nA = 0, sumA = 0, nY = 0, sumY = 0, kid5 = false, kid14 = false;    for (let t = a; t < b; t++) { const g = s.age[s.hl[t]]; if (g < 6) kid5 = true; if (g < 15) kid14 = true; if (g >= 30) { nA++; sumA += g; } else if (g >= 18) { nY++; sumY += g; } }    const near = s.near[l] === 1;    if (nA) for (let t = a; t < b; t++) {      const i = s.hl[t], g = s.age[i];      if (g < 18 || g > 29 || (kid5 && g >= 20)) continue;      if (r() < MOVE[g] * pushOf(g, u, near)) { s.gone[i] = 1; outs++; }    }    const ref = nA ? Math.round(sumA / nA) : nY ? Math.round(sumY / nY) : -1;    if (ref < 0) continue;    let p = MOVE[ref] * pushOf(ref, u, near);    if (kid14 && !esOpen) p *= NO_ES;    if (kid14 && !jhsOpen) p *= NO_JHS;    if (kid5 && s.dcn[l]) p *= DC_STAY;    if (r() < p) for (let t = a; t < b; t++) { const i = s.hl[t]; if (!s.gone[i]) { s.gone[i] = 1; outs++; } }  }  compact(s);  buildHH(s);  const vac: number[] = [], vnear: number[] = [];  for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l]) { vac.push(l); if (s.near[l]) vnear.push(l); }  // 1 年に転入する世帯の数の平均: 基準(人口に比例)+ 埋まっていない仕事(工場の仕事は多めに)+ 駅の近くの空き家  const k = api.num("ease"), dc = s.facs.some((f) => f.kind === "daycare");  const lam0 = IN_BASE * s.n * k * (esOpen ? 1 : 0.6) * (dc ? 1.1 : 1) * (1 - Math.min(0.6, 1.5 * u));  const lamJ = (IN_JOB * (s.job.open - s.job.openF) + IN_FAC * s.job.openF) * k, lamS = IN_ST * vnear.length * k;  let ins = 0, rejected = 0;  const take = (pool: number[]) => { while (pool.length) { const t = Math.floor(r() * pool.length), l = pool[t]; pool[t] = pool[pool.length - 1]; pool.pop(); if (!s.locc[l]) return l; } return -1; };  const arrive = (W: Cum, pool: number[], city = false) => {    const mem = newcomers(r, W), l = take(pool);    if (l < 0 || s.n + mem.length / 2 > CAP) { rejected++; return; }    for (let t = 0; t < mem.length; t += 2) { const i = addAgent(s, mem[t], mem[t + 1], l); if (city && i >= 0 && mem[t] >= 18) s.cw[i] = 1; }    s.locc[l] = mem.length / 2; ins += mem.length / 2;  };  for (let t = poisson(r, lamS); t > 0; t--) arrive(W_ST, vnear, true);  for (let t = poisson(r, lamJ); t > 0; t--) arrive(W_JOB, vac);  for (let t = poisson(r, lam0); t > 0; t--) arrive(W_BASE, vac);  // 町の中の引っ越し: 親と住む 25〜39 歳の一部が、町の空き家に移って世帯を分ける  buildHH(s);  for (let l = 0; l < s.nl; l++) {    const a = s.hs[l], b = s.hs[l + 1]; if (b - a < 2) continue;    let mx = 0; for (let t = a; t < b; t++) mx = Math.max(mx, s.age[s.hl[t]]);    for (let t = a; t < b; t++) {      const i = s.hl[t], g = s.age[i];      if (g >= 25 && g <= 39 && mx - g >= 18 && r() < 0.03) { const v = take(vac); if (v >= 0) { s.house[i] = v; s.locc[v] = 1; } }    }  }  return { ins, outs, rejected };}/** 転入する世帯: いちばん上の人の年齢を選び、夫婦か、ひとりか、子のいる家族かを決める。[年齢, 性別, …] */function newcomers(r: () => number, W: Cum) {  const h = pickAge(r, W), sx = r() < 0.5 ? 0 : 1, out = [h, sx];  const couple = r() < (h < 27 ? 0.3 : h < 60 ? 0.8 : 0.6);  if (couple) out.push(clamp(h + (sx ? 2 : -2) + Math.round((r() - 0.5) * 6), 18, 100), 1 - sx);  if (couple && h >= 24 && h < 50) { const x = r(), nk = x < 0.3 ? 0 : x < 0.6 ? 1 : x < 0.9 ? 2 : 3; for (let t = 0; t < nk; t++) out.push(Math.floor(r() * Math.min(18, h - 22)), r() < BOY ? 0 : 1); }  return out;}/** 町の仕事の数: つまみの値のうち 8 割は、人口に比べて増減する店・病院・農業などの分 */const jobsBase = (api: Api, s: S) => api.num("jobs") * (0.2 + 0.8 * Math.min(2, s.n / N0));

転入は世帯ごとに考える。1年に来る世帯の数の平均は、3つの分の和で決める。人口に比べた分、埋まっていない仕事の分(工場の仕事は多め)、駅の近くの空き家の分である。実際の数は、この平均を持つポアソン分布の乱数で引く。平均が3世帯なら、ある年は1世帯、次の年は5世帯というように、平均のまわりでばらつく。来た世帯は空き家に入り、空き家が無ければ転入できない。世帯のいちばん上の人の年齢は、全国の移動の年齢の形から選ぶ。最後に、親と住む25〜39歳の一部が町の空き家へ移り、世帯を分ける。これらの重みは統計から求めたものではなく、この画面で決めた値である。

朝と夕方の点

住民の行き先は、1年ごとと、地図を触ったときに決め直す。子どもは家からいちばん近い開いている学校へ、1〜5歳の子の一部は保育所へ行く。働く人は、空きのある仕事場のうち家にいちばん近い所へ行く。駅から1.5 kmの中に住む働く人は、一部(駅の近くへ越してきた人は全員)が駅から市へ通い、町に空いた仕事が無いときも市へ通う。

1日の動き(src/sims/town-population.ts の 458〜559 行)
/** 住民の行き先と仕事: 0 家にいる・1 小学校・2 中学校・3 保育所・4 職場・5 田畑・6 店・7 市の職場(駅から)・8 町の外(バス)・9 市の高校(駅から) */function assign(api: Api, s: S) {  const base = jobsBase(api, s), F = s.facs, FM = FARMS!;  type Slot = { x: number; y: number; cap: number; jit: number; dk: number; fac: Fac | null };  const slots: Slot[] = [];  for (const f of F) { f.filled = 0; if (f.kind === "daycare" || f.kind === "station") f.users = 0; }  const hall = F.find((f) => f.kind === "hall"), hosp = F.find((f) => f.kind === "hospital");  // 町の仕事を、役場・病院・商店街・田畑・住宅地の中の小さな仕事場に分ける  if (hall) slots.push({ x: hall.x, y: hall.y, cap: base * 0.12, jit: 0.05, dk: 4, fac: hall });  if (hosp) slots.push({ x: hosp.x, y: hosp.y, cap: base * 0.16, jit: 0.06, dk: 4, fac: hosp });  slots.push({ x: 3.4, y: roadY(3.4), cap: base * 0.22, jit: 0.4, dk: 6, fac: null });  const nfm = FM.length / 2;  for (let t = 0; t < nfm; t++) slots.push({ x: FM[2 * t], y: FM[2 * t + 1], cap: (base * 0.3) / nfm, jit: 0.06, dk: 5, fac: null });  const zs = s.zones.filter((z) => !z.user), zl = zs.reduce((a, z) => a + z.lots.length, 0) || 1;  for (const z of zs) slots.push({ x: z.x + z.w * 0.5, y: z.y + z.h * 0.5, cap: (base * 0.2 * z.lots.length) / zl, jit: 0.1, dk: 4, fac: null });  for (const f of F) {    if (f.kind === "factory") slots.push({ x: f.x, y: f.y, cap: FACTORY_JOBS, jit: 0.12, dk: 4, fac: f });    else if (f.kind === "daycare") slots.push({ x: f.x, y: f.y, cap: DAYCARE_JOBS, jit: 0.03, dk: 4, fac: f });    else if ((f.kind === "es" || f.kind === "jhs") && f.open) slots.push({ x: f.x, y: f.y, cap: SCHOOL_JOBS, jit: 0.05, dk: 4, fac: f });  }  const J = slots.reduce((a, b) => a + b.cap, 0);  const es = F.filter((f) => f.kind === "es" && f.open), jhs = F.filter((f) => f.kind === "jhs" && f.open), dcs = F.filter((f) => f.kind === "daycare"), sts = F.filter((f) => f.kind === "station");  const ex = W_KM + 0.05, ey = roadY(W_KM);  let LF = 0, emp = 0, city = 0;  for (let i = 0; i < s.n; i++) {    const a = s.age[i], l = s.house[i], hx = s.lx[l], hy = s.ly[l], sd = s.sd[i];    const jx = ((sd >>> 20) & 255) / 255 - 0.5, jy = ((sd >>> 12) & 255) / 255 - 0.5;    let dk = 0, tx = hx, ty = hy, jit = 0;    const st = nearest(sts, hx, hy), dst = st ? Math.hypot(st.x - hx, st.y - hy) : Infinity;    if (st && dst < R_ST) st.users++;    if (a <= 5) {      let best: Fac | null = null, bd = 16;      for (const f of dcs) { const d = (f.x - hx) ** 2 + (f.y - hy) ** 2; if (d < bd && f.users < DAYCARE_CAP) { bd = d; best = f; } }      if (best && a >= 1 && ((sd >>> 8) & 255) / 256 < DAYCARE_USE) { best.users++; dk = 3; tx = best.x; ty = best.y; jit = 0.04; }    } else if (a <= 14) {      const f = nearest(a <= 11 ? es : jhs, hx, hy);      if (f) { dk = a <= 11 ? 1 : 2; tx = f.x; ty = f.y; jit = 0.07; } else { dk = 8; tx = ex; ty = ey; }    } else if (a <= 17) {      if (st && dst < 3) { dk = 9; tx = st.x; ty = st.y; } else { dk = 8; tx = ex; ty = ey; }    } else {      const w = a <= 64 ? WORK : a <= 74 ? WORK_OLD : 0, hv = (sd & 0xffff) / 65536;      if (hv < w) {        // 働く人: 駅の近くに住む人の一部(駅の近くへ越してきた人は全員)は市へ通う。ほかは空きのある仕事場のうち家にいちばん近い所。町に無ければ、駅の近くの人は市へ        LF++;        if (st && dst < R_ST && (s.cw[i] === 1 || ((sd >>> 24) & 255) / 256 < CITY)) { dk = 7; city++; st.filled++; s.dk[i] = dk; s.dx[i] = st.x + jx * 0.02; s.dy[i] = st.y; continue; }        let best: Slot | null = null, bd = Infinity;        for (const sl of slots) { if (sl.cap < 1) continue; const d = (sl.x - hx) ** 2 + (sl.y - hy) ** 2; if (d < bd) { bd = d; best = sl; } }        if (best) { best.cap--; emp++; dk = best.dk === 6 ? 4 : best.dk; tx = best.x; ty = best.y; jit = best.jit; if (best.fac) best.fac.filled++; }        else if (st && dst < R_ST) { dk = 7; tx = st.x; ty = st.y; city++; st.filled++; }      } else if (a >= 65 && ((sd >>> 16) & 255) < 56) { dk = 6; tx = 3.4 + jx * 0.8; ty = roadY(tx); jit = 0.02; }    }    s.dk[i] = dk; s.dx[i] = tx + jx * jit * 2; s.dy[i] = ty + jy * jit * 1.4;  }  const open = slots.reduce((a, b) => a + Math.max(0, Math.floor(b.cap)), 0), openF = slots.reduce((a, b) => a + (b.fac?.kind === "factory" ? Math.max(0, Math.floor(b.cap)) : 0), 0);  s.job = { LF, J, u: LF ? Math.max(0, LF - emp - city) / LF : 0, open, openF, city };}/** 家から行き先までの道: 国道をまたぐか遠いときは国道を通り、近いときは東西に行ってから南北に行く */const TMP = new Float32Array(2);function path(hx: number, hy: number, tx: number, ty: number, f: number) {  const r1 = roadY(hx), r2 = roadY(tx);  if ((hy - r1) * (ty - r2) < 0 || Math.abs(hx - tx) > 1.2) {    const a = Math.abs(r1 - hy), b = Math.abs(tx - hx), c = Math.abs(ty - r2), L = a + b + c || 1;    let d = f * L;    if (d < a) { TMP[0] = hx; TMP[1] = hy + ((r1 - hy) * d) / a; return; }    d -= a;    if (d < b) { const x = hx + ((tx - hx) * d) / b; TMP[0] = x; TMP[1] = roadY(x); return; }    d -= b; TMP[0] = tx; TMP[1] = r2 + (ty - r2) * (c ? d / c : 1); return;  }  const a = Math.abs(tx - hx), c = Math.abs(ty - hy), L = a + c || 1, d = f * L;  if (d < a) { TMP[0] = hx + ((tx - hx) * d) / a; TMP[1] = hy; } else { TMP[0] = tx; TMP[1] = hy + (ty - hy) * (c ? (d - a) / c : 1); }}/** 1 日の中の時刻(0〜1)を時計の時刻に。朝と夕方の通う時間をゆっくり見せる */const PH = [0, 0.08, 0.38, 0.52, 0.86, 1], HR = [5, 7, 9, 15.5, 19.5, 24];function clock(ph: number) {  let k = 0; while (k < PH.length - 2 && ph > PH[k + 1]) k++;  const h = HR[k] + ((HR[k + 1] - HR[k]) * (ph - PH[k])) / (PH[k + 1] - PH[k]), hh = Math.floor(h) % 24, mm = Math.floor((h - Math.floor(h)) * 6) * 10;  return `${hh}:${String(mm).padStart(2, "0")}`;}/** 出かけている人を点で描く。出る時刻と帰る時刻は、1 人ずつの決まった乱数でずらす */function drawPeople(api: Api, s: S, L: Lay) {  const ph = s.phase, c = api.ctx, stride = [3, 2, 1][api.quality], r = Math.max(0.55, api.u(0.8));  c.beginPath();  for (let i = 0; i < s.n; i += stride) {    const dk = s.dk[i]; if (!dk) continue;    const sd = s.sd[i], h1 = (sd & 1023) / 1023, h2 = ((sd >>> 10) & 1023) / 1023;    let d0: number, d1: number;    if (dk === 1 || dk === 2) { d0 = 0.14 + 0.06 * h1; d1 = 0.5 + 0.08 * h2; }    else if (dk === 3) { d0 = 0.12 + 0.08 * h1; d1 = 0.6 + 0.12 * h2; }    else if (dk === 6) { d0 = 0.36 + 0.06 * h1; d1 = 0.46 + 0.07 * h2; }    else if (dk === 7 || dk === 9) { d0 = 0.07 + 0.1 * h1; d1 = 0.63 + 0.14 * h2; }    else { d0 = 0.1 + 0.14 * h1; d1 = 0.58 + 0.16 * h2; }    const l = s.house[i], hx = s.lx[l], hy = s.ly[l], tx = s.dx[i], ty = s.dy[i];    const T = 0.05 + 0.05 * Math.min(1, (Math.abs(tx - hx) + Math.abs(ty - hy)) / 3);    if (ph < d0 || ph > d1 + T) continue;    const f = ph < d0 + T ? (ph - d0) / T : ph < d1 ? 1 : 1 - (ph - d1) / T;    if (f >= 1 && dk >= 7) continue; // 市や隣の市に出ている    path(hx, hy, tx, ty, f);    const X = L.X(TMP[0]), Y = L.Y(TMP[1]);    c.rect(X - r, Y - r, 2 * r, 2 * r);  }  c.fillStyle = api.ink; c.fill();}

出かける時刻と帰る時刻は、住民ごとに決まった乱数でずらしてある。道は、国道をまたぐときや遠くへ行くときは国道を通る折れ線にした。1日を10秒で見せる絵なので、年の進み方とは別に動く。

地図を触る

何もない所をたたくと、選んだ施設を置く。工場と保育所は家と重ならないいちばん近い場所を探し、駅は線路の上に置く。住宅地を置くと、その区画にすべて家が建ち、最初は空き家になる。施設はドラッグで動かせ、指を動かさずに0.65秒押し続けると取り除く。

操作(src/sims/town-population.ts の 588〜737 行)
/** 地図の点から、そこにある施設を探す(画面の上の距離) */function hit(api: Api, s: S, L: Lay, px: number, py: number): Fac | null {  let best: Fac | null = null, bd = api.u(15);  for (const f of s.facs) {    if (f.kind === "zone") continue;    const d = Math.hypot(L.X(f.x) - px, L.Y(f.y) - py);    if (d < bd) { bd = d; best = f; }  }  if (best) return best;  for (const f of s.facs) if (f.kind === "zone" && f.zone) {    const z = f.zone;    if (px > L.X(z.x) && px < L.X(z.x + z.w) && py > L.Y(z.y) && py < L.Y(z.y + z.h)) return f;  }  return null;}function spotOK(s: S, x: number, y: number, R: number, self: Fac | null) {  if (x < R || x > W_KM - R || y < R || y > H_KM - R) return false;  if (hill(x, y) > 0.05 || Math.abs(y - riverY(x)) < R * 0.7 + 0.06 || Math.abs(y - railY(x)) < R * 0.7 + 0.07) return false;  for (const f of s.facs) if (f !== self && f.kind !== "zone" && f.kind !== "station" && Math.abs(f.x - x) < R + FOOT[f.kind] && Math.abs(f.y - y) < (R + FOOT[f.kind]) * 0.75) return false;  for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && Math.abs(s.lx[l] - x) < R && Math.abs(s.ly[l] - y) < R * 0.75) return false;  return true;}/** 家や施設と重ならない、いちばん近い場所 */function freeSpot(s: S, kind: Kind, x: number, y: number, self: Fac | null = null): [number, number] | null {  const R = FOOT[kind];  for (let rr = 0; rr <= 1.5; rr += 0.05) {    const n = rr === 0 ? 1 : Math.ceil((TAU * rr) / 0.06);    for (let t = 0; t < n; t++) { const a = (t / n) * TAU, px = x + rr * Math.cos(a), py = y + rr * Math.sin(a); if (spotOK(s, px, py, R, self)) return [px, py]; }  }  return null;}/** 家を取り除く: 住んでいた世帯は町の空き家へ移り、無ければ町を出る */function evict(s: S, lots: Set<number>) {  buildHH(s);  const vac: number[] = [];  for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l] && !lots.has(l)) vac.push(l);  for (const l of lots) {    const a = s.hs[l], b = s.hs[l + 1];    if (a < b) { const v = vac.length ? vac.splice(Math.floor(s.rnd() * vac.length), 1)[0] : -1; for (let t = a; t < b; t++) { const i = s.hl[t]; if (v >= 0) s.house[i] = v; else { s.gone[i] = 1; s.extraOut++; } } }    s.lst[l] = 2;  }  compact(s);}function setHouses(api: Api, s: S, target: number) {  buildHH(s);  let have = 0; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) have++;  const cx = 3.5, cy = 2.4, d = (l: number) => (s.lx[l] - cx) ** 2 + (s.ly[l] - cy) ** 2 - (s.near[l] ? 4 : 0);  if (target > have) {    // 町の中心と駅に近い空き区画から建てる    const empty: number[] = []; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 0 && !nearFac(s, s.lx[l], s.ly[l])) empty.push(l);    empty.sort((a, b) => d(a) - d(b));    for (const l of empty) { if (have >= target) break; s.lst[l] = 1; s.lvac[l] = 0; have++; }    if (have < target) say(api, s, `lots-${s.year}`, "家を建てる区画が足りません。地図に住宅地を置くと、区画が増えます", true);  } else if (target < have) {    // 中心から遠い空き家から壊し、足りなければ人の住む家も壊す    const vac: number[] = [], occ: number[] = [];    for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) (s.locc[l] ? occ : vac).push(l);    vac.sort((a, b) => d(b) - d(a)); occ.sort((a, b) => d(b) - d(a));    const gone = new Set<number>();    for (const l of [...vac, ...occ]) { if (have <= target) break; gone.add(l); have--; }    const moved = occ.filter((l) => gone.has(l)).length;    evict(s, gone);    for (const l of gone) s.lst[l] = 0;    if (moved) say(api, s, `evict-${s.year}-${target}`, `人の住む家を ${moved} 戸取り壊しました。移る空き家が無い世帯は町を出ます`, true);  }  if (Math.round(api.num("houses")) !== have) api.set("houses", have);  refresh(api, s);}const LIMIT: Partial<Record<Kind, number>> = { factory: 4, daycare: 4, station: 3, zone: 8 };function place(api: Api, s: S, tool: string, x: number, y: number) {  if (tool === "remove") { api.note("施設をたたくと取り除けます(長押しでも取り除けます)"); return; }  const kind = tool as Kind, n = s.facs.filter((f) => f.kind === kind && (kind !== "zone" || f.user)).length;  if (n >= (LIMIT[kind] ?? 4)) { api.note(`${NAME[kind]}は ${LIMIT[kind]} つまで置けます`); return; }  let f: Fac | null = null;  if (kind === "station") {    const sx = clamp(x, 0.3, W_KM - 0.3);    if (s.facs.some((o) => o.kind === "station" && Math.abs(o.x - sx) < 1)) { api.note("駅と駅は 1 km 以上離して置けます"); return; }    f = addFac(s, "station", "駅", sx, railY(sx), true);    api.note("駅を置きました。駅から 1.5 km の中に住む人は、市の仕事に通えます", 2600);  } else if (kind === "zone") {    const z = makeZone(s, "住宅地", clamp(x - 0.3, 0.05, W_KM - 0.65), clamp(y - 0.21, 0.05, H_KM - 0.47), 0.6, 0.42, true);    if (z.lots.length < 12) { for (const l of z.lots) s.lst[l] = 2; s.zones.pop(); api.note("ここには家を建てられません(山・川・線路・ほかの家の上)"); return; }    for (const l of z.lots) { s.lst[l] = 1; s.lvac[l] = 0; }    f = addFac(s, "zone", "住宅地", z.x + z.w / 2, z.y + z.h / 2, true, z);    api.note(`住宅地に家を ${z.lots.length} 戸建てました。人が来るまでは空き家です`, 2600);  } else {    const p = freeSpot(s, kind, x, y);    if (!p) { api.note("近くに、家と重ならない場所がありません"); return; }    f = addFac(s, kind, NAME[kind], p[0], p[1], true);    api.note(kind === "factory" ? `工場を置きました。${FACTORY_JOBS} 人分の仕事が増えます` : `保育所を置きました。1〜5 歳の子を ${DAYCARE_CAP} 人まで預かれます`, 2600);  }  s.sel = f;  refresh(api, s);  if (kind === "zone") api.set("houses", s.st.houses);  api.emit("place", { kind });}const removable = (f: Fac) => f.kind === "factory" || f.kind === "daycare" || f.kind === "station" || (f.kind === "zone" && f.user);function removeFac(api: Api, s: S, f: Fac) {  if (!removable(f)) { api.note(f.kind === "es" || f.kind === "jhs" ? "学校は、通う子どもの数で開くか閉じるかが決まります(動かすことはできます)" : `${f.name}は取り除けません`); return; }  if (f.kind === "zone" && f.zone) { const z = f.zone; evict(s, new Set(z.lots)); s.zones.splice(s.zones.indexOf(z), 1); }  s.facs.splice(s.facs.indexOf(f), 1);  if (s.sel === f) s.sel = null;  api.note(`${f.kind === "zone" ? "住宅地" : f.name}を取り除きました`);  refresh(api, s);  if (f.kind === "zone") api.set("houses", s.st.houses);  api.emit("remove", { kind: f.kind });}function moveFac(api: Api, s: S, d: Drag, x: number, y: number, drop: boolean) {  const f = d.fac!;  if (f.kind === "hall" || f.kind === "hospital") return;  if (f.kind === "station") { f.x = clamp(x, 0.3, W_KM - 0.3); f.y = railY(f.x); }  else if (f.kind === "zone" && f.zone) {    const z = f.zone, nx = clamp(x, z.w / 2 + 0.05, W_KM - z.w / 2 - 0.05), ny = clamp(y, z.h / 2 + 0.05, H_KM - z.h / 2 - 0.05), ddx = nx - f.x, ddy = ny - f.y;    for (const l of z.lots) { s.lx[l] += ddx; s.ly[l] += ddy; }    for (let t = 0; t < z.streets.length; t += 2) { z.streets[t] += ddx; z.streets[t + 1] += ddy; }    z.x += ddx; z.y += ddy; f.x = nx; f.y = ny;  } else { f.x = clamp(x, 0.1, W_KM - 0.1); f.y = clamp(y, 0.1, H_KM - 0.1); }  if (!drop) return;  if (f.kind !== "station" && f.kind !== "zone") {    const p = freeSpot(s, f.kind, f.x, f.y, f);    if (p) { f.x = p[0]; f.y = p[1]; } else { f.x = d.sx; f.y = d.sy; api.note("家と重ならない場所に置けます"); }  }  refresh(api, s);}/** 指の操作: 何もない所をたたくと置く・施設をドラッグすると動かす・長押しか「取り除く」でたたくと取り除く・たたくと選ぶ */function onPointer(type: "down" | "move" | "up" | "cancel" | "wheel", api: Api, s: S) {  const L = s.lay; if (!L) return;  const P = api.pointer, wx = (P.x - L.mx) / L.k, wy = (P.y - L.my) / L.k;  if (type === "down") {    if (P.x < L.mx || P.x > L.mx + L.mw || P.y < L.my || P.y > L.my + L.mh) { s.drag = null; return; }    const f = hit(api, s, L, P.x, P.y);    s.drag = { fac: f, x0: P.x, y0: P.y, t0: performance.now(), moved: false, ox: f ? f.x - wx : 0, oy: f ? f.y - wy : 0, sx: f ? f.x : 0, sy: f ? f.y : 0, done: false };    return;  }  const d = s.drag; if (!d) return;  if (type === "move") {    if (!d.moved && Math.hypot(P.x - d.x0, P.y - d.y0) > api.u(6)) d.moved = true;    if (d.fac && d.moved && !d.done) moveFac(api, s, d, wx + d.ox, wy + d.oy, false);    return;  }  s.drag = null;  if (type !== "up" || d.done) return;  if (d.fac && d.moved) { moveFac(api, s, d, wx + d.ox, wy + d.oy, true); s.sel = d.fac; return; }  if (d.fac) {    if (api.str("tool") === "remove" || performance.now() - d.t0 > 650) removeFac(api, s, d.fac);    else s.sel = s.sel === d.fac ? null : d.fac;    return;  }  if (!d.moved) { s.sel = null; place(api, s, api.str("tool"), wx, wy); }}

描く

色は白と黒の2色だけで、濃淡は線の太さと点の数で表す。人口ピラミッドの細い線は2025年の形で、棒の外では文字の色、棒の中では地の色で描き、どちらに重なっても見えるようにした。家の四角が小さくしか描けない画面では、空き家を小さな点にして、人の住む家との違いを大きさで見せる。駅があると、朝は東の市へ向かう電車、夕方は市から戻る電車が線路を走る。地図の学校の「文」と、田んぼに並ぶ「‖」は、地図記号の形を借りている。

描画(src/sims/town-population.ts の 739〜995 行)
/** 配置: 横長なら地図を左に、2 つのグラフを右に縦に並べる。縦長なら地図を上に、グラフを下に。右下の角(170×50 px)は画面のボタンのために空ける */function layout(api: Api): Lay {  const W = api.w, H = api.h, u = api.u, m = u(12), asp = H_KM / W_KM, BTN = 54;  let mx: number, my: number, mw: number, mh: number, pyr: Box, chart: Box, log: Box | null = null;  if (W >= H * 1.15) {    const colW = clamp(W * 0.3, u(220), u(400));    mw = W - colW - m * 3; mh = mw * asp;    if (mh > H - m * 2) { mh = H - m * 2; mw = mh / asp; }    mx = m + (W - colW - m * 3 - mw) / 2; my = (H - mh) / 2;    // 地図の下に余りがあれば「できごと」の欄を置く    if (H - mh - 2 * m >= u(84)) { my = m + Math.min(u(8), (H - mh - 2 * m - u(84)) / 2); log = { x: mx, y: my + mh + u(10), w: mw, h: H - my - mh - u(10) - m }; }    const cx = W - colW - m, ch = H - m - Math.max(m, BTN);    pyr = { x: cx, y: m, w: colW, h: ch * 0.56 }; chart = { x: cx, y: m + ch * 0.56 + u(10), w: colW, h: ch * 0.44 - u(10) };  } else {    mw = W - 2 * m; mh = mw * asp;    if (mh > H * 0.5) { mh = H * 0.5; mw = mh / asp; }    mx = (W - mw) / 2; my = m;    const y0 = my + mh + u(16), ah = H - y0 - Math.max(m, BTN);    if (ah > W * 1.1) { pyr = { x: m, y: y0, w: W - 2 * m, h: ah * 0.56 }; chart = { x: m, y: y0 + ah * 0.56 + u(10), w: W - 2 * m, h: ah * 0.44 - u(10) }; }    else { const hw = (W - 3 * m) / 2; pyr = { x: m, y: y0, w: hw, h: ah }; chart = { x: 2 * m + hw, y: y0, w: hw, h: ah }; }  }  const k = mw / W_KM;  return { mx, my, mw, mh, k, pyr, chart, log, X: (x) => mx + x * k, Y: (y) => my + y * k };}function polyline(api: Api, L: Lay, f: (x: number) => number, x0: number, x1: number, w: number, color: "ink" | "paper", dash?: number[]) {  const c = api.ctx; c.beginPath();  for (let x = x0; x <= x1 + 1e-9; x += 0.05) { const X = L.X(x), Y = L.Y(f(x)); if (x === x0) c.moveTo(X, Y); else c.lineTo(X, Y); }  c.lineWidth = w; c.strokeStyle = color === "ink" ? api.ink : api.paper; c.setLineDash(dash ?? []); c.stroke(); c.setLineDash([]);}function drawMap(api: Api, s: S, L: Lay) {  const u = api.u, c = api.ctx, k = L.k;  c.save(); c.beginPath(); c.rect(L.mx, L.my, L.mw, L.mh); c.clip();  // 等高線と田の記号(‖)  const C = CONTOURS!; c.beginPath();  for (let t = 0; t < C.length; t += 4) { c.moveTo(L.X(C[t]), L.Y(C[t + 1])); c.lineTo(L.X(C[t + 2]), L.Y(C[t + 3])); }  c.lineWidth = u(0.6); c.strokeStyle = api.ink; c.setLineDash([]); c.stroke();  const PD = PADDY!, ph = Math.max(1.2, u(2.1)), pg = Math.max(0.8, u(1.1)), userZ = s.zones.filter((z) => z.user);  c.beginPath();  for (let t = 0; t < PD.length; t += 2) {    const x = PD[t], y = PD[t + 1];    if (userZ.some((z) => x > z.x - 0.05 && x < z.x + z.w + 0.05 && y > z.y - 0.05 && y < z.y + z.h + 0.05)) continue;    if (s.facs.some((f) => f.kind !== "zone" && Math.abs(f.x - x) < FOOT[f.kind] + 0.06 && Math.abs(f.y - y) < FOOT[f.kind] + 0.04)) continue;    const X = L.X(x), Y = L.Y(y);    c.moveTo(X - pg, Y - ph); c.lineTo(X - pg, Y + ph); c.moveTo(X + pg, Y - ph); c.lineTo(X + pg, Y + ph);  }  c.lineWidth = u(0.6); c.stroke();  // 川(横の線で塗る)  const up: [number, number][] = [], dn: [number, number][] = [];  for (let x = -0.05; x <= W_KM + 0.05; x += 0.05) { up.push([L.X(x), L.Y(riverY(x) - 0.035)]); dn.push([L.X(x), L.Y(riverY(x) + 0.035)]); }  api.hatch([...up, ...[...dn].reverse()], { gap: Math.max(1.5, u(2)), angle: 0, w: u(0.5) });  api.poly(up, { w: u(0.9) }); api.poly(dn, { w: u(0.9) });  // 道: 国道は二重線、県道と集落の道は細い線  for (const z of s.zones) if (z.name === "集落") { const x = z.x + z.w / 2; api.line(L.X(x), L.Y(z.y + z.h / 2), L.X(x), L.Y(roadY(x)), { w: u(0.6), dash: [u(3), u(2)] }); }  c.beginPath(); c.moveTo(L.X(roadX(0.7)), L.Y(0.7)); for (let y = 0.7; y <= railY(3.48) + 0.3; y += 0.05) c.lineTo(L.X(roadX(y)), L.Y(y));  c.lineWidth = u(1.1); c.strokeStyle = api.ink; c.stroke();  polyline(api, L, roadY, -0.1, W_KM + 0.1, Math.max(3, u(4.4)), "ink"); polyline(api, L, roadY, -0.1, W_KM + 0.1, Math.max(1.6, u(2.6)), "paper");  // 線路(白と黒の交互)  polyline(api, L, railY, -0.1, W_KM + 0.1, Math.max(2.4, u(3.4)), "ink"); polyline(api, L, railY, -0.1, W_KM + 0.1, Math.max(1.2, u(1.8)), "paper", [u(8), u(8)]);  // 住宅地: 区画の道と家。人の住む家は塗りの四角。空き家は、家が大きく描けるときは白抜きの四角(10 年以上なら斜線も)、小さいときは小さな点  c.beginPath();  for (const z of s.zones) for (let t = 0; t < z.streets.length; t += 4) { c.moveTo(L.X(z.streets[t]), L.Y(z.streets[t + 1])); c.lineTo(L.X(z.streets[t + 2]), L.Y(z.streets[t + 3])); }  c.lineWidth = u(0.45); c.strokeStyle = api.ink; c.stroke();  const hsz = Math.max(1.1, SP * k * 0.62), hh = hsz / 2;  c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && s.locc[l]) c.rect(L.X(s.lx[l]) - hh, L.Y(s.ly[l]) - hh, hsz, hsz);  c.fillStyle = api.ink; c.fill();  if (hsz >= 3.4) {    c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l]) c.rect(L.X(s.lx[l]) - hh + 0.4, L.Y(s.ly[l]) - hh + 0.4, hsz - 0.8, hsz - 0.8);    c.lineWidth = 0.8; c.stroke();    c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l] && s.lvac[l] >= 10) { const X = L.X(s.lx[l]), Y = L.Y(s.ly[l]); c.moveTo(X - hh, Y + hh); c.lineTo(X + hh, Y - hh); }    c.stroke();  } else {    const q = Math.max(0.5, hsz * 0.22);    c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l]) c.rect(L.X(s.lx[l]) - q, L.Y(s.ly[l]) - q, 2 * q, 2 * q);    c.fill();  }  // 駅から 1.5 km の範囲  for (const f of s.facs) if (f.kind === "station") api.circle(L.X(f.x), L.Y(f.y), R_ST * k, { w: u(1.1), dash: [u(4), u(3)] });  drawPeople(api, s, L);  drawTrain(api, s, L);  for (const f of s.facs) drawFac(api, s, L, f);  api.label("川", L.X(0.3), L.Y(riverY(0.3)) + u(4), { size: Math.max(8, u(9.5)), align: "center" });  api.label("市へ", L.X(W_KM) - u(4), L.Y(railY(W_KM)) - u(7), { size: Math.max(8, u(9.5)), align: "right" });  c.restore();  // 枠・方位・縮尺・時刻・凡例  api.rect(L.mx, L.my, L.mw, L.mh, { w: u(1.2) });  const fs = Math.max(8, u(10.5)), bx = L.mx + u(10), by = L.my + L.mh - u(12);  api.rect(bx - u(4), by - u(9), k + u(44), u(16), { fill: "paper", stroke: false });  api.line(bx, by, bx + k, by, { w: u(1.4) }); api.line(bx, by - u(4), bx, by + u(1), { w: u(1.2) }); api.line(bx + k, by - u(4), bx + k, by + u(1), { w: u(1.2) });  api.text("1 km", bx + k + u(6), by + u(3), { size: fs, font: "mono" });  const nx = L.mx + L.mw - u(16), ny = L.my + u(14);  api.arrow(nx, ny + u(12), nx, ny - u(4), { w: u(1.2), head: u(5) }); api.label("北", nx, ny + u(24), { size: fs, align: "center" });  const tod = s.phase, word = tod < 0.38 ? "朝" : tod < 0.52 ? "昼" : tod < 0.86 ? "夕方" : "夜";  api.label(`${s.year} 年  ${word} ${clock(tod)}`, L.mx + u(10), L.my + u(18), { size: u(12.5), font: "mono" });  if (L.mw > u(430)) {    const q = u(3), lgX = L.mx + L.mw - u(132), lgY = L.my + L.mh - u(10);    api.rect(lgX - u(5), lgY - u(12), u(130), u(17), { fill: "paper", stroke: false });    api.rect(lgX, lgY - q * 2, q * 2, q * 2, { fill: "ink", stroke: false }); api.text("人の住む家", lgX + u(9), lgY, { size: Math.max(8, u(9.5)) });    if (hsz >= 3.4) api.rect(lgX + u(70), lgY - q * 2, q * 2, q * 2, { w: 0.8 }); else api.dot(lgX + u(70) + q, lgY - q, Math.max(0.5, hsz * 0.22));    api.text("空き家", lgX + u(79), lgY, { size: Math.max(8, u(9.5)) });  }}function drawTrain(api: Api, s: S, L: Lay) {  if (!s.facs.some((f) => f.kind === "station")) return;  const ph = s.phase, u = api.u;  // 朝は東の市へ向かい(dir = 1)、夕方は市から戻る(dir = -1)  for (const [t0, dir] of [[0.08, 1], [0.22, 1], [0.62, -1], [0.76, -1]] as const) {    const f = (ph - t0) / 0.12; if (f < 0 || f > 1) continue;    const head = dir < 0 ? W_KM + 0.4 - f * (W_KM + 1.2) : -0.4 + f * (W_KM + 1.2);    for (let car = 0; car < 4; car++) {      const x = head - dir * car * 0.2, x2 = x - dir * 0.17;      api.line(L.X(x), L.Y(railY(x)), L.X(x2), L.Y(railY(x2)), { w: Math.max(3, u(5)) });    }  }}function drawFac(api: Api, s: S, L: Lay, f: Fac) {  const u = api.u, X = L.X(f.x), Y = L.Y(f.y), k = L.k, fs = Math.max(8, u(9.5)), c = api.ctx;  let lab = f.name, ly = Y + u(16), top = Y - u(10), right = X + u(10);  if (f.kind === "es" || f.kind === "jhs") {    // 学校: 校庭と校舎。上に学校の地図記号「文」    const w = Math.max(u(15), (f.kind === "es" ? 0.2 : 0.24) * k), h = Math.max(u(10), 0.13 * k);    api.rect(X - w / 2, Y - h / 2, w, h, { w: u(1), dash: f.open ? undefined : [u(2), u(2)] }); // 塗らない(昼は校庭に子どもの点が見える)    if (f.open) { c.beginPath(); c.ellipse(X + w * 0.1, Y, w * 0.3, h * 0.28, 0, 0, TAU); c.lineWidth = u(0.6); c.strokeStyle = api.ink; c.stroke(); api.rect(X - w / 2, Y - h / 2, w * 0.18, h, { fill: "ink", stroke: false }); }    else { api.line(X - w / 2, Y - h / 2, X + w / 2, Y + h / 2, { w: u(0.8) }); api.line(X - w / 2, Y + h / 2, X + w / 2, Y - h / 2, { w: u(0.8) }); }    api.label("文", X, Y - h / 2 - u(3), { size: fs * 1.1, align: "center", weight: 700 });    lab = f.open ? f.name : `${f.name}(閉校)`; ly = Y + h / 2 + u(11); top = Y - h / 2 - u(14); right = X + w / 2;  } else if (f.kind === "factory") {    // 工場: のこぎり屋根の建物と、歯車の印    const w = Math.max(u(22), 0.3 * k), h = Math.max(u(12), 0.16 * k), x0 = X - w / 2, y0 = Y - h / 2, n = 4, pts: [number, number][] = [[x0, Y + h / 2]];    for (let t = 0; t < n; t++) { pts.push([x0 + (t * w) / n, y0 + h * 0.35]); pts.push([x0 + ((t + 0.75) * w) / n, y0]); pts.push([x0 + ((t + 0.75) * w) / n, y0 + h * 0.35]); }    pts.push([x0 + w, y0 + h * 0.35], [x0 + w, Y + h / 2]);    api.poly(pts, { close: true, w: u(1.1) }); api.hatch(pts, { gap: Math.max(2.4, u(4)), angle: 45, w: u(0.45) }); // 塗らない(昼は働く人の点が見える)    const gx = x0 - u(7), gy = y0 + u(1), gr = u(3.6);    for (let t = 0; t < 8; t++) { const a = (t / 8) * TAU; api.line(gx + Math.cos(a) * gr, gy + Math.sin(a) * gr, gx + Math.cos(a) * (gr + u(2)), gy + Math.sin(a) * (gr + u(2)), { w: u(1.4) }); }    api.circle(gx, gy, gr, { fill: "paper", w: u(1.1) }); api.circle(gx, gy, gr * 0.35, { w: u(0.8) });    ly = Y + h / 2 + u(11); top = y0 - u(6); right = x0 + w;  } else if (f.kind === "daycare") {    const w = u(14), h = u(11);    api.rect(X - w / 2, Y - h / 2, w, h, { fill: "paper", w: u(1) }); api.text("保", X, Y + fs * 0.36, { size: fs * 0.95, align: "center", weight: 700 });    ly = Y + h / 2 + u(11); top = Y - h / 2; right = X + w / 2;  } else if (f.kind === "station") {    const a = Math.atan2(railY(f.x + 0.05) - railY(f.x - 0.05), 0.1);    c.save(); c.translate(X, Y); c.rotate(a); api.rect(-u(11), -u(4), u(22), u(8), { fill: "paper", w: u(1.3) }); c.restore();    ly = Y - u(9); top = Y - u(6); right = X + u(11);  } else if (f.kind === "hall") {    api.circle(X, Y, u(5), { fill: "paper", w: u(1.1) }); api.circle(X, Y, u(2.2), { w: u(1) }); ly = Y + u(15);  } else if (f.kind === "hospital") {    api.rect(X - u(5), Y - u(5), u(10), u(10), { fill: "paper", w: u(1) }); api.line(X - u(3), Y, X + u(3), Y, { w: u(1.4) }); api.line(X, Y - u(3), X, Y + u(3), { w: u(1.4) }); ly = Y + u(15);  } else if (f.kind === "zone" && f.zone) {    const z = f.zone; api.rect(L.X(z.x), L.Y(z.y), z.w * k, z.h * k, { w: u(0.9), dash: [u(4), u(3)] });    lab = "新しい住宅地"; ly = L.Y(z.y) - u(4); top = L.Y(z.y); right = L.X(z.x + z.w);  }  // 地図が小さいときは、役場・病院・保育所の名前を省いて重なりを減らす  if (k >= 45 || (f.kind !== "hall" && f.kind !== "hospital" && f.kind !== "daycare")) api.label(lab, X, ly, { size: fs, align: "center" });  // つかめる施設の取っ手(四角)・長押しの進み・選んだ施設の輪と説明  if (f.kind !== "hall" && f.kind !== "hospital") { const q = u(2.6); api.rect(right + u(2) - q, top - q, 2 * q, 2 * q, { fill: "paper", w: u(1) }); }  const d = s.drag;  if (d && d.fac === f && !d.moved && !d.done && removable(f)) {    const p = clamp((performance.now() - d.t0) / 650, 0, 1);    if (p > 0.15) { c.beginPath(); c.arc(X, Y, u(20), -Math.PI / 2, -Math.PI / 2 + p * TAU); c.lineWidth = u(2.2); c.strokeStyle = api.ink; c.setLineDash([]); c.stroke(); }  }  if (s.sel === f) {    if (f.kind !== "zone") api.circle(X, Y, u(19), { w: u(1), dash: [u(1.5), u(2.5)] });    const info = facInfo(s, f); c.font = `400 ${fs}px sans-serif`;    const tw = c.measureText(info).width, tx = clamp(X - tw / 2, L.mx + u(6), L.mx + L.mw - tw - u(6));    api.label(info, tx, Math.max(L.my + u(40), top - u(10)), { size: fs });  }}function facInfo(s: S, f: Fac) {  switch (f.kind) {    case "factory": return `工場  ${FACTORY_JOBS} 人分の仕事・働く人 ${f.filled} 人`;    case "daycare": return `保育所  定員 ${DAYCARE_CAP} 人・預ける子 ${f.users} 人`;    case "station": return `駅  1.5 km の中に ${nf(f.users)} 人・市へ通う人 ${f.filled} 人`;    case "es": case "jhs": return f.open ? `${f.name}  通う子ども ${f.users} 人` : `${f.name}  閉校`;    case "zone": { let v = 0, h = 0; for (const l of f.zone!.lots) if (s.lst[l] === 1) { h++; if (!s.locc[l]) v++; } return `住宅地  家 ${h} 戸・空き家 ${v} 戸`; }    default: return f.name;  }}const niceUp = (v: number) => { const e = Math.pow(10, Math.floor(Math.log10(Math.max(1, v)))); for (const m of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 10]) if (m * e >= v) return m * e; return 10 * e; };/** 人口ピラミッド: 1 歳ごとの棒(左が男・右が女)。細い線は最初の年の形 */function drawPyramid(api: Api, s: S, b: Box, tfr: number) {  const u = api.u, c = api.ctx, fs = Math.max(8, u(10)), A = AMAX + 1, st = s.st, rx = b.x + b.w;  api.text("人口ピラミッド", b.x, b.y + u(12), { size: u(12.5), weight: 600 });  api.text(`${s.year} 年`, rx, b.y + u(12), { size: u(11.5), align: "right", font: "mono" });  api.text(`65 歳以上 ${fmt(st.old * 100, 1)} %・15 歳未満 ${fmt(st.young * 100, 1)} %`, b.x, b.y + u(28), { size: fs });  api.text(`合計特殊出生率 ${fmt(tfr, 2)}(人口置換水準 ${fmt(REPL, 2)})`, b.x, b.y + u(42), { size: fs });  const top = b.y + u(54), bot = b.y + b.h - u(28), gap = u(26), cx = b.x + b.w / 2, half = b.w / 2 - gap / 2;  const bh = (bot - top) / 101, P = s.pyr, P0 = s.pyr0;  const cnt = (Q: Float32Array, sx: number, a: number) => { if (a < 100) return Q[sx * A + a]; let t = 0; for (let x = 100; x <= AMAX; x++) t += Q[sx * A + x]; return t; };  let mx = 1; for (let a = 0; a <= 100; a++) for (let sx = 0; sx < 2; sx++) mx = Math.max(mx, cnt(P, sx, a), cnt(P0, sx, a));  const sc = niceUp(mx), wOf = (v: number) => (v / sc) * half, yOf = (a: number) => bot - (a + 1) * bh;  const bars = () => { c.beginPath(); for (let a = 0; a <= 100; a++) { const y = yOf(a), m = wOf(cnt(P, 0, a)), f = wOf(cnt(P, 1, a)); c.rect(cx - gap / 2 - m, y, m, bh + 0.3); c.rect(cx + gap / 2, y, f, bh + 0.3); } };  bars(); c.fillStyle = api.ink; c.fill();  // 最初の年の形。棒の外では文字の色、棒の中では地の色で描き、どちらでも見えるようにする  const outline = () => {    c.beginPath();    for (const sx of [0, 1]) for (let a = 0; a <= 100; a++) {      const w = wOf(cnt(P0, sx, a)), x = sx ? cx + gap / 2 + w : cx - gap / 2 - w, y0 = yOf(a), y1 = y0 + bh;      if (a === 0) c.moveTo(x, y1); else c.lineTo(x, y1);      c.lineTo(x, y0);    }  };  outline(); c.lineWidth = u(0.9); c.strokeStyle = api.ink; c.setLineDash([]); c.stroke();  c.save(); bars(); c.clip(); outline(); c.strokeStyle = api.paper; c.stroke(); c.restore();  // 年齢の目盛りと、15 歳・65 歳の線  for (let a = 0; a <= 100; a += 20) api.text(String(a), cx, yOf(a) + bh + fs * 0.35, { size: fs * 0.9, align: "center", font: "mono" });  for (const a of [15, 65]) { const y = yOf(a) + bh; api.line(b.x, y, b.x + b.w, y, { w: u(0.7), dash: [u(2), u(3)] }); }  api.text("男", b.x, top + fs, { size: fs }); api.text("女", rx, top + fs, { size: fs, align: "right" });  api.text(`細い線は ${START} 年の形`, cx, bot + u(26), { size: fs * 0.9, align: "center" });  for (const v of [sc / 2, sc]) for (const sg of [-1, 1]) { const x = cx + sg * (gap / 2 + wOf(v)); api.line(x, bot + u(1), x, bot + u(4), { w: u(0.8) }); }  api.line(b.x, bot + u(1), b.x + b.w, bot + u(1), { w: u(0.7) });  api.text(`${nf(sc)} 人`, cx - gap / 2 - wOf(sc), bot + u(14), { size: fs * 0.9, align: "left", font: "mono" });  api.text(`${nf(sc)} 人`, cx + gap / 2 + wOf(sc), bot + u(14), { size: fs * 0.9, align: "right", font: "mono" });}/** 地図の下の「できごと」: 新しい順に、入るだけ */function drawLog(api: Api, s: S, b: Box) {  const u = api.u, fs = Math.max(8, u(10.5)), lh = fs * 1.55;  api.line(b.x, b.y, b.x + b.w, b.y, { w: u(0.6), dash: [u(2), u(3)] });  api.text("できごと", b.x, b.y + u(16), { size: fs, weight: 600 });  const n = Math.max(0, Math.floor((b.h - u(22)) / lh));  if (!s.log.length) { api.text("まだ大きなできごとはありません", b.x + u(64), b.y + u(16), { size: fs }); return; }  const c = api.ctx; c.save(); c.beginPath(); c.rect(b.x, b.y, b.w, b.h); c.clip();  for (let k = 0; k < Math.min(n, s.log.length); k++) api.text(s.log[k], b.x + u(64), b.y + u(16) + k * lh, { size: fs });  c.restore();}/** 総人口(実線・左の目盛り)と高齢化率(破線・右の目盛り)の移り変わり */function drawChart(api: Api, s: S, b: Box) {  const u = api.u, fs = Math.max(8, u(10)), H = s.hist, n = H.n;  api.text("総人口と高齢化率", b.x, b.y + u(12), { size: u(12.5), weight: 600 });  const x0 = b.x + u(34), x1 = b.x + b.w - u(30), y0 = b.y + u(28), y1 = b.y + b.h - u(18);  const k0 = Math.max(0, n - 1 - 150), yrs = Math.min(150, Math.max(50, Math.ceil((n + 4) / 25) * 25)), X = (k: number) => x0 + ((k - k0) / yrs) * (x1 - x0);  let pm = 1; for (let k = k0; k < n; k++) pm = Math.max(pm, H.pop[k]);  const PM = niceUp(pm * 1.05), Yp = (v: number) => y1 - (v / PM) * (y1 - y0), Yo = (v: number) => y1 - (v / 0.7) * (y1 - y0);  api.line(x0, y1, x1, y1, { w: u(0.8) }); api.line(x0, y0, x0, y1, { w: u(0.8) }); api.line(x1, y0, x1, y1, { w: u(0.8), dash: [u(2), u(2)] });  for (const v of [PM / 2, PM]) { api.line(x0 - u(4), Yp(v), x0, Yp(v), { w: u(0.8) }); api.text(nf(v), x0 - u(6), Yp(v) + fs * 0.35, { size: fs * 0.9, align: "right", font: "mono" }); }  for (const v of [0.2, 0.4, 0.6]) { api.line(x1, Yo(v), x1 + u(4), Yo(v), { w: u(0.8) }); api.text(`${Math.round(v * 100)}%`, x1 + u(6), Yo(v) + fs * 0.35, { size: fs * 0.9, font: "mono" }); }  const step = yrs > 100 ? 50 : 25;  for (let yr = Math.ceil((START + k0) / step) * step; yr <= START + k0 + yrs; yr += step) { const x = X(yr - START); api.line(x, y1, x, y1 + u(4), { w: u(0.8) }); api.text(String(yr), x, y1 + u(14), { size: fs * 0.9, align: "center", font: "mono" }); }  const pts: number[] = [], po: number[] = [];  for (let k = k0; k < n; k++) { pts.push(X(k), Yp(H.pop[k])); po.push(X(k), Yo(H.old[k])); }  if (n >= 2) { api.poly(pts, { w: u(1.8) }); api.poly(po, { w: u(1.3), dash: [u(4), u(3)] }); }  const lx = X(n - 1), lp = Yp(H.pop[n - 1]), lo = Yo(H.old[n - 1]);  api.dot(lx, lp, u(2.4)); api.dot(lx, lo, u(2.2));  // 札はグラフの右上に凡例として置く(点の横に置くと、下地が折れ線を隠すため)  const t1 = `総人口 ${nf(H.pop[n - 1])} 人(実線)`, t2 = `高齢化率 ${fmt(H.old[n - 1] * 100, 1)} %(破線)`;  api.text(t1, x1 - u(6), y0 + fs, { size: fs, align: "right" });  api.text(t2, x1 - u(6), y0 + fs * 2.4, { size: fs, align: "right" });}function readout(s: S) {  const c = s.cur, st = s.st, j = s.job, d = (v: number) => (c.done ? nf(v) : "-"), dd = (v: number) => (c.done ? sgn(v) : "-");  return [    `年  ${s.year} 年(始めから ${s.year - START} 年)    総人口  ${nf(st.pop)} 人`,    `65 歳以上の割合  ${fmt(st.old * 100, 1)} %    15 歳未満の割合  ${fmt(st.young * 100, 1)} %`,    `その年の出生  ${d(c.births)} 人    死亡  ${d(c.deaths)} 人    自然増減  ${dd(c.births - c.deaths)} 人`,    `転入  ${d(c.ins)} 人    転出  ${d(c.outs)} 人    社会増減  ${dd(c.ins - c.outs)} 人`,    `空き家  ${nf(st.vac)} 戸(${fmt(st.houses ? (st.vac / st.houses) * 100 : 0, 1)} %)    仕事  ${nf(j.J)} 人分・働く人 ${nf(j.LF)} 人`,  ];}

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/town-population.ts・1044 行)

読み込んでいます

姉妹サイト

株式会社Game が運営するメディアです。