feat(web): simulatore di accumulo nel browser, verificato contro il Python

scripts/web/ — motore in JavaScript (engine.js) sui ritorni VERI del book
esportati da export_series.py, pagine assemblate da build.py. Da "sistema
dinamico dove imposti valore iniziale, mensile e durata, e calcola la best curva
fissando il tempo": la simulazione gira nel browser, quindi il motore va
riscritto e VA PROVATO che dia la stessa risposta.

test_engine.js confronta mediane e probabilita' con r0807_growth_yearly.py su due
configurazioni, esige che il versato (deterministico) coincida al centesimo, e
include un controllo positivo — un motore col fisco spento DEVE risultare fuori
tolleranza, perche' un test che non sa fallire non e' un test. Il risolutore e'
validato contro dep_necessario di r0727_tasse.py: -0.4 / -0.6 / -1.1%.

smoke.js ESEGUE le pagine con un DOM finto. Serve perche' node --check valida solo
la sintassi: ho pubblicato una pagina che lo passava e moriva alla prima riga
utile (chiavi Python "0.0" ricostruite in JS come String(0) = "0"; i pesi
intermedi funzionavano per caso).

Altri due errori che questi strumenti hanno intercettato:
- due anni di dati di un grafico scritti A MEMORIA perche' tail aveva troncato
  l'output -> ora i dati si INIETTANO da JSON (build.py), il passaggio manuale
  non esiste piu';
- una "distorsione sistematica" del motore JS (+0.75%, 8 semi tutti positivi) che
  erano 8 estrazioni contro UN punto Python rumoroso. Misurato bene, 8 semi per
  parte: -0.01%, t = -0.04; ed entrambi i campionatori cadono entro 1.7 SE
  dall'atteso ANALITICO della media di blocco.

Scelta guidata da una misura: la banda del versamento suggerito resta +-1% da
1.200 a 3.000 percorsi -> non domina il Monte Carlo ma la granularita' della
bisezione (~5 EUR). Percorsi tenuti bassi e incertezza dichiarata, invece di
pagare tempo per una precisione che non arriva.

Nessun impatto sulla produzione: non tocca book, pesi, cron o config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-07 18:44:13 +00:00
parent 56141b0ffe
commit ed2532b991
9 changed files with 2444 additions and 0 deletions
+531
View File
@@ -0,0 +1,531 @@
<title>Crescita del capitale: quanto è versamento e quanto è guadagno</title>
<style>
:root {
color-scheme: light;
--ground: #f7f7f5;
--surface: #fdfdfc;
--rule: #dededa;
--rule-soft: #ebebe7;
--ink: #16171a;
--ink-2: #55575c;
--ink-3: #83858b;
--versato: #2a78d6;
--guadagno: #eb6834;
--grid: #e6e6e2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ground: #17181a;
--surface: #1d1e21;
--rule: #34363a;
--rule-soft: #26282b;
--ink: #f1f1ee;
--ink-2: #a8aab0;
--ink-3: #7c7e85;
--versato: #3987e5;
--guadagno: #d95926;
--grid: #2b2d31;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--ground: #17181a;
--surface: #1d1e21;
--rule: #34363a;
--rule-soft: #26282b;
--ink: #f1f1ee;
--ink-2: #a8aab0;
--ink-3: #7c7e85;
--versato: #3987e5;
--guadagno: #d95926;
--grid: #2b2d31;
}
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--ground);
color: var(--ink);
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: 17px;
line-height: 1.62;
margin: 0;
padding: 0 24px 96px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 18px; }
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile, .switch {
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
header { padding-top: 64px; display: flex; flex-direction: column; gap: 20px; }
.eyebrow {
font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--ink-3); margin: 0;
}
h1 {
font-size: clamp(30px, 4.4vw, 46px); line-height: 1.12; margin: 0;
font-weight: 400; letter-spacing: -0.015em; text-wrap: balance; max-width: 20ch;
}
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
.lede b { color: var(--ink); font-weight: 700; }
.lede-meta {
font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
padding-top: 16px; border-top: 1px solid var(--rule);
}
.lede-meta b { color: var(--ink-2); font-weight: 400; }
h2 {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px; letter-spacing: 0.15em; text-transform: uppercase;
color: var(--ink-2); font-weight: 500; margin: 0 0 4px;
padding-bottom: 10px; border-bottom: 1px solid var(--rule);
}
p { margin: 0; }
strong { font-weight: 700; }
em { font-style: italic; color: var(--ink-2); }
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.figcap {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; color: var(--ink-3); line-height: 1.7; max-width: 78ch;
}
.chart-title {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--ink); margin: 0;
}
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
.chart-box {
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 18px 16px 10px; overflow-x: auto;
}
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
.legend i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; margin-right: 7px; vertical-align: -1px; }
.legend .band { width: 2px; height: 13px; border-radius: 0; background: var(--ink-3); margin-right: 7px; }
.stack { display: flex; flex-direction: column; gap: 30px; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px; background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.tile { background: var(--surface); padding: 18px 18px 20px; display: flex; flex-direction: column; gap: 6px; }
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
.tile .v { font-size: 27px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.1; }
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.6; }
.tile .v small { font-size: 15px; color: var(--ink-3); letter-spacing: 0; }
details { border-top: 1px solid var(--rule); padding-top: 14px; }
summary {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink-2); cursor: pointer; padding: 4px 0;
}
summary:focus-visible { outline: 2px solid var(--versato); outline-offset: 3px; }
.tablewrap { overflow-x: auto; margin-top: 16px; }
table { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 660px; }
th, td { padding: 6px 12px 6px 0; text-align: right; white-space: nowrap; }
th:first-child, td:first-child { text-align: left; padding-left: 0; }
thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
tbody tr td:first-child { color: var(--ink); }
tbody tr.mark td { background: color-mix(in srgb, var(--versato) 7%, transparent); }
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 10px; }
/* confronto fra piani */
.cmp { border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.cmp table { min-width: 520px; }
.cmp thead th { padding: 12px 14px 10px; background: var(--surface); }
.cmp td { padding: 9px 14px; }
.cmp tbody tr:last-child td { border-bottom: 0; }
.cmp .hi { color: var(--ink); }
#tip {
position: fixed; z-index: 40; pointer-events: none; opacity: 0;
transition: opacity .1s ease;
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 10px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px; line-height: 1.75; color: var(--ink-2);
box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums;
max-width: 260px;
}
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
#tip .r b { font-weight: 400; color: var(--ink); }
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
.bargroup { cursor: default; }
.bargroup:focus-visible { outline: none; }
.bargroup:focus-visible .hit { stroke: var(--ink); stroke-width: 1.5; }
.caveat {
border-left: 2px solid var(--guadagno); padding: 2px 0 2px 18px;
color: var(--ink-2); font-size: 16px; max-width: 64ch;
}
.caveat b { color: var(--ink); font-weight: 700; }
footer {
border-top: 1px solid var(--rule); padding-top: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px; color: var(--ink-3); line-height: 1.85; max-width: 78ch;
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<p class="eyebrow">PythagorasGoal · piano di accumulo · 2026-08-07</p>
<h1>Quanto è versamento e quanto è guadagno</h1>
<p class="lede">
Il piano con <b>€800 al mese</b> — più i €5.000 iniziali sul conto Deribit — anno per anno,
con le due componenti del capitale separate: i bonifici, che sono decisi, e il rendimento del
book, che è estratto.
</p>
<p class="lede-meta">
book live <b>TP01 + SKH01 75/25</b> alle ancore canoniche, de-luckato <b>×0.89</b> ·
block bootstrap <b>4.000 path</b>, blocchi da 20 giorni ·
bersaglio <b>$272.061</b> (capitale-rendita per €50/giorno netti) ·
<b>scripts/research/r0807_growth_yearly.py --dep 800</b>
</p>
</header>
<section class="prose">
<h2>La forma della curva</h2>
<p>
Per i primi sette anni il capitale <em>è</em> quasi solo la somma dei bonifici: il book
aggiunge poco perché ha poco su cui lavorare. Il sorpasso — l'anno in cui il guadagno
cumulato supera tutto il denaro versato — arriva all'<strong id="x-cross-l"></strong> anno
nel modello lordo e all'<strong id="x-cross-n">11º</strong> col fisco dentro. Da lì la curva
cambia natura: smette di essere un piano di risparmio e diventa un capitale che lavora.
</p>
<p>
Versare di più <em>non</em> anticipa il sorpasso — lo ritarda, perché alza l'asticella che il
guadagno deve superare. A €500/mese cadeva al 7º anno; qui all'8º. Quello che i €300 in più
comprano è il <strong>traguardo</strong>, non il momento in cui la strategia prende il
sopravvento: la mediana tocca il bersaglio al <strong id="x-hit-n">12º</strong> anno col
fisco, contro il 15º a €500.
</p>
</section>
<div class="tiles" id="tiles"></div>
<section class="stack">
<figure>
<p class="chart-title">Modello lordo <span>— nessuna imposta durante l'accumulo</span></p>
<div class="legend" aria-hidden="true">
<span><i style="background:var(--versato)"></i>versato (deterministico)</span>
<span><i style="background:var(--guadagno)"></i>guadagno del percorso mediano</span>
<span><i class="band"></i>banda p25p75 del capitale</span>
</div>
<div class="chart-box"><svg id="c-lordo" role="img" aria-label="Capitale per anno con 800 euro al mese, modello lordo, scomposto in versamenti e guadagno"></svg></div>
</figure>
<figure>
<p class="chart-title">Con il fisco d'accumulo <span>— plusvalenze 33% annue + patrimoniale 0,2%</span></p>
<div class="chart-box"><svg id="c-netto" role="img" aria-label="Capitale per anno con 800 euro al mese e imposte, scomposto in versamenti e guadagno"></svg></div>
<figcaption class="figcap">
Stessa scala verticale nei due grafici: la differenza di altezza <em>è</em> il costo del fisco.
La banda p25p75 dice quanto il percorso può spostarsi; il guadagno disegnato è quello del
percorso mediano, non di un percorso singolo.
</figcaption>
</figure>
</section>
<section class="prose">
<h2>Cosa comprano i €300 in più</h2>
<p>
Il confronto è a parità di tutto il resto — stesso book, stessi path, stesso lump da €5.000 —
e va letto sulla riga della <em>probabilità</em>, non su quella del capitale: la mediana dice
dove finisce metà dei percorsi, la probabilità dice quanti ci arrivano.
</p>
</section>
<div class="cmp"><div class="tablewrap"><table id="cmp"></table></div></div>
<section class="prose">
<h2>Il vincolo dei dieci anni</h2>
<p class="caveat">
Anche €800 al mese <b>non bastano</b> per il traguardo in dieci anni: la probabilità col fisco
dentro è <b id="x-p10">25,5%</b>. Serve circa <b>€8801.050 al mese</b> — e a quel punto si
sono versati oltre $119.000 per arrivare a $272.061, cioè <em>a orizzonte corto non fai
lavorare la strategia, compri il capitale coi bonifici</em>.
</p>
<p>
L'imposta durante l'accumulo <strong>non era mai stata contata</strong> in nessuna traiettoria
del progetto: l'aliquota compariva in un solo punto, la lordizzazione del bersaglio in fase di
<em>prelievo</em>. L'errore è composto — <strong id="x-tax5">15%</strong> di capitale a 5 anni,
<strong id="x-tax10">29%</strong> a 10, <strong id="x-tax15">42%</strong> a 15 — quindi
cresce proprio sull'orizzonte su cui si sta decidendo. Le assunzioni (33% sulle plusvalenze,
minusvalenze riportabili 4 anni, 0,2% annuo sul valore) sono dichiarate e <em>non sono un
parere fiscale</em>.
</p>
</section>
<details>
<summary>Tabella completa — i due regimi, anno per anno, a €800/mese</summary>
<div class="tablewrap">
<table id="tbl">
<caption>Valori in dollari. «capitale» è la mediana fra i 4.000 percorsi; «P(bersaglio)» è la quota di percorsi che ha toccato $272.061 in qualunque momento entro quell'anno.</caption>
</table>
</div>
</details>
<footer>
Il «guadagno» di ogni riga è <b>mediana(capitale) versato</b>: il rendimento del percorso
mediano, non di un percorso singolo — le due componenti non sono indipendenti e non vanno
sommate come se lo fossero. I versamenti entrano ogni 30 giorni. Nessun rischio di venue in
questo modello: a p=1% annuo di fallimento dell'exchange la probabilità di arrivare scende di
~2 punti e quella di <b>perdere tutto</b> sale al 18%, e a p=5% il capitale mediano a 20 anni è
zero per qualunque calendario di versamenti. E un piano si giudica sulle sue deviazioni: qui i
versamenti non si fermano mai, che è l'unico scenario che non succede.
</footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>
<script>
(function () {
"use strict";
/* riga = [anno, versato, guadagno_med, p25, p75, P(bersaglio)] */
const D800 = {
"lordo":[[1,16514,1769,17245,19578,0.0],[2,26978,5761,30155,35973,0.0],[3,37442,12653,45263,55806,0.0],[4,47906,22674,62395,80542,0.0],[5,58370,36254,82632,109462,0.0],[6,68834,54149,105982,145050,0.001],[7,80170,77582,134771,188140,0.027],[8,90634,107277,166043,240255,0.153],[9,101098,145547,201137,302141,0.406],[10,111562,190624,245158,375704,0.663],[11,122026,246454,293656,463843,0.844],[12,132490,314762,352325,569174,0.942],[13,143826,397855,420145,697480,0.981],[14,154290,497056,504972,849886,0.995],[15,164754,611612,598478,1027590,0.998]],
"netto":[[1,16514,1150,16970,18530,0.0],[2,26978,3704,28990,32772,0.0],[3,37442,8016,42367,49006,0.0],[4,47906,14081,56895,67961,0.0],[5,58370,21953,73255,88990,0.0],[6,68834,32239,91315,113319,0.0],[7,80170,45114,112318,141436,0.001],[8,90634,60851,134505,172871,0.014],[9,101098,80839,158029,208664,0.072],[10,111562,102940,185847,248679,0.255],[11,122026,129351,216191,293736,0.508],[12,132490,160661,249046,345007,0.736],[13,143826,196548,286976,403693,0.884],[14,154290,238407,331309,470099,0.961],[15,164754,285353,377454,544074,0.99]]};
/* il piano precedente, per il confronto */
const D500 = {
"lordo":[[1,12590,1480,13204,15143,0.0],[2,19130,4439,21604,26058,0.0],[3,25670,9315,31459,39175,0.0],[4,32210,16336,42734,55555,0.0],[5,38750,25573,55941,74606,0.0],[6,45290,37658,71107,98301,0.0],[7,52375,53218,90027,126660,0.001],[8,58915,73347,110476,161177,0.012],[9,65455,98449,133388,202044,0.059],[10,71995,128781,162249,250780,0.2],[11,78535,166083,194409,309087,0.413],[12,85075,210811,232546,378373,0.625],[13,92160,266535,276493,463805,0.787],[14,98700,331836,332045,563596,0.901],[15,105240,406396,392375,682638,0.958]],
"netto":[[1,12590,964,12975,14272,0.0],[2,19130,2854,20694,23582,0.0],[3,25670,5911,29311,34169,0.0],[4,32210,10094,38695,46478,0.0],[5,38750,15457,49239,60153,0.0],[6,45290,22328,60828,76054,0.0],[7,52375,30864,74451,94333,0.0],[8,58915,41442,88742,114692,0.0],[9,65455,54390,103868,138004,0.001],[10,71995,68996,121916,164188,0.012],[11,78535,86387,141436,193292,0.051],[12,85075,107166,162790,226868,0.154],[13,92160,130846,187634,264641,0.334],[14,98700,158081,216211,308116,0.537],[15,105240,188583,245868,356333,0.732]]};
const TARGET = 272061;
const I = { ANNO:0, VERS:1, GUAD:2, P25:3, P75:4, PHIT:5 };
const D = D800;
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
const usdK = n => n === 0 ? "$0" : "$" + Math.round(n / 1000) + "k";
const pct = n => (100 * n).toFixed(1).replace(".", ",") + "%";
const ORD = ["", "1º","2º","3º","4º","5º","6º","7º","8º","9º","10º","11º","12º","13º","14º","15º"];
const at = (set, key, y) => set[key].find(r => r[I.ANNO] === y);
const cap = r => r[I.VERS] + r[I.GUAD];
/* primo anno in cui il guadagno cumulato supera tutto il versato */
const crossover = key => (D[key].find(r => r[I.GUAD] > r[I.VERS]) || {})[I.ANNO];
/* primo anno in cui il capitale MEDIANO tocca il bersaglio */
const hitYear = key => (D[key].find(r => cap(r) >= TARGET) || {})[I.ANNO];
/* ---------------- testo guidato dai dati ---------------- */
const put = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
put("x-cross-l", ORD[crossover("lordo")]);
put("x-cross-n", ORD[crossover("netto")]);
put("x-hit-n", ORD[hitYear("netto")]);
put("x-p10", pct(at(D, "netto", 10)[I.PHIT]));
[5, 10, 15].forEach(y => {
const l = cap(at(D, "lordo", y)), n = cap(at(D, "netto", y));
put("x-tax" + y, "" + Math.round(100 * (1 - n / l)) + "%");
});
/* ---------------- tiles ---------------- */
const tiles = [5, 10, 15].map(y => {
const n = at(D, "netto", y), l = at(D, "lordo", y);
return { k: "a " + y + " anni", v: usd(cap(n)),
s: "versati " + usd(n[I.VERS]) + " · il guadagno è il " +
Math.round(100 * n[I.GUAD] / cap(n)) + "% del capitale · lordo sarebbe " + usd(cap(l)) };
}).concat([{ k: "sorpasso", v: crossover("lordo") + "º<small> / " + crossover("netto") + "º anno</small>",
s: "l'anno in cui il guadagno cumulato supera tutto il versato — lordo / col fisco" }]);
document.getElementById("tiles").innerHTML = tiles.map(t =>
'<div class="tile"><span class="k">' + t.k + '</span><span class="v">' + t.v +
'</span><span class="s">' + t.s + "</span></div>").join("");
/* ---------------- confronto 500 vs 800 ---------------- */
const cmpRows = [
["versato a 10 anni", s => usd(at(s, "netto", 10)[I.VERS])],
["capitale a 10 anni", s => usd(cap(at(s, "netto", 10)))],
["P(bersaglio) a 10 anni", s => pct(at(s, "netto", 10)[I.PHIT]), true],
["versato a 15 anni", s => usd(at(s, "netto", 15)[I.VERS])],
["capitale a 15 anni", s => usd(cap(at(s, "netto", 15)))],
["P(bersaglio) a 15 anni", s => pct(at(s, "netto", 15)[I.PHIT]), true],
["anno del traguardo (mediana)", s => {
const r = s.netto.find(x => x[I.VERS] + x[I.GUAD] >= TARGET);
return r ? ORD[r[I.ANNO]] + " anno" : "oltre il 15º"; }, true]
];
document.getElementById("cmp").innerHTML =
"<caption>Tutti i valori sono <b>col fisco d'accumulo</b>, il regime realistico. Stesso lump da €5.000, stessi 4.000 percorsi.</caption>" +
"<thead><tr><th></th><th>€500/mese</th><th>€800/mese</th></tr></thead><tbody>" +
cmpRows.map(([k, f, hi]) =>
"<tr><td>" + k + "</td><td" + (hi ? ' class="hi"' : "") + ">" + f(D500) +
"</td><td" + (hi ? ' class="hi"' : "") + ">" + f(D800) + "</td></tr>").join("") +
"</tbody>";
/* ---------------- tooltip ---------------- */
const tip = document.getElementById("tip");
function showTip(evt, serie, row) {
tip.innerHTML =
'<span class="t">anno ' + row[I.ANNO] + " · " + serie + "</span>" +
'<div class="r"><span><span class="sw" style="background:var(--versato)"></span>versato</span><b>' + usd(row[I.VERS]) + "</b></div>" +
'<div class="r"><span><span class="sw" style="background:var(--guadagno)"></span>guadagno</span><b>' + usd(row[I.GUAD]) + "</b></div>" +
'<div class="r"><span>capitale mediano</span><b>' + usd(cap(row)) + "</b></div>" +
'<div class="r"><span>banda p25p75</span><b>' + usdK(row[I.P25]) + " " + usdK(row[I.P75]) + "</b></div>" +
'<div class="r"><span>P(bersaglio)</span><b>' + pct(row[I.PHIT]) + "</b></div>";
tip.style.opacity = "1";
moveTip(evt);
}
function moveTip(evt) {
const r = tip.getBoundingClientRect();
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
if (y < 8) y = evt.clientY + 18;
tip.style.left = x + "px";
tip.style.top = y + "px";
}
const hideTip = () => { tip.style.opacity = "0"; };
/* ---------------- scala verticale, dai dati ---------------- */
const rawMax = Math.max(...["lordo", "netto"].flatMap(k => D[k].map(r => r[I.P75])));
const STEP = [25000, 50000, 100000, 150000, 200000, 250000, 500000]
.find(s => rawMax / s <= 8) || 1000000;
const YMAX = Math.ceil(rawMax / STEP) * STEP;
/* ---------------- chart ---------------- */
const NS = "http://www.w3.org/2000/svg";
const el = (n, a) => { const e = document.createElementNS(NS, n);
for (const k in a) e.setAttribute(k, a[k]); return e; };
function roundedTop(x, yTop, w, h, r) {
if (h <= 0) return "";
r = Math.min(r, h, w / 2);
return "M" + x + "," + (yTop + h) + "V" + (yTop + r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + (-r) +
"h" + (w - 2 * r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + r +
"V" + (yTop + h) + "Z";
}
function draw(svgId, key, label) {
const rows = D[key];
const W = 960, H = 360, M = { t: 26, r: 108, b: 38, l: 74 };
const pw = W - M.l - M.r, ph = H - M.t - M.b;
const svg = document.getElementById(svgId);
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.innerHTML = "";
const y = v => M.t + ph - (v / YMAX) * ph;
const step = pw / rows.length;
const bw = Math.min(38, step * 0.62);
for (let v = 0; v <= YMAX; v += STEP) {
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end",
class: "axis", fill: "var(--ink-3)", "font-size": 11 });
t.textContent = usdK(v);
svg.appendChild(t);
}
const ty = y(TARGET);
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: ty, y2: ty,
stroke: "var(--ink-2)", "stroke-width": 1.5, "stroke-dasharray": "5 4" }));
const tl = el("text", { x: M.l + 8, y: ty - 8, class: "axis",
fill: "var(--ink-2)", "font-size": 11 });
tl.textContent = "bersaglio $272.061 — capitale-rendita";
svg.appendChild(tl);
rows.forEach((row, i) => {
const cx = M.l + step * i + step / 2;
const x = cx - bw / 2;
const vTop = y(row[I.VERS]), gTop = y(cap(row)), base = y(0);
const g = el("g", { class: "bargroup", tabindex: "0",
"aria-label": "anno " + row[I.ANNO] + ": versato " + usd(row[I.VERS]) +
", guadagno " + usd(row[I.GUAD]) });
g.appendChild(el("path", { d: roundedTop(x, vTop, bw, base - vTop, 0), fill: "var(--versato)" }));
/* distanziatore da 2px solo dove il segmento e' visibile: nei primi anni il guadagno
vale pochi pixel e il distanziatore lo cancellerebbe */
const rawH = vTop - gTop;
const gh = rawH - (rawH > 6 ? 2 : 0);
if (gh > 0.3) g.appendChild(el("path", { d: roundedTop(x, gTop, bw, gh, 4), fill: "var(--guadagno)" }));
const p25 = y(row[I.P25]), p75 = y(row[I.P75]);
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25,
stroke: "var(--surface)", "stroke-width": 4 }));
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25,
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 }));
[p25, p75].forEach(yy => g.appendChild(el("line", { x1: cx - 5, x2: cx + 5, y1: yy, y2: yy,
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 })));
const hit = el("rect", { class: "hit", x: M.l + step * i, y: M.t,
width: step, height: ph, fill: "transparent" });
g.appendChild(hit);
g.addEventListener("pointerenter", e => showTip(e, label, row));
g.addEventListener("pointermove", moveTip);
g.addEventListener("pointerleave", hideTip);
g.addEventListener("focus", () => {
const b = hit.getBoundingClientRect();
showTip({ clientX: b.left + b.width / 2, clientY: b.top + 40 }, label, row);
});
g.addEventListener("blur", hideTip);
svg.appendChild(g);
const xt = el("text", { x: cx, y: H - 14, "text-anchor": "middle", class: "axis",
fill: row[I.ANNO] === crossover(key) ? "var(--ink)" : "var(--ink-3)", "font-size": 11 });
xt.textContent = row[I.ANNO];
svg.appendChild(xt);
});
/* etichette dirette delle SERIE a destra dell'ultima barra: l'identita' non e' mai solo
colore, ed e' il relief richiesto dal contrasto dell'arancio in chiaro */
const last = rows[rows.length - 1];
const lastTop = y(cap(last)), lastMid = y(last[I.VERS]), lx = M.l + pw + 14;
[["guadagno", usd(last[I.GUAD]), (lastTop + lastMid) / 2, "var(--guadagno)"],
["versato", usd(last[I.VERS]), (lastMid + y(0)) / 2, "var(--versato)"]
].forEach(([nome, val, yy, col]) => {
const a = el("text", { x: lx, y: yy - 2, class: "axis", fill: col, "font-size": 11 });
a.textContent = nome;
svg.appendChild(a);
const b = el("text", { x: lx, y: yy + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
b.textContent = val;
svg.appendChild(b);
});
/* etichetta diretta: il sorpasso */
const ci = crossover(key) - 1, cRow = rows[ci];
const ccx = M.l + step * ci + step / 2, cy = y(cap(cRow));
svg.appendChild(el("line", { x1: ccx, x2: ccx, y1: cy - 6, y2: cy - 26,
stroke: "var(--guadagno)", "stroke-width": 1.5 }));
const ct = el("text", { x: ccx, y: cy - 32, "text-anchor": "middle", class: "axis",
fill: "var(--guadagno)", "font-size": 11 });
ct.textContent = "sorpasso";
svg.appendChild(ct);
const ax = el("text", { x: M.l + pw + 14, y: H - 14, class: "axis",
fill: "var(--ink-3)", "font-size": 10.5 });
ax.textContent = "anno";
svg.appendChild(ax);
}
draw("c-lordo", "lordo", "lordo");
draw("c-netto", "netto", "col fisco");
/* ---------------- tabella ---------------- */
const tbl = document.getElementById("tbl");
const head = "<thead><tr><th>anno</th><th>versato</th>" +
"<th>guad. lordo</th><th>capitale lordo</th><th>P(bers.)</th>" +
"<th>guad. netto</th><th>capitale netto</th><th>P(bers.)</th><th>costo fisco</th></tr></thead>";
const body = D.lordo.map((l, i) => {
const n = D.netto[i], cl = cap(l), cn = cap(n);
const mark = [5, 10, 15].includes(l[I.ANNO]) ? ' class="mark"' : "";
return "<tr" + mark + "><td>" + l[I.ANNO] + "</td><td>" + usd(l[I.VERS]) + "</td><td>" +
usd(l[I.GUAD]) + "</td><td>" + usd(cl) + "</td><td>" + pct(l[I.PHIT]) + "</td><td>" +
usd(n[I.GUAD]) + "</td><td>" + usd(cn) + "</td><td>" + pct(n[I.PHIT]) + "</td><td>" +
(100 * (cn / cl - 1)).toFixed(1).replace(".", ",") + "%</td></tr>";
}).join("");
tbl.insertAdjacentHTML("beforeend", head + "<tbody>" + body + "</tbody>");
})();
</script>