Files
Adriano Dal Pastro ed2532b991 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>
2026-08-07 18:44:13 +00:00

532 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>