ふるさと納税で危険な年収帯
ふるさと納税の限度額の目安を、公式に基づいて算出すると次のようになる。
上記の図の通り年収440万前後および650万円前後でジャンプが発生している。 これは主に所得税率の境目で起きる。
傾きが急になる理由
ふるさと納税の控除は、ざっくり次の3つに分かれる。
- 所得税控除
- 住民税基本分
- 住民税特例分
このうち、上限額を実質的に決めているのは住民税特例分である。
住民税特例分には住民税所得割額の20%という上限があるため、この枠をどこまで使い切れるかで、ふるさと納税の限度額が決まる。
追加で寄附した金額は、まず所得税控除と住民税基本分に配分され、残りを住民税特例分が受け持つ。
ここで、所得税率が切り替わって高くなると、追加の寄附額に対して 所得税控除 の割合がそれまでより多くなる。すると、そのぶん 住民税特例分 が受け持つ額はそれまでより小さくなる。
つまり、所得税率が高くなったあとは、同じ1万円を追加で寄附しても 住民税特例分の枠 の減り方がそれまでより遅くなる。
その結果、住民税特例分の上限に達するまでに許される寄附額が増え、グラフの傾きが急になる。税率差が大きい方が急になりやすい。
逆に、税率帯が変わらない区間では、追加寄附1万円あたりで 住民税特例分 をどれだけ使うかもほぼ一定なので、グラフの傾きもほぼ一定になる。
細かいギザギザ
細かいギザギザは社会保険の等級表を表している。曲線は階段的になり、局所的な戻りや細かい折れは等級切替の影響が強い。
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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
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}`);
}