ふるさと納税で危険な年収帯


ふるさと納税の限度額の目安を、公式に基づいて算出すると次のようになる。

ふるさと納税の整理図

なぞのジャンプがある

上記の図の通り年収440万前後および650万円前後でジャンプが発生している。 これは主に所得税率の境目で起きる。

傾きが急になる理由

ふるさと納税の控除は、ざっくり次の3つに分かれる。

  • 所得税控除
  • 住民税基本分
  • 住民税特例分

このうち、上限額を実質的に決めているのは住民税特例分である。
住民税特例分には住民税所得割額の20%という上限があるため、この枠をどこまで使い切れるかで、ふるさと納税の限度額が決まる。

追加で寄附した金額は、まず所得税控除住民税基本分に配分され、残りを住民税特例分が受け持つ。

ここで、所得税率が切り替わって高くなると、追加の寄附額に対して 所得税控除 の割合がそれまでより多くなる。すると、そのぶん 住民税特例分 が受け持つ額はそれまでより小さくなる。

つまり、所得税率が高くなったあとは、同じ1万円を追加で寄附しても 住民税特例分の枠 の減り方がそれまでより遅くなる。
その結果、住民税特例分の上限に達するまでに許される寄附額が増え、グラフの傾きが急になる。税率差が大きい方が急になりやすい。

逆に、税率帯が変わらない区間では、追加寄附1万円あたりで 住民税特例分 をどれだけ使うかもほぼ一定なので、グラフの傾きもほぼ一定になる。

細かいギザギザ

細かいギザギザは社会保険の等級表を表している。曲線は階段的になり、局所的な戻りや細かい折れは等級切替の影響が強い。

ふるさと納税の拡大図

等級表切り替えの影響

N字型折れ

ふるさと納税の600万から700万円帯拡大図

654万円付近のN字型折れ。

654万付近のN字型折れは制度上ありうるもの。年収653万は月額だと544,166円、654万だと545,000円。この1万円差で標準報酬月額の等級が切り替わる。その結果、健康保険・支援金・厚生年金の従業員負担が一段上がり、ふるさと納税上限の変化は一時的に鈍化し、場合によってはマイナスになる。450万付近も似ている。

この議論は最も単純なモデルをベースとしており、扶養控除、保険、住宅ローン、iDeCoなど控除が増えれば増えるほど年収がずれていく。自分の状況に合わせたいなら税理士に確認するのが良いでしょう。

ふるさと納税の早見表などをもとに寄付する場合、この近辺では見落としが発生することがあり、例えば650万円から逆算するなら限度額超過に注意が必要です。よくわからなかったら保守的に考えるのが楽です。多少限度額を越えても損はしません。

細かい補足

できるだけ単純なモデルとした

  • 対象は 単身・扶養なし・iDeCo等なし
  • 会社員前提
  • 協会けんぽ東京
  • 40歳未満
  • 賞与なし
  • 社会保険料は年収固定率ではなく、標準報酬月額の等級表ベースで計算する

一般的な図表との違い

よく使われる簡便式は住民税特別控除を限界税率1本で使うため、境目で見かけ上の階段大ジャンプが出る。簡便式は、国税庁・総務省の控除ルールから上限額を逆算した「目安用」の式。実際の税務実務そのものが、この簡便式を使って最終税額を確定しているわけではない

年収1000万以上

所得税率の切り替わりのタイミングで同様の事象が発生する

なににせよ、ふるさと納税は、共同体に責任を負う市民の立場からすれば、速やかに廃止されるべき制度でしょう。

参考コード

bun

// furusato_tufte.ts
// bun run furusato_tufte.ts
//
// 出力:
//   - furusato_tufte.svg
//
// 前提:
//   - 単身
//   - 配偶者控除・扶養控除なし
//   - iDeCo なし
//   - 基礎控除あり
//   - 会社員(協会けんぽ東京・40歳未満・賞与なし)
//   - 社会保険料は標準報酬月額の等級表で計算
//   - 300万円〜1000万円を 1万円刻み
//
// ねらい:
//   - ジャンプ地点を自動検出
//   - 注釈は白い箱 + 細いリーダー線
//   - 660万円 / 850万円の補助線は出さない
//   - x/y 軸と目盛りを明示

const W = 1400;
const H = 930;

const M = {
  top: 80,
  right: 80,
  bottom: 200,
  left: 110,
};

const DEFAULT_X_MIN = 300; // 万円
const DEFAULT_X_MAX = 1000; // 万円
const STEP_YEN = 10_000;

const BASIC_DEDUCTION = 480_000;
const RESIDENT_BASIC_DEDUCTION = 430_000;
const MONTHS_PER_YEAR = 12;
const RECONSTRUCTION_SURTAX_RATE = 0.021;

const ASSUMPTIONS = {
  unemploymentInsuranceEmployeeRate: 0.005, // 一般の事業 令和8年度
} as const;

const HEALTH_INSURANCE_TABLE = [
  { upperBound: 63_000, employeeHealth: 2_856.5, employeeSupport: 66.7 },
  { upperBound: 73_000, employeeHealth: 3_349.0, employeeSupport: 78.2 },
  { upperBound: 83_000, employeeHealth: 3_841.5, employeeSupport: 89.7 },
  { upperBound: 93_000, employeeHealth: 4_334.0, employeeSupport: 101.2 },
  { upperBound: 101_000, employeeHealth: 4_826.5, employeeSupport: 112.7 },
  { upperBound: 107_000, employeeHealth: 5_122.0, employeeSupport: 119.6 },
  { upperBound: 114_000, employeeHealth: 5_417.5, employeeSupport: 126.5 },
  { upperBound: 122_000, employeeHealth: 5_811.5, employeeSupport: 135.7 },
  { upperBound: 130_000, employeeHealth: 6_205.5, employeeSupport: 144.9 },
  { upperBound: 138_000, employeeHealth: 6_599.5, employeeSupport: 154.1 },
  { upperBound: 146_000, employeeHealth: 6_993.5, employeeSupport: 163.3 },
  { upperBound: 155_000, employeeHealth: 7_387.5, employeeSupport: 172.5 },
  { upperBound: 165_000, employeeHealth: 7_880.0, employeeSupport: 184.0 },
  { upperBound: 175_000, employeeHealth: 8_372.5, employeeSupport: 195.5 },
  { upperBound: 185_000, employeeHealth: 8_865.0, employeeSupport: 207.0 },
  { upperBound: 195_000, employeeHealth: 9_357.5, employeeSupport: 218.5 },
  { upperBound: 210_000, employeeHealth: 9_850.0, employeeSupport: 230.0 },
  { upperBound: 230_000, employeeHealth: 10_835.0, employeeSupport: 253.0 },
  { upperBound: 250_000, employeeHealth: 11_820.0, employeeSupport: 276.0 },
  { upperBound: 270_000, employeeHealth: 12_805.0, employeeSupport: 299.0 },
  { upperBound: 290_000, employeeHealth: 13_790.0, employeeSupport: 322.0 },
  { upperBound: 310_000, employeeHealth: 14_775.0, employeeSupport: 345.0 },
  { upperBound: 330_000, employeeHealth: 15_760.0, employeeSupport: 368.0 },
  { upperBound: 350_000, employeeHealth: 16_745.0, employeeSupport: 391.0 },
  { upperBound: 370_000, employeeHealth: 17_730.0, employeeSupport: 414.0 },
  { upperBound: 395_000, employeeHealth: 18_715.0, employeeSupport: 437.0 },
  { upperBound: 425_000, employeeHealth: 20_192.5, employeeSupport: 471.5 },
  { upperBound: 455_000, employeeHealth: 21_670.0, employeeSupport: 506.0 },
  { upperBound: 485_000, employeeHealth: 23_147.5, employeeSupport: 540.5 },
  { upperBound: 515_000, employeeHealth: 24_625.0, employeeSupport: 575.0 },
  { upperBound: 545_000, employeeHealth: 26_102.5, employeeSupport: 609.5 },
  { upperBound: 575_000, employeeHealth: 27_580.0, employeeSupport: 644.0 },
  { upperBound: 605_000, employeeHealth: 29_057.5, employeeSupport: 678.5 },
  { upperBound: 635_000, employeeHealth: 30_535.0, employeeSupport: 713.0 },
  { upperBound: 665_000, employeeHealth: 32_012.5, employeeSupport: 747.5 },
  { upperBound: 695_000, employeeHealth: 33_490.0, employeeSupport: 782.0 },
  { upperBound: 730_000, employeeHealth: 34_967.5, employeeSupport: 816.5 },
  { upperBound: 770_000, employeeHealth: 36_937.5, employeeSupport: 862.5 },
  { upperBound: 810_000, employeeHealth: 38_907.5, employeeSupport: 908.5 },
  { upperBound: 855_000, employeeHealth: 40_877.5, employeeSupport: 954.5 },
  { upperBound: 905_000, employeeHealth: 43_340.0, employeeSupport: 1_012.0 },
  { upperBound: 955_000, employeeHealth: 45_802.5, employeeSupport: 1_069.5 },
  { upperBound: 1_005_000, employeeHealth: 48_265.0, employeeSupport: 1_127.0 },
  { upperBound: 1_055_000, employeeHealth: 50_727.5, employeeSupport: 1_184.5 },
  { upperBound: 1_115_000, employeeHealth: 53_682.5, employeeSupport: 1_253.5 },
  { upperBound: 1_175_000, employeeHealth: 56_637.5, employeeSupport: 1_322.5 },
  { upperBound: 1_235_000, employeeHealth: 59_592.5, employeeSupport: 1_391.5 },
  { upperBound: 1_295_000, employeeHealth: 62_547.5, employeeSupport: 1_460.5 },
  { upperBound: 1_355_000, employeeHealth: 65_502.5, employeeSupport: 1_529.5 },
  { upperBound: Number.POSITIVE_INFINITY, employeeHealth: 68_457.5, employeeSupport: 1_598.5 },
] as const;

const PENSION_TABLE = [
  { upperBound: 93_000, employeePension: 8_052.0 },
  { upperBound: 101_000, employeePension: 8_967.0 },
  { upperBound: 107_000, employeePension: 9_516.0 },
  { upperBound: 114_000, employeePension: 10_065.0 },
  { upperBound: 122_000, employeePension: 10_797.0 },
  { upperBound: 130_000, employeePension: 11_529.0 },
  { upperBound: 138_000, employeePension: 12_261.0 },
  { upperBound: 146_000, employeePension: 12_993.0 },
  { upperBound: 155_000, employeePension: 13_725.0 },
  { upperBound: 165_000, employeePension: 14_640.0 },
  { upperBound: 175_000, employeePension: 15_555.0 },
  { upperBound: 185_000, employeePension: 16_470.0 },
  { upperBound: 195_000, employeePension: 17_385.0 },
  { upperBound: 210_000, employeePension: 18_300.0 },
  { upperBound: 230_000, employeePension: 20_130.0 },
  { upperBound: 250_000, employeePension: 21_960.0 },
  { upperBound: 270_000, employeePension: 23_790.0 },
  { upperBound: 290_000, employeePension: 25_620.0 },
  { upperBound: 310_000, employeePension: 27_450.0 },
  { upperBound: 330_000, employeePension: 29_280.0 },
  { upperBound: 350_000, employeePension: 31_110.0 },
  { upperBound: 370_000, employeePension: 32_940.0 },
  { upperBound: 395_000, employeePension: 34_770.0 },
  { upperBound: 425_000, employeePension: 37_515.0 },
  { upperBound: 455_000, employeePension: 40_260.0 },
  { upperBound: 485_000, employeePension: 43_005.0 },
  { upperBound: 515_000, employeePension: 45_750.0 },
  { upperBound: 545_000, employeePension: 48_495.0 },
  { upperBound: 575_000, employeePension: 51_240.0 },
  { upperBound: 605_000, employeePension: 53_985.0 },
  { upperBound: 635_000, employeePension: 56_730.0 },
  { upperBound: Number.POSITIVE_INFINITY, employeePension: 59_475.0 },
] as const;

// -----------------------------
// Tax model
// -----------------------------
function salaryDeduction(s: number): number {
  if (s <= 1_625_000) return 550_000;
  if (s <= 1_800_000) return Math.floor(s * 0.4 - 100_000);
  if (s <= 3_600_000) return Math.floor(s * 0.3 + 80_000);
  if (s <= 6_600_000) return Math.floor(s * 0.2 + 440_000);
  if (s <= 8_500_000) return Math.floor(s * 0.1 + 1_100_000);
  return 1_950_000;
}

function incomeTaxRate(taxable: number): number {
  const rounded = Math.floor(taxable / 1000) * 1000;
  if (rounded <= 1_949_000) return 0.05;
  if (rounded <= 3_299_000) return 0.1;
  if (rounded <= 6_949_000) return 0.2;
  if (rounded <= 8_999_000) return 0.23;
  return 0.33;
}

function incomeTaxDeductionAmount(taxable: number): number {
  const rounded = Math.floor(taxable / 1000) * 1000;
  if (rounded <= 1_949_000) return 0;
  if (rounded <= 3_299_000) return 97_500;
  if (rounded <= 6_949_000) return 427_500;
  if (rounded <= 8_999_000) return 636_000;
  if (rounded <= 17_999_000) return 1_536_000;
  if (rounded <= 39_999_000) return 2_796_000;
  return 4_796_000;
}

function lookupByMonthlySalary<T extends { upperBound: number }>(
  monthlySalary: number,
  table: readonly T[],
): T {
  return table.find((row) => monthlySalary < row.upperBound) ?? table[table.length - 1];
}

function socialInsurance(s: number): number {
  const monthlySalary = s / MONTHS_PER_YEAR;
  const healthRow = lookupByMonthlySalary(monthlySalary, HEALTH_INSURANCE_TABLE);
  const pensionRow = lookupByMonthlySalary(monthlySalary, PENSION_TABLE);

  const monthlyHealthInsurance =
    healthRow.employeeHealth + healthRow.employeeSupport;
  const monthlyEmployeesPension = pensionRow.employeePension;
  const annualUnemploymentInsurance =
    s * ASSUMPTIONS.unemploymentInsuranceEmployeeRate;

  return Math.round(
    monthlyHealthInsurance * MONTHS_PER_YEAR +
      monthlyEmployeesPension * MONTHS_PER_YEAR +
      annualUnemploymentInsurance,
  );
}

function incomeTaxableIncome(s: number): number {
  return Math.max(
    0,
    s - salaryDeduction(s) - BASIC_DEDUCTION - socialInsurance(s),
  );
}

function residentTaxableIncome(s: number): number {
  return Math.max(
    0,
    s - salaryDeduction(s) - RESIDENT_BASIC_DEDUCTION - socialInsurance(s),
  );
}

function incomeTaxAmount(taxable: number): number {
  const rounded = Math.floor(taxable / 1000) * 1000;
  const baseTax = Math.max(
    0,
    Math.floor(rounded * incomeTaxRate(rounded) - incomeTaxDeductionAmount(rounded)),
  );
  return Math.floor(baseTax * (1 + RECONSTRUCTION_SURTAX_RATE));
}

function furusatoLimitSimple(s: number): number {
  const taxable = incomeTaxableIncome(s);
  const t = incomeTaxRate(taxable);
  const residentIncomeLevy = residentTaxableIncome(s) * 0.1;
  const denom = 0.9 - t;

  if (denom <= 0) return 0;
  return Math.floor(2000 + (0.2 * residentIncomeLevy) / denom);
}

function isFullyDeductible(s: number, donationYen: number): boolean {
  const deductionBase = donationYen - 2_000;
  if (deductionBase <= 0) return true;

  const incomeTaxBefore = incomeTaxAmount(incomeTaxableIncome(s));
  const incomeTaxAfter = incomeTaxAmount(
    Math.max(0, incomeTaxableIncome(s) - deductionBase),
  );
  const incomeTaxReduction = incomeTaxBefore - incomeTaxAfter;
  const residentBasicReduction = deductionBase * 0.1;
  const residentSpecialNeeded =
    deductionBase - incomeTaxReduction - residentBasicReduction;
  const residentSpecialCap = residentTaxableIncome(s) * 0.1 * 0.2;

  return residentSpecialNeeded <= residentSpecialCap;
}

function furusatoLimitTrue(s: number): number {
  let lo = 2_000;
  let hi = furusatoLimitSimple(s) + 200_000;
  while (isFullyDeductible(s, hi)) {
    hi *= 2;
  }

  while (hi - lo > 1) {
    const mid = Math.floor((lo + hi) / 2);
    if (isFullyDeductible(s, mid)) {
      lo = mid;
    } else {
      hi = mid;
    }
  }

  return lo;
}

// -----------------------------
// Data
// -----------------------------
type Point = {
  salaryYen: number;
  salaryMan: number;
  limitYen: number;
};

type Gain = {
  startMan: number;
  endMan: number;
  centerMan: number;
  deltaYen: number;
};

// -----------------------------
// Detect jumps
// -----------------------------
type Jump = {
  index: number;
  salaryBeforeYen: number;
  salaryAfterYen: number;
  limitBefore: number;
  limitAfter: number;
  delta: number;
};

function median(arr: number[]): number {
  const xs = [...arr].sort((a, b) => a - b);
  const n = xs.length;
  if (n % 2 === 1) return xs[(n - 1) / 2];
  return (xs[n / 2 - 1] + xs[n / 2]) / 2;
}

function fmtYen(n: number): string {
  return n.toLocaleString("ja-JP");
}

function fmtMan(nYen: number): string {
  return `${(nYen / 10_000).toFixed(1)}万円`;
}

function fmtSalaryMan(nYen: number): string {
  return `${Math.round(nYen / 10_000)}万`;
}

function fmtDonationMan(nYen: number): string {
  return `${(nYen / 10_000).toFixed(1)}万`;
}

function fmtAxisSalaryMan(nMan: number): string {
  return `${nMan}万`;
}

function fmtAxisDonationMan(nYen: number): string {
  if (nYen === 0) return "0";
  return `${(nYen / 10_000).toFixed(1)}万`;
}

function fmtDeltaManCompact(nYen: number): string {
  return `+${(nYen / 10_000).toFixed(1)}`;
}

function escapeXml(s: string): string {
  return s
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}

function makeJumpCallout(params: {
  x: number;
  y: number;
  boxX: number;
  boxY: number;
  title: string;
  line1: string;
  line2: string;
}) {
  const { x, y, boxX, boxY, title, line1, line2 } = params;

  const boxW = 320;
  const boxH = 112;
  const anchorX = x < boxX ? boxX : boxX + boxW;
  const anchorY = boxY + 56;

  return `
    <line x1="${x}" y1="${y}" x2="${anchorX}" y2="${anchorY}"
          stroke="#666" stroke-width="1.2" />

    <circle cx="${x}" cy="${y}" r="4.5" fill="#111" />

    <rect x="${boxX}" y="${boxY}" width="${boxW}" height="${boxH}"
          fill="#fffefa" stroke="#d8d4cc" stroke-width="1" rx="2" />

    <text x="${boxX + 14}" y="${boxY + 29}" class="anno strong">${escapeXml(title)}</text>
    <text x="${boxX + 14}" y="${boxY + 61}" class="anno">${escapeXml(line1)}</text>
    <text x="${boxX + 14}" y="${boxY + 91}" class="anno">${escapeXml(line2)}</text>
  `;
}

function makeSimpleCallout(params: {
  x: number;
  y: number;
  boxX: number;
  boxY: number;
  title: string;
}) {
  const { x, y, boxX, boxY, title } = params;
  const boxW = 280;
  const boxH = 58;
  const anchorX = x < boxX ? boxX : boxX + boxW;
  const anchorY = boxY + boxH / 2;

  return `
    <line x1="${x}" y1="${y}" x2="${anchorX}" y2="${anchorY}"
          stroke="#666" stroke-width="1.2" />

    <rect x="${boxX}" y="${boxY}" width="${boxW}" height="${boxH}"
          fill="#fffefa" stroke="#d8d4cc" stroke-width="1" rx="2" />

    <text x="${boxX + 14}" y="${boxY + 37}" class="anno strong">${escapeXml(title)}</text>
  `;
}

type AnnotationLayout = {
  x: number;
  y: number;
  boxX: number;
  boxY: number;
};

function overlaps(a: AnnotationLayout, b: AnnotationLayout): boolean {
  const boxW = 320;
  const boxH = 112;
  return !(
    a.boxX + boxW <= b.boxX ||
    b.boxX + boxW <= a.boxX ||
    a.boxY + boxH <= b.boxY ||
    b.boxY + boxH <= a.boxY
  );
}

function clamp(n: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, n));
}

const BOX_W = 320;
const BOX_H = 112;
const BOX_GAP_X = 30;
const BOX_GAP_Y = 20;
const CURVE_CLEARANCE = 18;

type ChartConfig = {
  xMin: number;
  xMax: number;
  output: string;
  title: string;
  subtitle: string;
  limitFn: (salaryYen: number) => number;
  showJumps: boolean;
  simpleCalloutSalaryMans?: number[];
};

type ChartData = {
  points: Point[];
  gains: Gain[];
  diffs: number[];
  jumps: Jump[];
  plotW: number;
  plotH: number;
  maxY: number;
  gainBandTop: number;
  gainBandH: number;
  gainBandBottom: number;
  gainMax: number;
  sx: (xMan: number) => number;
  sy: (y: number) => number;
};

function buildChart(config: ChartConfig): ChartData {
  const points: Point[] = [];
  for (let s = config.xMin * 10_000; s <= config.xMax * 10_000; s += STEP_YEN) {
    points.push({
      salaryYen: s,
      salaryMan: s / 10_000,
      limitYen: config.limitFn(s),
    });
  }

  const gains: Gain[] = [];
  for (let startMan = config.xMin; startMan < config.xMax; startMan += 50) {
    const startYen = startMan * 10_000;
    const endYen = (startMan + 50) * 10_000;
    gains.push({
      startMan,
      endMan: startMan + 50,
      centerMan: startMan + 25,
      deltaYen: config.limitFn(endYen) - config.limitFn(startYen),
    });
  }

  const diffs: number[] = [];
  for (let i = 1; i < points.length; i++) {
    diffs.push(points[i].limitYen - points[i - 1].limitYen);
  }

  const baseStep = median(diffs);
  const jumpThreshold = baseStep * 3;
  const jumps: Jump[] = [];
  for (let i = 1; i < points.length; i++) {
    const delta = points[i].limitYen - points[i - 1].limitYen;
    if (delta > jumpThreshold) {
      jumps.push({
        index: i,
        salaryBeforeYen: points[i - 1].salaryYen,
        salaryAfterYen: points[i].salaryYen,
        limitBefore: points[i - 1].limitYen,
        limitAfter: points[i].limitYen,
        delta,
      });
    }
  }

  const plotW = W - M.left - M.right;
  const plotH = H - M.top - M.bottom;
  const maxYRaw = Math.max(...points.map((p) => p.limitYen));
  const yTickStep = 25_000;
  const maxY = Math.ceil((maxYRaw * 1.08) / yTickStep) * yTickStep;
  const gainBandTop = M.top + plotH + 54;
  const gainBandH = 56;
  const gainBandBottom = gainBandTop + gainBandH;
  const gainMax = Math.max(...gains.map((g) => g.deltaYen));
  const sx = (xMan: number) =>
    M.left + ((xMan - config.xMin) / (config.xMax - config.xMin)) * plotW;
  const sy = (y: number) => M.top + plotH - (y / maxY) * plotH;

  return {
    points,
    gains,
    diffs,
    jumps,
    plotW,
    plotH,
    maxY,
    gainBandTop,
    gainBandH,
    gainBandBottom,
    gainMax,
    sx,
    sy,
  };
}

function intersectsCurve(layout: AnnotationLayout, points: Point[], sx: (xMan: number) => number, sy: (y: number) => number): boolean {
  const minX = layout.boxX - CURVE_CLEARANCE;
  const maxX = layout.boxX + BOX_W + CURVE_CLEARANCE;
  const minY = layout.boxY - CURVE_CLEARANCE;
  const maxY = layout.boxY + BOX_H + CURVE_CLEARANCE;

  return points.some((p) => {
    const px = sx(p.salaryMan);
    const py = sy(p.limitYen);
    return px >= minX && px <= maxX && py >= minY && py <= maxY;
  });
}

function layoutJumpAnnotations(
  jumps: Jump[],
  points: Point[],
  sx: (xMan: number) => number,
  sy: (y: number) => number,
  plotW: number,
  plotH: number,
): AnnotationLayout[] {
  const placed: AnnotationLayout[] = [];

  jumps.forEach((j, idx) => {
    const x = sx(j.salaryAfterYen / 10_000);
    const y = sy(j.limitAfter);

    if (idx === 0) {
      placed.push({
        x,
        y,
        boxX: clamp(x + BOX_GAP_X, M.left + 10, M.left + plotW - BOX_W - 10),
        boxY: clamp(y + BOX_GAP_Y, M.top + 10, M.top + plotH - BOX_H - 10),
      });
      return;
    }

    if (idx === 1) {
      placed.push({
        x,
        y,
        boxX: clamp(x - BOX_W - BOX_GAP_X, M.left + 10, M.left + plotW - BOX_W - 10),
        boxY: clamp(y - BOX_H - 36, M.top + 10, M.top + plotH - BOX_H - 10),
      });
      return;
    }

    const xCandidates =
      idx % 2 === 0
        ? [x + BOX_GAP_X, x + 80, x - BOX_W - BOX_GAP_X]
        : [x - BOX_W - BOX_GAP_X, x - BOX_W - 80, x + BOX_GAP_X];
    const yCandidates = [y - 96, y - 220, y + BOX_GAP_Y, y - 340, y + 140];

    const defaultLayout: AnnotationLayout = {
      x,
      y,
      boxX: clamp(xCandidates[0], M.left + 10, M.left + plotW - BOX_W - 10),
      boxY: clamp(yCandidates[0], M.top + 10, M.top + plotH - BOX_H - 10),
    };
    let chosenLayout = defaultLayout;
    let found = false;

    for (const candidateX of xCandidates) {
      for (const candidateY of yCandidates) {
        const nextLayout = {
          x,
          y,
          boxX: clamp(candidateX, M.left + 10, M.left + plotW - BOX_W - 10),
          boxY: clamp(candidateY, M.top + 10, M.top + plotH - BOX_H - 10),
        };
        if (placed.some((prev) => overlaps(prev, nextLayout))) continue;
        if (intersectsCurve(nextLayout, points, sx, sy)) continue;
        chosenLayout = nextLayout;
        found = true;
        break;
      }
      if (found) break;
    }

    placed.push(chosenLayout);
  });

  return placed;
}

function renderChart(config: ChartConfig) {
  const data = buildChart(config);
  const yTickStep = 25_000;
  const polyline = data.points
    .map((p) => `${data.sx(p.salaryMan)},${data.sy(p.limitYen)}`)
    .join(" ");
  const xTicks: string[] = [];
  for (let x = config.xMin; x <= config.xMax; x += 50) {
    xTicks.push(`
    <line x1="${data.sx(x)}" y1="${M.top + data.plotH}" x2="${data.sx(x)}" y2="${M.top + data.plotH + 6}"
          stroke="#444" stroke-width="1" />
    <text x="${data.sx(x)}" y="${M.top + data.plotH + 24}" text-anchor="middle" class="tick">${fmtAxisSalaryMan(x)}</text>
  `);
  }
  const yTicks: string[] = [];
  for (let y = 0; y <= data.maxY; y += yTickStep) {
    yTicks.push(`
    <line x1="${M.left - 6}" y1="${data.sy(y)}" x2="${M.left}" y2="${data.sy(y)}"
          stroke="#444" stroke-width="1" />
    <text x="${M.left - 12}" y="${data.sy(y) + 5}" text-anchor="end" class="tick">${fmtAxisDonationMan(y)}</text>
  `);
  }
  const gainBars = data.gains
    .map((g) => {
      const x = data.sx(g.centerMan);
      const topY =
        data.gainBandBottom - (g.deltaYen / data.gainMax) * (data.gainBandH - 18);
      return `
    <line x1="${x}" y1="${data.gainBandBottom}" x2="${x}" y2="${topY}"
          stroke="#9a9487" stroke-width="2" />
    <text x="${x}" y="${topY - 6}" text-anchor="middle" class="mini">${fmtDeltaManCompact(g.deltaYen)}</text>
    `;
    })
    .join("\n");
  const jumpAnnotations: string[] = [];
  if (config.showJumps) {
    const annotationLayouts = layoutJumpAnnotations(
      data.jumps,
      data.points,
      data.sx,
      data.sy,
      data.plotW,
      data.plotH,
    );
    data.jumps.forEach((j, idx) => {
      const { x, y, boxX, boxY } = annotationLayouts[idx];
      jumpAnnotations.push(
        makeJumpCallout({
          x,
          y,
          boxX,
          boxY,
          title: `${fmtSalaryMan(j.salaryAfterYen)}付近でジャンプ`,
          line1: `${fmtDonationMan(j.limitBefore)} → ${fmtDonationMan(j.limitAfter)}`,
          line2: `+${fmtDonationMan(j.delta)}`,
        }),
      );
    });
  }
  if (config.simpleCalloutSalaryMans?.length) {
    config.simpleCalloutSalaryMans.forEach((salaryMan, idx) => {
      const point = data.points.find((p) => p.salaryMan === salaryMan);
      if (!point) return;
      const x = data.sx(point.salaryMan);
      const y = data.sy(point.limitYen);
      const simpleBoxW = 280;
      const simpleBoxH = 58;
      const boxX =
        idx === 0
          ? clamp(x + BOX_GAP_X, M.left + 10, M.left + data.plotW - simpleBoxW - 10)
          : clamp(
              x - simpleBoxW - BOX_GAP_X,
              M.left + 10,
              M.left + data.plotW - simpleBoxW - 10,
            );
      const boxY =
        idx === 0
          ? clamp(y + BOX_GAP_Y, M.top + 10, M.top + data.plotH - simpleBoxH - 10)
          : clamp(y - simpleBoxH - 36, M.top + 10, M.top + data.plotH - simpleBoxH - 10);
      jumpAnnotations.push(
        makeSimpleCallout({
          x,
          y,
          boxX,
          boxY,
          title: `${salaryMan}万付近でジャンプ`,
        }),
      );
    });
  }
  return {
    svg: `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
  <style>
    .title {
      font-family: Georgia, "Times New Roman", serif;
      font-size: 30px;
      fill: #111;
    }
    .subtitle {
      font-family: Georgia, "Times New Roman", serif;
      font-size: 16px;
      fill: #555;
    }
    .axislabel {
      font-family: Georgia, "Times New Roman", serif;
      font-size: 18px;
      fill: #222;
    }
    .tick {
      font-family: Georgia, "Times New Roman", serif;
      font-size: 15px;
      fill: #444;
    }
    .anno {
      font-family: Georgia, "Times New Roman", serif;
      font-size: 16px;
      fill: #222;
    }
    .mini {
      font-family: Georgia, "Times New Roman", serif;
      font-size: 12px;
      fill: #7a7469;
    }
    .miniLabel {
      font-family: Georgia, "Times New Roman", serif;
      font-size: 13px;
      fill: #7a7469;
    }
    .strong {
      font-weight: 700;
    }
  </style>
  <rect width="100%" height="100%" fill="#fbfbf8" />

  <text x="${W / 2}" y="42" text-anchor="middle" class="title">
    ふるさと納税の上限額とジャンプ点
  </text>
  <text x="${W / 2}" y="68" text-anchor="middle" class="subtitle">
    ${escapeXml(config.subtitle)}
  </text>

  <line x1="${M.left}" y1="${M.top + data.plotH}" x2="${M.left + data.plotW}" y2="${M.top + data.plotH}" stroke="#222" stroke-width="1.2" />
  <line x1="${M.left}" y1="${M.top}" x2="${M.left}" y2="${M.top + data.plotH}" stroke="#222" stroke-width="1.2" />

  ${xTicks.join("\n")}
  ${yTicks.join("\n")}

  <polyline fill="none" stroke="#1f1f1f" stroke-width="2.4" points="${polyline}" />

  ${jumpAnnotations.join("\n")}

  <text x="24" y="${M.top - 22}" text-anchor="start" class="axislabel">上限寄附額</text>
  <text x="${M.left}" y="${data.gainBandTop - 12}" text-anchor="start" class="miniLabel">50万増で増える額</text>
  <line x1="${M.left}" y1="${data.gainBandBottom}" x2="${M.left + data.plotW}" y2="${data.gainBandBottom}"
        stroke="#c8c2b6" stroke-width="1" />
  ${gainBars}
</svg>
`,
    diffs: data.diffs,
    jumps: data.jumps,
  };
}

const charts: ChartConfig[] = [
  {
    xMin: DEFAULT_X_MIN,
    xMax: DEFAULT_X_MAX,
    output: "furusato_tufte.svg",
    title: "300-1000 simple",
    subtitle:
      "単身・扶養なし・iDeCoなし・会社員(協会けんぽ東京・40歳未満・賞与なし・簡便逆算式) / 1万円刻み",
    limitFn: furusatoLimitSimple,
    showJumps: true,
  },
  {
    xMin: 1000,
    xMax: 2000,
    output: "furusato_tufte_1000_2000.svg",
    title: "1000-2000 simple",
    subtitle:
      "単身・扶養なし・iDeCoなし・会社員(協会けんぽ東京・40歳未満・賞与なし・簡便逆算式) / 1万円刻み",
    limitFn: furusatoLimitSimple,
    showJumps: true,
  },
  {
    xMin: 0,
    xMax: 2000,
    output: "furusato_true_0_2000.svg",
    title: "0-2000 true",
    subtitle:
      "単身・扶養なし・iDeCoなし・会社員(協会けんぽ東京・40歳未満・賞与なし・控除式を数値探索) / 1万円刻み",
    limitFn: furusatoLimitTrue,
    showJumps: false,
  },
  {
    xMin: DEFAULT_X_MIN,
    xMax: DEFAULT_X_MAX,
    output: "furusato_true.svg",
    title: "300-1000 true",
    subtitle:
      "単身・扶養なし・iDeCoなし・会社員(協会けんぽ東京・40歳未満・賞与なし・控除式を数値探索) / 1万円刻み",
    limitFn: furusatoLimitTrue,
    showJumps: false,
    simpleCalloutSalaryMans: [440, 650],
  },
  {
    xMin: 1000,
    xMax: 2000,
    output: "furusato_true_1000_2000.svg",
    title: "1000-2000 true",
    subtitle:
      "単身・扶養なし・iDeCoなし・会社員(協会けんぽ東京・40歳未満・賞与なし・控除式を数値探索) / 1万円刻み",
    limitFn: furusatoLimitTrue,
    showJumps: false,
  },
];

for (const chart of charts) {
  const rendered = renderChart(chart);
  await Bun.write(chart.output, rendered.svg);
  const baseStep = median(rendered.diffs);
  const jumpThreshold = baseStep * 3;
  console.log(`[${chart.title}] base step (median): ${baseStep} yen`);
  console.log(`[${chart.title}] jump threshold: ${jumpThreshold} yen`);
  console.log(`[${chart.title}] detected jumps:`);
  for (const j of rendered.jumps) {
    console.log(
      `${fmtMan(j.salaryAfterYen)}: ${fmtYen(j.limitBefore)} -> ${fmtYen(j.limitAfter)} (delta ${fmtYen(j.delta)})`,
    );
  }
  console.log(`Generated: ${chart.output}`);
}