[Asm] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>复利实验室 · 利滚利与目标里程碑</title>
<style>
:root {
--bg: #f3efe6;
--paper: #fffdf8;
--paper-2: #faf6ec;
--ink: #18231d;
--ink-2: #3d4a42;
--muted: #6f7972;
--faint: #8c948d;
--line: #ded8c9;
--line-strong: #c9c1ae;
--green: #0f5c47;
--green-2: #1f7a5e;
--green-soft: #dceae2;
--green-wash: #f0f6f2;
--terra: #bd583b;
--terra-2: #d97757;
--terra-soft: #f6e3da;
--gold: #b28733;
--gold-soft: #f2e7c9;
--dark: #12241d;
--cream: #f7f1e3;
--shadow-sm: 0 4px 16px rgba(31, 42, 35, .05);
--shadow: 0 18px 52px rgba(31, 42, 35, .08);
--radius: 22px;
--font: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
--num: "Bahnschrift", "DIN Alternate", "Segoe UI", "Arial", sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
background:
radial-gradient(1100px 560px at 8% -8%, rgba(15, 92, 71, .09), transparent 62%),
radial-gradient(900px 520px at 102% 2%, rgba(189, 88, 59, .075), transparent 58%),
var(--bg);
font-family: var(--font);
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(rgba(24, 35, 29, .032) 1px, transparent 1px),
linear-gradient(90deg, rgba(24, 35, 29, .032) 1px, transparent 1px);
background-size: 48px 48px;
-webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
}
::selection { background: var(--green); color: var(--cream); }
button, input { font: inherit; }
button { cursor: pointer; }
button, input, summary { -webkit-tap-highlight-color: transparent; }
:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; border-radius: 5px; }
.page {
width: min(1360px, calc(100% - 56px));
margin: 0 auto;
padding: 22px 0 46px;
}
.num {
font-family: var(--num);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
letter-spacing: -.02em;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 13px;
background: var(--dark);
color: var(--cream);
font-size: 21px;
font-weight: 700;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.12);
}
.brand-copy { display: flex; flex-direction: column; line-height: 1.2; }
.brand-copy strong { font-size: 15px; letter-spacing: .02em; }
.brand-copy span {
margin-top: 3px;
color: var(--muted);
font-size: 9.5px;
letter-spacing: .18em;
font-family: var(--num);
}
.top-note {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 12px;
letter-spacing: .03em;
}
.top-note i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green-2);
box-shadow: 0 0 0 4px rgba(31, 122, 94, .1);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.34fr) minmax(350px, .66fr);
gap: 44px;
align-items: stretch;
padding: 42px 0 38px;
}
.hero-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
width: fit-content;
color: var(--green);
font-size: 11px;
font-weight: 700;
letter-spacing: .16em;
}
.eyebrow-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 0 4px rgba(178, 135, 51, .12);
}
.hero h1 {
margin: 18px 0 20px;
font-size: clamp(44px, 5.25vw, 76px);
line-height: 1.04;
letter-spacing: -.045em;
font-weight: 800;
}
.hero h1 em {
position: relative;
z-index: 0;
color: var(--green);
font-style: normal;
white-space: nowrap;
}
.hero h1 em::after {
content: "";
position: absolute;
left: 1%;
right: -1%;
bottom: 4%;
height: .15em;
border-radius: 99px;
background: var(--gold);
opacity: .36;
z-index: -1;
transform: rotate(-.8deg);
}
.hero-lead {
max-width: 650px;
margin: 0;
color: var(--ink-2);
font-size: 15.5px;
line-height: 1.85;
}
.hero-points {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
max-width: 620px;
margin-top: 28px;
}
.hero-points > div {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 8px;
row-gap: 1px;
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 253, 248, .56);
}
.hero-points span {
grid-row: 1 / 3;
align-self: center;
color: var(--gold);
font-family: var(--num);
font-size: 11px;
font-weight: 700;
}
.hero-points b { font-size: 12.5px; line-height: 1.3; }
.hero-points small { color: var(--muted); font-size: 10.5px; line-height: 1.3; }
.hero-foot {
margin: 20px 0 0;
color: var(--muted);
font-size: 11.5px;
letter-spacing: .03em;
}
.exposure-card {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 390px;
padding: 28px 28px 24px;
border-radius: 29px;
background:
radial-gradient(540px 280px at 100% 0%, rgba(95, 156, 126, .23), transparent 64%),
linear-gradient(148deg, #17372b 0%, #10251d 54%, #0b1a15 100%);
color: var(--cream);
box-shadow: 0 28px 70px rgba(15, 36, 28, .22);
}
.exposure-card::before,
.exposure-card::after {
content: "";
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.exposure-card::before {
width: 310px;
height: 310px;
right: -132px;
top: -138px;
border: 1px solid rgba(247, 241, 227, .12);
}
.exposure-card::after {
width: 210px;
height: 210px;
right: -82px;
top: -88px;
border: 1px solid rgba(247, 241, 227, .08);
}
.exposure-top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(247, 241, 227, .7);
font-size: 12px;
letter-spacing: .06em;
}
.risk-badge {
padding: 4px 9px;
border: 1px solid rgba(247, 241, 227, .18);
border-radius: 999px;
color: var(--cream);
font-size: 10.5px;
letter-spacing: .04em;
background: rgba(255, 255, 255, .06);
}
.risk-badge[data-level="mid"] { background: rgba(178, 135, 51, .18); border-color: rgba(226, 192, 113, .36); }
.risk-badge[data-level="high"] { background: rgba(189, 88, 59, .2); border-color: rgba(217, 119, 87, .4); }
.risk-badge[data-level="custom"] { background: rgba(178, 135, 51, .16); border-color: rgba(226, 192, 113, .34); color: var(--gold-soft); }
.exposure-value {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
gap: 5px;
margin-top: 6px;
line-height: 1;
}
.exposure-value > span:first-child {
font-family: var(--num);
font-variant-numeric: tabular-nums;
font-size: clamp(62px, 5.8vw, 82px);
font-weight: 600;
letter-spacing: -.055em;
}
.pct-sign {
margin-top: 9px;
color: var(--gold-soft);
font-family: var(--num);
font-size: 25px;
font-weight: 500;
}
.exposure-caption {
position: relative;
z-index: 1;
min-height: 44px;
margin: 8px 0 17px;
color: rgba(247, 241, 227, .78);
font-size: 12.5px;
line-height: 1.65;
}
.gauge { position: relative; z-index: 1; margin-bottom: 14px; }
.gauge-track {
position: relative;
height: 7px;
border-radius: 99px;
background: linear-gradient(90deg, #2c8063 0%, #c6a04b 55%, #c65f42 100%);
opacity: .92;
}
.gauge-track::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(10, 24, 19, .3);
clip-path: inset(0 0 0 var(--risk-pos, 11%));
}
.gauge-marker {
position: absolute;
top: 50%;
left: var(--risk-pos, 11%);
width: 15px;
height: 15px;
border: 3px solid var(--cream);
border-radius: 50%;
background: var(--dark);
transform: translate(-50%, -50%);
box-shadow: 0 0 0 4px rgba(247, 241, 227, .1);
}
.gauge-labels {
display: flex;
justify-content: space-between;
margin-top: 7px;
color: rgba(247, 241, 227, .42);
font-family: var(--num);
font-size: 8.5px;
letter-spacing: .03em;
}
.formula-box {
position: relative;
z-index: 1;
padding: 10px 12px;
border: 1px solid rgba(247, 241, 227, .12);
border-radius: 10px;
background: rgba(255, 255, 255, .055);
color: rgba(247, 241, 227, .8);
font-size: 11px;
letter-spacing: .01em;
}
.formula-box b { color: var(--cream); font-weight: 650; }
.fraction-badge {
position: relative;
z-index: 1;
margin-top: 9px;
color: rgba(247, 241, 227, .64);
font-size: 11.5px;
}
.fraction-badge b {
display: inline-block;
margin-left: 2px;
color: var(--gold-soft);
font-family: var(--num);
font-size: 16px;
letter-spacing: 0;
vertical-align: -1px;
}
.exposure-facts {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin-top: 17px;
overflow: hidden;
border: 1px solid rgba(247, 241, 227, .1);
border-radius: 11px;
background: rgba(247, 241, 227, .08);
}
.exposure-facts > div {
min-width: 0;
padding: 8px 9px;
background: rgba(15, 34, 27, .78);
}
.exposure-facts span {
display: block;
overflow: hidden;
color: rgba(247, 241, 227, .48);
font-size: 8.5px;
letter-spacing: .03em;
white-space: nowrap;
text-overflow: ellipsis;
}
.exposure-facts b {
display: block;
overflow: hidden;
margin-top: 1px;
color: var(--cream);
font-family: var(--num);
font-size: 14px;
font-weight: 550;
white-space: nowrap;
text-overflow: ellipsis;
}
.layout {
display: grid;
grid-template-columns: 366px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 253, 248, .82);
box-shadow: var(--shadow-sm);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.controls {
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
overflow: auto;
padding: 20px 20px 18px;
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
.controls::-webkit-scrollbar { width: 5px; }
.controls::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 99px; }
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 13px;
}
.eyebrow-sm {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-family: var(--num);
font-size: 8.5px;
font-weight: 700;
letter-spacing: .18em;
}
.panel-head h2, .card-head h2, .section-head h2, .method-card h2 {
margin: 0;
font-size: 18px;
line-height: 1.35;
letter-spacing: -.02em;
font-weight: 750;
}
.reset-btn {
flex: none;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
color: var(--muted);
font-size: 11px;
transition: .18s ease;
}
.reset-btn:hover { border-color: var(--green); color: var(--green); background: var(--green-wash); }
.preset-label {
margin: 2px 0 7px;
color: var(--faint);
font-size: 10px;
letter-spacing: .05em;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 16px;
}
.preset {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: 9px 9px 8px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
color: var(--ink-2);
text-align: left;
transition: .18s ease;
}
.preset b { font-size: 11.5px; line-height: 1.35; font-weight: 700; white-space: nowrap; }
.preset span { color: var(--muted); font-size: 9px; line-height: 1.35; white-space: nowrap; }
.preset:hover { border-color: var(--green-2); transform: translateY(-1px); }
.preset.active {
border-color: var(--green);
background: var(--green);
color: var(--cream);
box-shadow: 0 6px 16px rgba(15, 92, 71, .18);
}
.preset.active span { color: rgba(247, 241, 227, .68); }
.control {
padding: 14px 0;
border-top: 1px solid var(--line);
}
.control-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 9px;
}
.control-head label {
color: var(--ink-2);
font-size: 12.5px;
font-weight: 650;
letter-spacing: .01em;
}
.control-head output {
flex: none;
color: var(--green);
font-family: var(--num);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.mini-note {
flex: none;
color: var(--faint);
font-size: 9.5px;
letter-spacing: .04em;
}
.hint {
margin: 7px 0 0;
color: var(--muted);
font-size: 10px;
line-height: 1.55;
}
input[type="range"] {
--fill: 50%;
display: block;
width: 100%;
height: 18px;
margin: 0;
padding: 0;
border: 0;
outline: none;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
border-radius: 99px;
background: linear-gradient(90deg, var(--green-2) 0 var(--fill), #e6e0d3 var(--fill) 100%);
}
input[type="range"]::-moz-range-track {
width: 100%;
height: 4px;
border-radius: 99px;
background: #e6e0d3;
}
input[type="range"]::-moz-range-progress {
height: 4px;
border-radius: 99px;
background: var(--green-2);
}
input[type="range"]::-webkit-slider-thumb {
width: 17px;
height: 17px;
margin-top: -6.5px;
border: 4px solid var(--green);
border-radius: 50%;
background: var(--paper);
box-shadow: 0 1px 5px rgba(15, 36, 28, .2);
-webkit-appearance: none;
appearance: none;
transition: transform .12s ease;
}
input[type="range"]::-moz-range-thumb {
width: 11px;
height: 11px;
border: 4px solid var(--green);
border-radius: 50%;
background: var(--paper);
box-shadow: 0 1px 5px rgba(15, 36, 28, .2);
}
input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.08); }
input[type="range"]:active::-webkit-slider-thumb { transform: scale(.96); }
input[type="range"].range-loss::-webkit-slider-runnable-track {
background: linear-gradient(90deg, var(--terra-2) 0 var(--fill), #e6e0d3 var(--fill) 100%);
}
input[type="range"].range-loss::-moz-range-progress { background: var(--terra-2); }
input[type="range"].range-loss::-webkit-slider-thumb { border-color: var(--terra); }
input[type="range"].range-loss::-moz-range-thumb { border-color: var(--terra); }
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 9px;
}
.chip {
padding: 3px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--muted);
font-family: var(--num);
font-size: 9.5px;
line-height: 1.5;
transition: .15s ease;
}
.chip:hover { border-color: var(--green-2); color: var(--green); background: var(--green-wash); }
.chip.warm:hover { border-color: var(--terra); color: var(--terra); background: var(--terra-soft); }
.stepper-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 6px;
}
.stepper {
display: inline-flex;
align-items: center;
gap: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper);
}
.stepper button {
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 15px;
line-height: 1;
}
.stepper button:hover { background: var(--green-wash); color: var(--green); }
.stepper input {
width: 30px;
height: 28px;
padding: 0;
border: 0;
border-right: 1px solid var(--line);
border-left: 1px solid var(--line);
background: transparent;
color: var(--ink-2);
font-family: var(--num);
font-size: 12px;
text-align: center;
-moz-appearance: textfield;
}
.stepper input::-webkit-outer-spin-button,
.stepper input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.stepper span { padding: 0 8px 0 5px; color: var(--muted); font-size: 10px; }
.hint-inline { color: var(--faint); font-size: 9.5px; }
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 10px;
background: #ede8dc;
}
.segmented button {
padding: 7px 8px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-size: 11px;
font-weight: 600;
transition: .16s ease;
}
.segmented button.active {
background: var(--paper);
color: var(--ink);
box-shadow: 0 2px 7px rgba(31, 42, 35, .09);
}
.segmented-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.segmented-3 button { padding-right: 4px; padding-left: 4px; font-size: 10.5px; }
.segmented-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.segmented-4 button { padding-right: 2px; padding-left: 2px; font-size: 10.5px; }
.segmented-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.mode-switch { margin-top: 2px; }
.mode-panel { margin-top: 8px; }
.control-head.sub { margin-top: 5px; margin-bottom: 7px; }
.control-head.sub label { color: var(--muted); font-size: 11px; font-weight: 550; }
.mode-total {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--line);
}
.mode-total > div { min-width: 0; padding: 7px 9px; background: var(--paper-2); }
.mode-total span { display: block; color: var(--muted); font-size: 8.5px; }
.mode-total b {
display: block;
overflow: hidden;
margin-top: 1px;
color: var(--green);
font-family: var(--num);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-wrap { position: relative; }
.select-wrap::after {
content: "⌄";
position: absolute;
top: 50%;
right: 11px;
color: var(--muted);
font-size: 13px;
transform: translateY(-55%);
pointer-events: none;
}
.select-wrap select {
width: 100%;
height: 36px;
padding: 0 32px 0 10px;
border: 1px solid var(--line);
border-radius: 9px;
outline: none;
background: var(--paper);
color: var(--ink-2);
font-size: 12px;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.select-wrap select:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
.jump-btn {
width: 100%;
padding: 8px 10px;
border: 1px dashed var(--green-2);
border-radius: 9px;
background: var(--green-wash);
color: var(--green);
font-size: 11px;
font-weight: 650;
text-align: left;
transition: .16s ease;
}
.jump-btn:hover { border-style: solid; background: var(--green-soft); }
.loss-input-wrap {
display: inline-flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper);
}
.loss-input-wrap input {
width: 48px;
height: 27px;
padding: 0 2px 0 8px;
border: 0;
background: transparent;
color: var(--terra);
font-family: var(--num);
font-size: 14px;
font-weight: 600;
text-align: right;
-moz-appearance: textfield;
}
.loss-input-wrap input::-webkit-outer-spin-button,
.loss-input-wrap input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.loss-input-wrap span { padding: 0 8px 0 2px; color: var(--terra); font-size: 11px; }
.loss-input-wrap.compact input { width: 38px; height: 24px; padding-left: 5px; font-size: 12px; }
.loss-input-wrap.compact span { padding-right: 5px; font-size: 10px; }
.capital-wrap {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--paper);
}
.capital-wrap span {
padding-left: 11px;
color: var(--muted);
font-family: var(--num);
font-size: 13px;
}
.capital-wrap input {
width: 100%;
height: 36px;
padding: 0 10px 0 5px;
border: 0;
background: transparent;
color: var(--ink);
font-family: var(--num);
font-size: 14px;
font-variant-numeric: tabular-nums;
-moz-appearance: textfield;
}
.capital-wrap input::-webkit-outer-spin-button,
.capital-wrap input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.results { min-width: 0; }
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.kpi {
position: relative;
overflow: hidden;
min-width: 0;
padding: 15px 15px 13px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--paper);
box-shadow: var(--shadow-sm);
}
.kpi::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--line-strong);
}
.kpi-loss { background: linear-gradient(180deg, #fdf1ea 0%, var(--paper) 62%); }
.kpi-loss::before { background: var(--terra); }
.kpi-gain::before { background: var(--gold); }
.kpi-left::before { background: var(--green-2); }
.kpi-saved::before { background: var(--green); }
.kpi-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-size: 10.5px;
font-weight: 550;
letter-spacing: .02em;
}
.kpi-code {
color: var(--faint);
font-family: var(--num);
font-size: 7.5px;
font-weight: 700;
letter-spacing: .14em;
}
.kpi-value {
overflow: hidden;
margin: 9px 0 1px;
color: var(--ink);
font-family: var(--num);
font-size: clamp(22px, 2vw, 30px);
font-weight: 600;
line-height: 1.15;
letter-spacing: -.035em;
white-space: nowrap;
text-overflow: ellipsis;
}
.kpi-value.loss { color: var(--terra); }
.kpi-value.green { color: var(--green); }
.kpi-value.is-long { font-size: clamp(18px, 1.55vw, 23px); letter-spacing: -.02em; }
.kpi-meta {
display: flex;
align-items: baseline;
gap: 5px;
color: var(--muted);
font-size: 9.5px;
white-space: nowrap;
}
.kpi-meta b {
color: var(--ink-2);
font-family: var(--num);
font-size: 11.5px;
font-weight: 600;
}
.kpi-loss .kpi-meta b { color: var(--terra); }
.kpi-saved .kpi-meta b { color: var(--green); }
.kpi-foot {
overflow: hidden;
margin: 8px 0 0;
padding-top: 7px;
border-top: 1px dashed var(--line);
color: var(--muted);
font-size: 9px;
line-height: 1.45;
white-space: nowrap;
text-overflow: ellipsis;
}
.strip-card {
display: grid;
grid-template-columns: minmax(220px, .72fr) minmax(0, 1.28fr);
gap: 26px;
align-items: center;
margin-top: 10px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--paper);
box-shadow: var(--shadow-sm);
}
.strip-copy h3 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 750;
letter-spacing: -.01em;
}
.strip-copy p {
margin: 0;
color: var(--muted);
font-size: 10.5px;
line-height: 1.55;
}
.strip-labels {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
color: var(--muted);
font-size: 9.5px;
}
.strip-labels b {
margin-left: 3px;
color: var(--terra);
font-family: var(--num);
font-size: 11px;
}
.strip-labels span:last-child b { color: var(--green); }
.strip-bar {
display: flex;
gap: 2px;
width: 100%;
height: 17px;
overflow: hidden;
border-radius: 99px;
background: #e7e4d9;
}
.strip-risk {
flex: none;
width: 11%;
min-width: 2px;
border-radius: 99px 3px 3px 99px;
background: linear-gradient(90deg, #c96346, var(--terra));
box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
transition: width .25s ease;
}
.strip-safe { flex: 1; border-radius: 3px 99px 99px 3px; background: linear-gradient(90deg, #cfe1d6, #e1ece4); }
.strip-amounts {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: var(--muted);
font-size: 9.5px;
}
.strip-amounts b {
margin-left: 3px;
color: var(--ink-2);
font-family: var(--num);
font-size: 11px;
}
.strip-amounts span:first-child b { color: var(--terra); }
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--paper);
box-shadow: var(--shadow);
}
.chart-card { margin-top: 10px; padding: 20px 20px 14px; }
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.chart-heading p, .card-head p {
margin: 3px 0 0;
color: var(--muted);
font-size: 10.5px;
line-height: 1.55;
}
.tabs {
display: inline-flex;
flex: none;
gap: 3px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
}
.tab {
padding: 6px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-size: 10.5px;
font-weight: 600;
white-space: nowrap;
transition: .16s ease;
}
.tab:hover { color: var(--ink); }
.tab.active { background: var(--dark); color: var(--cream); box-shadow: 0 2px 7px rgba(15,36,28,.16); }
.chart-wrap {
position: relative;
width: 100%;
margin-top: 8px;
touch-action: pan-y;
}
#chartSvg { display: block; width: 100%; height: auto; overflow: visible; }
#chartSvg text { font-family: var(--font); }
.chart-tooltip {
position: absolute;
z-index: 5;
width: 208px;
padding: 10px 11px;
border: 1px solid rgba(247, 241, 227, .12);
border-radius: 10px;
background: rgba(18, 36, 29, .96);
color: var(--cream);
box-shadow: 0 12px 32px rgba(15, 36, 28, .24);
pointer-events: none;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.chart-tooltip[hidden] { display: none; }
#chartSvg[hidden] { display: none; }
.custom-chart { padding: 3px 0 1px; }
.custom-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--line);
}
.custom-summary > div { min-width: 0; padding: 10px 12px; background: var(--paper-2); }
.custom-summary span { display: block; color: var(--muted); font-size: 9px; letter-spacing: .02em; }
.custom-summary b {
display: block;
overflow: hidden;
margin-top: 2px;
color: var(--ink);
font-family: var(--num);
font-size: clamp(16px, 1.8vw, 22px);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
}
.custom-summary > div:first-child b { color: var(--terra); }
.custom-list { margin-top: 10px; }
.custom-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr) 112px;
gap: 10px;
align-items: center;
padding: 7px 0;
border-bottom: 1px dashed var(--line);
}
.custom-row:last-child { border-bottom: 0; }
.custom-label { min-width: 0; }
.custom-label b {
display: block;
overflow: hidden;
color: var(--ink-2);
font-size: 10px;
font-weight: 650;
line-height: 1.35;
white-space: nowrap;
text-overflow: ellipsis;
}
.custom-label span { display: block; color: var(--muted); font-family: var(--num); font-size: 8.5px; line-height: 1.35; }
.custom-track {
height: 10px;
overflow: hidden;
border-radius: 99px;
background: #ece7db;
}
.custom-track i {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #d97757, var(--terra));
transition: width .18s ease;
}
.custom-amount { min-width: 0; text-align: right; }
.custom-amount b {
display: block;
overflow: hidden;
color: var(--terra);
font-family: var(--num);
font-size: 11.5px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
}
.custom-amount small { display: block; color: var(--muted); font-family: var(--num); font-size: 8px; line-height: 1.35; white-space: nowrap; }
.custom-total {
display: grid;
grid-template-columns: 86px minmax(0, 1fr) 112px;
gap: 10px;
align-items: center;
margin-top: 6px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
}
.custom-total .custom-label b { color: var(--ink); }
.custom-total .custom-track { background: #e7e2d6; }
.custom-total .custom-track i { background: var(--ink); }
.custom-total .custom-amount b { color: var(--ink); }
.custom-empty {
margin: 9px 0 0;
padding: 9px 11px;
border: 1px dashed var(--line);
border-radius: 9px;
background: var(--paper-2);
color: var(--muted);
font-size: 10px;
line-height: 1.55;
}
.chart-card.is-custom .chart-foot { justify-content: flex-start; }
.chart-card.is-custom .chart-foot p { text-align: left; }
.tt-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 7px;
padding-bottom: 7px;
border-bottom: 1px solid rgba(247, 241, 227, .12);
color: rgba(247, 241, 227, .58);
font-size: 9.5px;
}
.tt-head b { color: var(--cream); font-family: var(--num); font-size: 16px; font-weight: 550; }
.tt-row {
display: grid;
grid-template-columns: 7px 1fr auto;
align-items: center;
gap: 6px;
padding: 2px 0;
color: rgba(247, 241, 227, .72);
font-size: 9.5px;
}
.tt-row b { color: var(--cream); font-family: var(--num); font-size: 11px; font-weight: 550; }
.tt-row b.loss { color: #ef9a7d; }
.tt-dot { width: 6px; height: 6px; border-radius: 50%; }
.tt-dot.current { background: #65b394; }
.tt-dot.base { background: #d97757; }
.tt-sep { height: 1px; margin: 5px 0; background: rgba(247, 241, 227, .1); }
.tt-row.plain { grid-template-columns: 1fr auto; }
.chart-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 3px;
padding-top: 11px;
border-top: 1px solid var(--line);
}
.legend { display: flex; flex-wrap: wrap; gap: 14px; color: var(--muted); font-size: 9.5px; }
.legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.legend-line { display: inline-block; width: 18px; height: 0; border-top: 2px solid var(--green); }
.legend-line.base { border-top: 2px dashed var(--terra); }
.chart-foot p { margin: 0; color: var(--muted); font-size: 9.5px; line-height: 1.5; text-align: right; }
.ladder-card { margin-top: 24px; padding: 24px 24px 18px; }
.ladder-badge {
flex: none;
padding: 6px 11px;
border: 1px solid rgba(15, 92, 71, .16);
border-radius: 999px;
background: var(--green-wash);
color: var(--green);
font-family: var(--num);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.ladder { margin-top: 18px; border-top: 1px solid var(--line); }
.ladder-row {
display: grid;
grid-template-columns: 37px minmax(150px, 1.25fr) repeat(4, minmax(74px, .7fr));
gap: 12px;
align-items: center;
padding: 12px 8px 11px;
border-bottom: 1px solid var(--line);
}
.ladder-row.current {
margin: 0 -8px;
padding-right: 16px;
padding-left: 16px;
border-radius: 12px;
background: var(--green-wash);
box-shadow: inset 3px 0 0 var(--green);
}
.ladder-no {
display: grid;
place-items: center;
width: 29px;
height: 29px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--paper-2);
color: var(--muted);
font-family: var(--num);
font-size: 10px;
font-weight: 600;
}
.current .ladder-no { border-color: var(--green); background: var(--green); color: var(--cream); }
.ladder-name { min-width: 0; }
.ladder-name strong { display: block; font-size: 12px; line-height: 1.35; font-weight: 700; }
.ladder-name small { display: block; margin-top: 1px; color: var(--muted); font-size: 9.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ladder-metric { min-width: 0; }
.ladder-metric span { display: block; color: var(--faint); font-size: 8.5px; letter-spacing: .03em; }
.ladder-metric b {
display: block;
margin-top: 1px;
color: var(--ink);
font-family: var(--num);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.ladder-metric b.loss { color: var(--terra); }
.ladder-metric b.good { color: var(--green); }
.ladder-metric small { display: block; color: var(--muted); font-family: var(--num); font-size: 8.5px; white-space: nowrap; }
.ladder-track {
grid-column: 1 / -1;
height: 4px;
margin-top: -3px;
overflow: hidden;
border-radius: 99px;
background: #eee9dd;
}
.ladder-track i {
display: block;
width: 50%;
height: 100%;
border-radius: inherit;
background: #c9c1ae;
transition: width .28s ease;
}
.ladder-row.base .ladder-track i { background: linear-gradient(90deg, #d08c74, var(--terra)); }
.ladder-row.split .ladder-track i { background: linear-gradient(90deg, #d8bd7c, var(--gold)); }
.ladder-row.current .ladder-track i { background: linear-gradient(90deg, #54a083, var(--green)); }
.ladder-row.current .ladder-metric b { color: var(--green); }
.ladder-row.current .ladder-metric b.loss { color: var(--terra); }
.insight {
display: flex;
gap: 11px;
align-items: flex-start;
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 11px;
background: var(--paper-2);
color: var(--ink-2);
font-size: 11px;
line-height: 1.7;
}
.insight-icon {
display: grid;
place-items: center;
flex: none;
width: 20px;
height: 20px;
margin-top: 1px;
border-radius: 50%;
background: var(--gold-soft);
color: #7d5a18;
font-family: var(--num);
font-size: 11px;
font-weight: 700;
}
.insight b { color: var(--green); }
.insight .warn { color: var(--terra); }
.stocks-section { margin-top: 30px; }
.section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 13px;
}
.section-head p { margin: 3px 0 0; color: var(--muted); font-size: 10.5px; line-height: 1.55; }
.stock-head-right {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 7px;
}
.stock-summary {
flex: none;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 253, 248, .55);
color: var(--muted);
font-family: var(--num);
font-size: 10px;
white-space: nowrap;
}
.stock-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.stock-tools > span { color: var(--faint); font-size: 9px; }
.tool-chip {
padding: 3px 7px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--paper);
color: var(--muted);
font-family: var(--num);
font-size: 9px;
line-height: 1.5;
transition: .15s ease;
}
.tool-chip:hover { border-color: var(--terra); color: var(--terra); background: var(--terra-soft); }
.stock-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.stock-card {
position: relative;
overflow: hidden;
padding: 14px 14px 12px;
border: 1px solid var(--line);
border-radius: 15px;
background: var(--paper);
box-shadow: var(--shadow-sm);
}
.stock-card::after {
content: attr(data-index);
position: absolute;
right: 9px;
bottom: -15px;
color: rgba(24, 35, 29, .035);
font-family: var(--num);
font-size: 68px;
font-weight: 700;
line-height: 1;
pointer-events: none;
}
.stock-top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-family: var(--num);
font-size: 9.5px;
letter-spacing: .04em;
}
.stock-tag {
padding: 2px 6px;
border-radius: 5px;
background: var(--green-wash);
color: var(--green);
font-family: var(--font);
font-size: 8.5px;
letter-spacing: 0;
white-space: nowrap;
}
.stock-tag.empty { background: #eee9dd; color: var(--muted); }
.stock-tag.idle { background: var(--gold-soft); color: #7d5a18; }
.stock-tag.target { background: var(--terra-soft); color: var(--terra); }
.stock-card.is-empty { border-style: dashed; background: #faf7ef; }
.stock-card.is-empty .stock-alloc b { color: var(--faint); }
.stock-card.is-empty .stock-split { opacity: .55; }
.stock-card.is-empty .stock-settings,
.stock-card.is-empty .custom-editor,
.stock-card.is-empty .stock-rows { opacity: .6; }
.stock-card.is-target { border-color: rgba(189, 88, 59, .42); box-shadow: 0 0 0 1px rgba(189, 88, 59, .06), var(--shadow-sm); }
.stock-alloc {
position: relative;
z-index: 1;
display: flex;
align-items: baseline;
gap: 2px;
margin-top: 8px;
line-height: 1;
}
.stock-alloc b {
color: var(--ink);
font-family: var(--num);
font-size: 27px;
font-weight: 600;
letter-spacing: -.035em;
}
.stock-alloc span { color: var(--muted); font-family: var(--num); font-size: 12px; }
.stock-alloc-label {
position: relative;
z-index: 1;
margin: 1px 0 9px;
color: var(--muted);
font-size: 9px;
}
.stock-split {
position: relative;
z-index: 1;
display: flex;
gap: 2px;
height: 5px;
overflow: hidden;
border-radius: 99px;
background: #ece7db;
}
.stock-split i { display: block; height: 100%; border-radius: inherit; }
.stock-split .active { background: linear-gradient(90deg, #55a486, var(--green-2)); }
.stock-split .reserve { flex: 1; background: #ded8c9; }
.stock-rows {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 9px;
}
.stock-rows > div { min-width: 0; }
.stock-rows span { display: block; color: var(--faint); font-size: 8.5px; }
.stock-rows b { display: block; color: var(--ink-2); font-family: var(--num); font-size: 10.5px; font-weight: 600; }
.stock-settings {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 9px;
padding-top: 8px;
border-top: 1px dashed var(--line);
}
.stock-settings.one { grid-template-columns: 1fr; }
.stock-field { min-width: 0; }
.stock-field-label { display: block; margin-bottom: 3px; color: var(--muted); font-size: 8.5px; }
.stock-number {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--paper);
}
.stock-number input {
width: 100%;
height: 26px;
padding: 0 2px 0 7px;
border: 0;
background: transparent;
color: var(--green);
font-family: var(--num);
font-size: 12px;
font-weight: 600;
text-align: right;
-moz-appearance: textfield;
}
.stock-number input::-webkit-outer-spin-button,
.stock-number input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.stock-number span { padding: 0 6px 0 2px; color: var(--muted); font-size: 9px; }
.stock-number input:disabled,
.stock-card.is-empty .loss-input-wrap input:disabled,
.custom-range:disabled { opacity: .55; cursor: not-allowed; }
.stock-loss {
position: relative;
z-index: 1;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--line);
color: var(--muted);
font-size: 9px;
}
.stock-loss b { color: var(--terra); font-family: var(--num); font-size: 12px; font-weight: 600; white-space: nowrap; }
.stock-loss-extra { position: relative; z-index: 1; margin-top: 4px; color: var(--muted); font-family: var(--num); font-size: 8.5px; text-align: right; }
.custom-editor {
position: relative;
z-index: 1;
margin-top: 9px;
padding-top: 8px;
border-top: 1px dashed var(--line);
}
.custom-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
color: var(--muted);
font-size: 9px;
}
.custom-range { height: 22px; }
.custom-range::-webkit-slider-runnable-track { height: 4px; }
.stock-contrib {
position: relative;
z-index: 1;
margin-top: 4px;
color: var(--muted);
font-family: var(--num);
font-size: 8.5px;
text-align: right;
}
.stock-card.custom-card .stock-loss { margin-top: 7px; }
.stock-card.custom-card::after { display: none; }
.method {
display: grid;
grid-template-columns: minmax(0, 1.18fr) minmax(300px, .82fr);
gap: 14px;
margin-top: 34px;
}
.method-card, .note-card {
padding: 22px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 253, 248, .72);
}
.method-card h2 { margin-bottom: 10px; }
.calc-line {
padding: 10px 12px;
border-left: 2px solid var(--green);
border-radius: 0 8px 8px 0;
background: var(--paper-2);
color: var(--ink-2);
font-family: var(--num);
font-size: 11px;
line-height: 1.65;
word-break: break-word;
}
.calc-line b { color: var(--terra); }
.formula-list { margin-top: 12px; }
.formula-row {
display: grid;
grid-template-columns: 106px minmax(0, 1fr);
gap: 12px;
padding: 7px 0;
border-bottom: 1px dashed var(--line);
font-size: 10.5px;
}
.formula-row:last-child { border-bottom: 0; }
.formula-row span { color: var(--muted); }
.formula-row code {
color: var(--ink-2);
font-family: var(--num);
font-size: 10.5px;
line-height: 1.65;
}
.note-card h3 { margin: 0 0 9px; font-size: 14px; font-weight: 750; }
.note-card ol { margin: 0; padding-left: 17px; }
.note-card li {
margin-bottom: 8px;
color: var(--ink-2);
font-size: 10.5px;
line-height: 1.65;
}
.note-card li::marker { color: var(--gold); font-family: var(--num); font-size: 9px; }
.note-card b { color: var(--ink); }
footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 28px;
padding-top: 17px;
border-top: 1px solid var(--line);
color: var(--faint);
font-size: 9.5px;
line-height: 1.6;
}
footer .footer-mark { font-family: var(--num); letter-spacing: .12em; white-space: nowrap; }
.mobile-readout { display: none; }
[url=home.php?mod=space&uid=945662]@media[/url] (max-width: 1180px) {
.hero { grid-template-columns: minmax(0, 1.2fr) minmax(330px, .8fr); gap: 28px; }
.layout { grid-template-columns: 338px minmax(0, 1fr); }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ladder-row { grid-template-columns: 34px minmax(130px, 1.2fr) repeat(4, minmax(66px, .7fr)); gap: 9px; }
}
@media (max-width: 980px) {
.page { width: min(100% - 36px, 820px); padding-bottom: 44px; }
.hero { grid-template-columns: 1fr; gap: 24px; padding-top: 32px; }
.exposure-card { min-height: 0; }
.layout { grid-template-columns: 1fr; }
.controls { position: static; max-height: none; overflow: visible; }
.preset-grid { grid-template-columns: repeat(3, 1fr); }
.kpi-grid { grid-template-columns: repeat(4, 1fr); }
.strip-card { grid-template-columns: 1fr; gap: 14px; }
.method { grid-template-columns: 1fr; }
.mobile-readout.is-visible {
position: fixed;
left: 14px;
right: 14px;
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 40;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
padding: 9px 11px;
border: 1px solid rgba(247, 241, 227, .13);
border-radius: 14px;
background: rgba(18, 36, 29, .96);
color: var(--cream);
box-shadow: 0 14px 38px rgba(15, 36, 28, .26);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.mobile-readout > div {
min-width: 0;
padding: 0 9px;
border-left: 1px solid rgba(247, 241, 227, .12);
}
.mobile-readout > div:first-child { border-left: 0; padding-left: 2px; }
.mobile-readout span {
display: block;
color: rgba(247, 241, 227, .5);
font-size: 8px;
letter-spacing: .03em;
}
.mobile-readout b {
display: block;
overflow: hidden;
margin-top: 1px;
color: var(--cream);
font-family: var(--num);
font-size: 13px;
font-weight: 550;
white-space: nowrap;
text-overflow: ellipsis;
}
.mobile-readout > div:first-child b { color: #ef9a7d; }
}
@media (max-width: 760px) {
.page { width: calc(100% - 28px); padding-top: 15px; }
.topbar { padding-bottom: 15px; }
.top-note { display: none; }
.hero { padding: 28px 0 26px; }
.hero h1 { font-size: clamp(40px, 10vw, 58px); }
.hero-lead { font-size: 13.5px; line-height: 1.8; }
.hero-points { gap: 6px; margin-top: 21px; }
.hero-points > div { padding: 8px; }
.hero-points b { font-size: 11px; }
.hero-points small { font-size: 9px; }
.exposure-card { padding: 22px; border-radius: 23px; }
.exposure-value > span:first-child { font-size: 62px; }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.kpi { padding: 13px 12px 11px; }
.kpi-value { font-size: 22px; }
.kpi-foot { font-size: 8.5px; }
.chart-card { padding: 16px 13px 12px; }
.card-head { flex-direction: column; gap: 11px; }
.tabs { align-self: stretch; display: grid; grid-template-columns: 1fr 1fr; }
.tab { text-align: center; }
.chart-foot { flex-direction: column; align-items: flex-start; gap: 7px; }
.chart-foot p { text-align: left; }
.ladder-card { padding: 18px 14px 14px; }
.ladder-row {
grid-template-columns: 30px 1fr 1fr;
gap: 8px 10px;
padding: 11px 6px 10px;
}
.ladder-row.current { margin: 0 -6px; padding-right: 10px; padding-left: 10px; }
.ladder-no { grid-column: 1; grid-row: 1; }
.ladder-name { grid-column: 2 / 4; grid-row: 1; }
.metric-exposure { grid-column: 2; grid-row: 2; }
.metric-invested { grid-column: 3; grid-row: 2; }
.metric-loss { grid-column: 2; grid-row: 3; }
.metric-gain { grid-column: 3; grid-row: 3; }
.ladder-track { grid-column: 1 / -1; grid-row: 4; }
.section-head { align-items: flex-start; flex-direction: column; gap: 8px; }
.stock-head-right { align-items: flex-start; justify-content: flex-start; }
.custom-row, .custom-total { grid-template-columns: 66px minmax(0, 1fr) 96px; gap: 8px; }
.custom-summary > div { padding: 9px 10px; }
.custom-summary b { font-size: 17px; }
.stock-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); }
.stock-alloc b { font-size: 23px; }
.method { margin-top: 26px; }
.method-card, .note-card { padding: 18px; }
.formula-row { grid-template-columns: 90px minmax(0, 1fr); gap: 8px; }
footer { flex-direction: column; align-items: flex-start; gap: 5px; }
}
@media (max-width: 460px) {
.page { width: calc(100% - 20px); }
.brand-mark { width: 37px; height: 37px; border-radius: 11px; font-size: 18px; }
.brand-copy strong { font-size: 13px; }
.brand-copy span { font-size: 8px; }
.hero h1 { font-size: 39px; letter-spacing: -.045em; }
.hero h1 em { white-space: normal; }
.hero-points { grid-template-columns: 1fr; }
.hero-points > div { grid-template-columns: auto 1fr auto; grid-template-rows: auto; align-items: center; }
.hero-points span { grid-row: auto; }
.hero-points small { text-align: right; }
.preset-grid { grid-template-columns: 1fr 1fr; }
.preset:last-child { grid-column: 1 / -1; }
.segmented-3 button,
.segmented-4 button { padding-right: 2px; padding-left: 2px; font-size: 9.5px; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
.kpi-value { font-size: 20px; }
.kpi-code { display: none; }
.strip-card { padding: 15px; }
.strip-labels, .strip-amounts { font-size: 8.5px; }
.ladder-row { grid-template-columns: 27px 1fr 1fr; }
.ladder-metric b { font-size: 11.5px; }
.stock-grid { grid-template-columns: 1fr 1fr; gap: 7px; }
.stock-card:last-child:nth-child(odd) { grid-column: 1 / -1; }
.stock-card { padding: 11px 10px 10px; }
.stock-alloc b { font-size: 21px; }
.stock-rows b { font-size: 9.5px; }
.stock-loss { font-size: 8.5px; }
.stock-loss b { font-size: 10.5px; }
.custom-row, .custom-total { grid-template-columns: 56px minmax(0, 1fr) 84px; gap: 6px; }
.custom-label b { font-size: 9px; }
.custom-label span { font-size: 8px; }
.custom-amount b { font-size: 10.5px; }
.custom-amount small { font-size: 7.5px; }
.custom-summary span { font-size: 8px; }
.custom-summary b { font-size: 15px; }
.stock-tools > span { font-size: 8px; }
.formula-row { grid-template-columns: 1fr; gap: 2px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}
/* ============================================================
复利盈利版 · 新增组件(沿用原有视觉语言)
============================================================ */
.kpi-target::before { background: var(--green-2); }
.kpi-gold::before { background: var(--gold); }
.kpi-terra::before { background: var(--terra); }
.kpi-end::before { background: var(--dark); }
.kpi-value.gold { color: var(--gold); }
.kpi-value.terra { color: var(--terra); }
.kpi-value.ink { color: var(--ink); }
.loss-input-wrap.gain input { color: var(--green); }
.loss-input-wrap.gain span { color: var(--green); }
.strip-principal {
flex: none;
width: 20%;
min-width: 2px;
border-radius: 99px 3px 3px 99px;
background: linear-gradient(90deg, #8fbfa8, var(--green-2));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .16);
transition: width .25s ease;
}
.strip-gainbar {
flex: 1;
min-width: 0;
border-radius: 3px 99px 99px 3px;
background: linear-gradient(90deg, #e6d3a2, var(--gold));
}
.strip-card .strip-labels span:first-child b { color: var(--green-2); }
.strip-card .strip-labels span:last-child b { color: var(--gold); }
.strip-card .strip-amounts span:first-child b { color: var(--gold); }
.strip-card .strip-amounts span:last-child b { color: var(--ink-2); }
.chart-head { align-items: flex-start; }
.chart-actions {
display: flex;
flex: none;
flex-direction: column;
align-items: flex-end;
gap: 7px;
}
.scale-switch { display: flex; gap: 4px; }
.mini-chip {
padding: 4px 9px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--paper-2);
color: var(--muted);
font-family: var(--num);
font-size: 9.5px;
line-height: 1.5;
transition: .15s ease;
}
.mini-chip:hover { border-color: var(--green-2); color: var(--green); }
.mini-chip.active {
border-color: var(--dark);
background: var(--dark);
color: var(--cream);
box-shadow: 0 2px 7px rgba(15, 36, 28, .16);
}
.mini-chip[hidden] { display: none; }
#curveSvg, #compareSvg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
#curveSvg text, #compareSvg text { font-family: var(--font); }
.chart-xl #curveSvg { margin-top: 2px; }
.chart-tooltip.wide { width: 224px; }
.legend-line.target { border-top: 2px dashed var(--gold); }
.legend-line.you { border-top: 3px solid var(--ink); }
.compare-legend {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.legend-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--paper-2);
color: var(--muted);
font-size: 9.5px;
line-height: 1.4;
white-space: nowrap;
}
.legend-chip i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.legend-chip b {
color: var(--ink-2);
font-family: var(--num);
font-size: 10px;
font-weight: 600;
}
.legend-chip.you {
border-color: rgba(15, 92, 71, .32);
background: var(--green-wash);
color: var(--green);
}
.legend-chip.you b { color: var(--green); }
.ms-ladder { margin-top: 18px; border-top: 1px solid var(--line); }
.ms-row {
display: grid;
grid-template-columns: 40px minmax(140px, 1.2fr) repeat(4, minmax(74px, .72fr));
gap: 12px;
align-items: center;
padding: 13px 8px 12px;
border-bottom: 1px solid var(--line);
}
.ms-row:last-child { border-bottom: 0; }
.ms-row.is-target {
margin: 0 -8px;
padding-right: 16px;
padding-left: 16px;
border-radius: 12px;
background: var(--green-wash);
box-shadow: inset 3px 0 0 var(--green);
}
.ms-row.is-target .ladder-no {
border-color: var(--green);
background: var(--green);
color: var(--cream);
}
.ms-row.is-target .ladder-metric b { color: var(--green); }
.ms-row.is-target .ladder-track i { background: linear-gradient(90deg, #54a083, var(--green)); }
.ms-row.is-hit:not(.is-target) .ladder-metric .good { color: var(--green-2); }
.ms-row .ladder-metric small { white-space: nowrap; }
.ms-row .ladder-track i { background: linear-gradient(90deg, #cfc7b3, var(--ink-2)); }
.ms-row.is-hit .ladder-track i { background: linear-gradient(90deg, #d8bd7c, var(--gold)); }
.ms-row.is-target .ladder-track i { background: linear-gradient(90deg, #54a083, var(--green)); }
.chain-card { margin-top: 12px; padding: 24px 24px 20px; }
.detail-card { margin-top: 12px; padding: 24px 24px 18px; }
.roll-flow {
display: flex;
align-items: stretch;
gap: 5px;
overflow-x: auto;
margin-top: 16px;
padding-bottom: 8px;
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
.roll-flow::-webkit-scrollbar { height: 5px; }
.roll-flow::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 99px; }
.roll-card {
flex: none;
min-width: 118px;
padding: 10px 11px 9px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper-2);
}
.roll-card.start { border-color: rgba(15, 92, 71, .22); background: var(--green-wash); }
.roll-card span { display: block; color: var(--muted); font-size: 8.5px; }
.roll-card b {
display: block;
margin-top: 2px;
color: var(--ink);
font-family: var(--num);
font-size: 14px;
font-weight: 600;
white-space: nowrap;
letter-spacing: -.02em;
}
.roll-card.start b { color: var(--green); }
.roll-card small {
display: block;
margin-top: 1px;
color: var(--gold);
font-family: var(--num);
font-size: 9px;
white-space: nowrap;
}
.roll-card.start small { color: var(--muted); }
.roll-arrow {
display: grid;
flex: none;
place-items: center;
color: var(--line-strong);
font-family: var(--num);
font-size: 12px;
}
.detail-wrap {
margin-top: 16px;
max-height: 520px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 14px;
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
.detail-wrap::-webkit-scrollbar { width: 6px; height: 6px; }
.detail-wrap::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 99px; }
table.detail {
width: 100%;
min-width: 690px;
border-collapse: collapse;
font-family: var(--num);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
table.detail thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 9px 12px;
border-bottom: 1px solid var(--line);
background: #f6f1e6;
color: var(--muted);
font-family: var(--font);
font-size: 9px;
font-weight: 600;
text-align: right;
white-space: nowrap;
}
table.detail thead th:first-child { text-align: left; }
table.detail tbody td {
padding: 7px 12px;
border-bottom: 1px dashed var(--line);
color: var(--ink-2);
text-align: right;
white-space: nowrap;
}
table.detail tbody td:first-child {
color: var(--muted);
font-family: var(--font);
font-size: 10px;
text-align: left;
}
table.detail tbody tr:last-child td { border-bottom: 0; }
table.detail tbody tr.mstone td { background: rgba(242, 231, 201, .5); }
table.detail tbody tr.mstone td:first-child { color: #7d5a18; font-weight: 650; }
table.detail tbody tr.is-target td { background: var(--green-wash); }
table.detail tbody tr.is-target td:first-child { color: var(--green); font-weight: 700; }
table.detail tbody td.gain { color: var(--green); }
table.detail tbody td.finish { color: var(--gold); font-weight: 600; }
.ms-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 5px;
border-radius: 4px;
background: var(--gold-soft);
color: #7d5a18;
font-family: var(--font);
font-size: 8px;
font-weight: 600;
vertical-align: 1px;
}
.ms-row.is-target .ms-tag { background: rgba(15, 92, 71, .12); color: var(--green); }
.calc-line b { color: var(--green); }
@media (max-width: 1180px) {
.ms-row {
grid-template-columns: 34px minmax(120px, 1.1fr) repeat(4, minmax(62px, .72fr));
gap: 9px;
}
.ms-row.is-target { padding-right: 12px; padding-left: 12px; }
.roll-card { min-width: 110px; }
}
@media (max-width: 980px) {
.chart-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
width: 100%;
}
.chain-card, .detail-card { padding: 22px 20px 16px; }
}
@media (max-width: 760px) {
.ms-row {
grid-template-columns: 30px 1fr 1fr;
gap: 8px 10px;
padding: 11px 6px 10px;
}
.ms-row .ladder-no { grid-row: 1; }
.ms-row .ladder-name { grid-column: 2 / 4; grid-row: 1; }
.ms-row .ms-years { grid-column: 2; grid-row: 2; }
.ms-row .ms-periods { grid-column: 3; grid-row: 2; }
.ms-row .ms-amount { grid-column: 2; grid-row: 3; }
.ms-row .ms-multiple { grid-column: 3; grid-row: 3; }
.ms-row .ladder-track { grid-column: 1 / -1; grid-row: 4; }
.ms-row.is-target { margin: 0 -6px; padding-right: 10px; padding-left: 10px; }
.chain-card, .detail-card { padding: 18px 14px 14px; }
.roll-card { min-width: 106px; padding: 9px 10px 8px; }
.roll-card b { font-size: 12.5px; }
.detail-wrap { max-height: 372px; }
table.detail { min-width: 470px; }
table.detail thead th:nth-child(2),
table.detail thead th:nth-child(4),
table.detail tbody td:nth-child(2),
table.detail tbody td:nth-child(4) { display: none; }
.compare-legend { gap: 4px; }
.legend-chip { padding: 4px 7px; font-size: 9px; }
.chart-xl .chart-wrap { margin-top: 10px; }
}
@media (max-width: 460px) {
.chart-actions { gap: 6px; }
.tabs .tab { padding: 6px 7px; }
.mini-chip { padding: 4px 7px; font-size: 9px; }
.roll-card { min-width: 98px; }
.roll-card b { font-size: 11.5px; }
.roll-card small { font-size: 8px; }
}
</style>
</head>
<body>
<div class="page">
<header class="topbar">
<div class="brand">
<span class="brand-mark">复</span>
<div class="brand-copy">
<strong>复利实验室</strong>
<span>COMPOUND GROWTH LAB</span>
</div>
</div>
<div class="top-note"><i></i> 用时间,放大确定性</div>
</header>
<section class="hero">
<div class="hero-copy">
<div class="eyebrow"><span class="eyebrow-dot"></span> 按年 · 按月 · 按每一次短线交易</div>
<h1>20% 的复利,<br><em>能把你带到哪。</em></h1>
<p class="hero-lead">100 → 120 → 144 → 172.8……每一次都在更大的本金上再赚 20%。按年、按季、按月,或者只数每一次短线买卖——看清翻倍要多久、多少次能摸到 100 万、1000 万,甚至 1 个亿。</p>
<div class="hero-points" aria-label="三个核心动作">
<div><span>01</span><b>每次收益</b><small>短线和长线都算</small></div>
<div><span>02</span><b>利滚利</b><small>收益也去赚钱</small></div>
<div><span>03</span><b>目标里程</b><small>100万 → 1亿</small></div>
</div>
<p class="hero-foot">不预测涨跌,只计算:如果每次稳定赚 X%,复利会给你什么。</p>
</div>
<aside class="exposure-card">
<div class="exposure-top">
<span id="annualLabel">年化复利</span>
<span class="risk-badge" id="rateBadge" data-level="mid">进取</span>
</div>
<div class="exposure-value"><span id="annualValue">20</span><span class="pct-sign">%</span></div>
<p class="exposure-caption" id="annualCaption">每期 20%、每年 1 期:年化 20%,约 3.8 年翻一倍。</p>
<div class="gauge" aria-hidden="true">
<div class="gauge-track"><span class="gauge-marker" id="gaugeMarker"></span></div>
<div class="gauge-labels"><span>0</span><span>30</span><span>60</span><span>90</span><span>120%</span></div>
</div>
<div class="formula-box" id="rateFormula">每期 <b>20%</b> × 每年 <b>1 期</b> → 年化 <b>20%</b></div>
<div class="fraction-badge" id="doubleBadge">翻倍需要 <b>3.8 年</b>(约 3.8 期)</div>
<div class="exposure-facts">
<div><span id="factRateLabel">每期收益</span><b id="factRate">20%</b></div>
<div><span id="factFreqLabel">每年期数</span><b id="factFreq">1 期</b></div>
<div><span>到 100 万</span><b id="factTarget">12.6 年</b></div>
</div>
</aside>
</section>
<main class="layout">
<aside class="panel controls" aria-label="参数控制">
<div class="panel-head">
<div>
<span class="eyebrow-sm">CONTROLS</span>
<h2>调一调,看复利</h2>
</div>
<button class="reset-btn" id="resetBtn" type="button">重置</button>
</div>
<div class="preset-label" id="presetLabel">快速场景(年化)</div>
<div class="preset-grid">
<button class="preset" data-preset="steady" type="button" aria-pressed="false"><b>稳健 8%</b><span>红利 · 宽基</span></button>
<button class="preset" data-preset="pro" type="button" aria-pressed="false"><b>进取 15%</b><span>长期好手</span></button>
<button class="preset active" data-preset="master" type="button" aria-pressed="true"><b>高手 20%</b><span>年化天花板</span></button>
</div>
<div class="control">
<div class="control-head">
<label for="rateRange" id="rateLabel">每期收益率</label>
<div class="loss-input-wrap gain">
<input type="number" id="rateInput" min="0" max="100" step="0.5" value="20" inputmode="decimal" aria-label="每期收益率百分比">
<span>%</span>
</div>
</div>
<input type="range" id="rateRange" min="0" max="100" step="0.5" value="20">
<div class="chip-row">
<button class="chip" type="button" data-rate="10">10%</button>
<button class="chip" type="button" data-rate="15">15%</button>
<button class="chip" type="button" data-rate="20">20%</button>
<button class="chip" type="button" data-rate="25">25%</button>
<button class="chip" type="button" data-rate="30">30%</button>
</div>
<p class="hint" id="rateHint">每期固定赚这么多,收益不取出,下一期连本带利继续滚。</p>
</div>
<div class="control">
<div class="control-head">
<label>复利频率</label>
<span class="mini-note" id="freqAnnual">年化 20%</span>
</div>
<div class="segmented segmented-4" role="group" aria-label="复利频率">
<button class="active" type="button" data-freq="1" aria-pressed="true">每年</button>
<button type="button" data-freq="4" aria-pressed="false">每季</button>
<button type="button" data-freq="12" aria-pressed="false">每月</button>
<button type="button" data-freq="0" aria-pressed="false">每次</button>
</div>
<p class="hint" id="freqHint">每年结一次:每期 20%,年化就是 20%。同一每期收益率,结算越频繁,年化越高。</p>
</div>
<div class="control">
<div class="control-head">
<label for="yearsRange" id="horizonLabel">计算年限</label>
<output id="yearsValue" for="yearsRange">30 年</output>
</div>
<input type="range" id="yearsRange" min="5" max="60" step="1" value="30">
<div class="chip-row" id="yearChips">
<button class="chip" type="button" data-years="10">10年</button>
<button class="chip" type="button" data-years="20">20年</button>
<button class="chip" type="button" data-years="30">30年</button>
<button class="chip" type="button" data-years="40">40年</button>
</div>
<div class="chip-row" id="tradeChips" hidden>
<button class="chip" type="button" data-trades="30">30次</button>
<button class="chip" type="button" data-trades="60">60次</button>
<button class="chip" type="button" data-trades="120">120次</button>
<button class="chip" type="button" data-trades="240">240次</button>
</div>
<p class="hint" id="horizonHint">只影响曲线和明细显示的长度;里程碑所需年数按实际复利单独计算。</p>
</div>
<div class="control">
<div class="control-head">
<label for="startInput">起始本金</label>
<span class="mini-note">人民币</span>
</div>
<div class="capital-wrap">
<span>¥</span>
<input type="number" id="startInput" min="100" max="1000000000" step="1000" value="100000" inputmode="numeric" aria-label="起始本金">
</div>
<div class="chip-row">
<button class="chip" type="button" data-start="10000">1万</button>
<button class="chip" type="button" data-start="100000">10万</button>
<button class="chip" type="button" data-start="1000000">100万</button>
</div>
</div>
<div class="control">
<div class="control-head">
<label for="extraInput" id="extraLabel">每期追加</label>
<span class="mini-note">可选</span>
</div>
<div class="capital-wrap">
<span>¥</span>
<input type="number" id="extraInput" min="0" max="10000000" step="1000" value="0" inputmode="numeric" aria-label="每期追加投入">
</div>
<div class="chip-row">
<button class="chip" type="button" data-extra="0">不加</button>
<button class="chip" type="button" data-extra="5000">5000</button>
<button class="chip" type="button" data-extra="20000">2万</button>
</div>
<p class="hint" id="extraHint">按期末追加,追加额同样进入下一期复利;默认 0,只看纯利滚利。</p>
</div>
<div class="control">
<div class="control-head">
<label>目标金额</label>
<span class="mini-note">高亮对比</span>
</div>
<div class="segmented segmented-3" role="group" aria-label="目标金额">
<button class="active" type="button" data-target="1000000" aria-pressed="true">100万</button>
<button type="button" data-target="10000000" aria-pressed="false">1000万</button>
<button type="button" data-target="100000000" aria-pressed="false">1亿</button>
</div>
<p class="hint" id="targetHint">对比图、阶梯和明细会围绕 ¥100 万高亮。</p>
</div>
</aside>
<section class="results" aria-label="计算结果">
<div class="kpi-grid">
<article class="kpi kpi-target">
<div class="kpi-head"><span>到 ¥100 万</span><span class="kpi-code">1M</span></div>
<div class="kpi-value green" id="kpi1m">12.6 年</div>
<div class="kpi-meta"><b id="kpi1mMeta">第 13 期</b><span id="kpi1mSub">约 2039 年</span></div>
<p class="kpi-foot" id="kpi1mFoot">×10 · 3.3 次翻倍</p>
</article>
<article class="kpi kpi-gold">
<div class="kpi-head"><span>到 ¥1000 万</span><span class="kpi-code">10M</span></div>
<div class="kpi-value gold" id="kpi10m">25.3 年</div>
<div class="kpi-meta"><b id="kpi10mMeta">第 26 期</b><span id="kpi10mSub">约 2052 年</span></div>
<p class="kpi-foot" id="kpi10mFoot">×100 · 6.6 次翻倍</p>
</article>
<article class="kpi kpi-terra">
<div class="kpi-head"><span>到 ¥1 亿</span><span class="kpi-code">100M</span></div>
<div class="kpi-value terra" id="kpi100m">37.9 年</div>
<div class="kpi-meta"><b id="kpi100mMeta">第 38 期</b><span id="kpi100mSub">约 2064 年</span></div>
<p class="kpi-foot" id="kpi100mFoot">×1000 · 10 次翻倍</p>
</article>
<article class="kpi kpi-end">
<div class="kpi-head"><span id="kpiEndHead">第 30 年末</span><span class="kpi-code">NOW</span></div>
<div class="kpi-value ink" id="kpiEnd">¥2374万</div>
<div class="kpi-meta"><b id="kpiEndMeta">×237</b><span id="kpiEndSub">第 30 年</span></div>
<p class="kpi-foot" id="kpiEndFoot">复利收益 ¥2364万</p>
</article>
</div>
<div class="strip-card">
<div class="strip-copy">
<span class="eyebrow-sm">WHO MADE IT</span>
<h3>这笔钱,是谁赚出来的?</h3>
<p id="stripText">第 30 年末,账户里 0.4% 是你投入的本金,99.6% 是复利收益。</p>
</div>
<div class="strip-visual">
<div class="strip-labels">
<span>累计投入 <b id="stripBase">¥100,000</b></span>
<span>复利收益 <b id="stripGainAmount">¥23,637,631</b></span>
</div>
<div class="strip-bar" aria-hidden="true">
<div class="strip-principal" id="stripPrincipalBar"></div>
<div class="strip-gainbar"></div>
</div>
<div class="strip-amounts">
<span>复利贡献 <b id="stripGainPct">99.6%</b></span>
<span>期末账户 <b id="stripEnd">¥23,737,631</b></span>
</div>
</div>
</div>
<section class="card chart-card chart-xl" id="curveCard">
<div class="card-head chart-head">
<div class="chart-heading">
<span class="eyebrow-sm">COMPOUND CURVE</span>
<h2 id="curveTitle">复利曲线:从 ¥10 万到 ¥2374 万</h2>
<p id="curveSubtitle">第 30 年 · 年化 20% · 纵轴对数刻度(每格 10 倍)</p>
</div>
<div class="chart-actions">
<div class="tabs" id="curveTabs" role="tablist" aria-label="曲线内容">
<button class="tab active" type="button" data-ctab="curve" role="tab" aria-selected="true">账户曲线</button>
<button class="tab" type="button" data-ctab="yearly" id="curveYearlyTab" role="tab" aria-selected="false">每年收益</button>
</div>
<div class="scale-switch" id="scaleSwitch" role="group" aria-label="纵轴刻度">
<button class="mini-chip active" type="button" data-scale="log" aria-pressed="true">对数</button>
<button class="mini-chip" type="button" data-scale="linear" aria-pressed="false">线性</button>
</div>
</div>
</div>
<div class="chart-wrap" id="curveWrap">
<svg id="curveSvg" role="img" aria-label="复利增长曲线图"></svg>
<div class="chart-tooltip wide" id="curveTip" hidden></div>
</div>
<div class="chart-foot">
<div class="legend" id="curveLegend">
<span><i class="legend-line"></i>账户金额</span>
<span><i class="legend-line target"></i>目标里程碑</span>
</div>
<p id="curveNote">复利前期慢、后期快;后期一年的收益,可能超过最初的全部本金。</p>
</div>
</section>
<section class="card chart-card" id="compareCard">
<div class="card-head chart-head">
<div class="chart-heading">
<span class="eyebrow-sm">RATE COMPARISON</span>
<h2 id="compareTitle">年化 5%–30%,多久到 ¥100 万</h2>
<p id="compareSubtitle">同一笔 ¥10 万本金 · 不含追加投入 · 纵轴对数刻度</p>
</div>
<div class="ladder-badge" id="compareBadge">目标 ¥100 万</div>
</div>
<div class="chart-wrap" id="compareWrap">
<svg id="compareSvg" role="img" aria-label="不同收益率的到达时间对比图"></svg>
</div>
<div class="compare-legend" id="compareLegend"></div>
<div class="chart-foot">
<p id="compareNote">曲线越陡,钱滚得越快;圆点是到达目标的那一年。</p>
</div>
</section>
</section>
</main>
<section class="card ladder-card" id="ladderCard">
<div class="card-head">
<div>
<span class="eyebrow-sm">MILESTONE LADDER</span>
<h2 id="ladderTitle">到每一个里程碑,需要多久</h2>
<p id="ladderSubtitle">每期 20% · 每年 1 期 · 从 ¥100,000 出发。</p>
</div>
<div class="ladder-badge" id="ladderBadge">目标 ¥100 万 · 12.6 年</div>
</div>
<div class="ms-ladder" id="milestoneLadder"></div>
<div class="insight">
<span class="insight-icon">i</span>
<p id="ladderInsight">从 10 万到 100 万要 12.6 年;再一个 12.6 年就是 1000 万。复利不关心绝对值,只关心倍数。</p>
</div>
</section>
<section class="card chain-card" id="chainCard">
<div class="card-head">
<div>
<span class="eyebrow-sm">ROLL FORWARD</span>
<h2 id="chainTitle">利滚利:前 12 期长什么样</h2>
<p id="chainSubtitle">¥100,000 → ¥120,000 → ¥144,000 …… 每一期的收益,下一期也去赚钱。</p>
</div>
<div class="ladder-badge" id="chainBadge">每期 +20%</div>
</div>
<div class="roll-flow" id="rollFlow"></div>
</section>
<section class="card detail-card" id="detailCard">
<div class="card-head">
<div>
<span class="eyebrow-sm" id="detailEyebrow">YEAR BY YEAR</span>
<h2 id="detailTitle">逐年明细</h2>
<p id="detailSubtitle">每年结一次 · 每期 20% · 金色行为里程碑,绿色行为目标达成那年。</p>
</div>
<div class="ladder-badge" id="detailBadge">30 年 · 30 期</div>
</div>
<div class="detail-wrap">
<table class="detail">
<thead>
<tr>
<th id="detailYearHead">年份</th>
<th id="detailStartHead">年初</th>
<th id="detailGainHead">本年收益</th>
<th>追加</th>
<th id="detailEndHead">年末</th>
<th>累计倍数</th>
<th>距目标</th>
</tr>
</thead>
<tbody id="detailBody"></tbody>
</table>
</div>
</section>
<section class="method">
<div class="method-card">
<span class="eyebrow-sm">METHOD</span>
<h2>这些数字怎么来的?</h2>
<div class="calc-line" id="calcLine">本金 ¥100,000 × (1 + 20%)^30 = ¥23,737,631</div>
<div class="formula-list">
<div class="formula-row"><span>期末金额</span><code>本金 ×(1 + 每期收益率)^ 期数</code></div>
<div class="formula-row"><span>到目标期数</span><code>ln(目标 ÷ 本金) ÷ ln(1 + 每期收益率)</code></div>
<div class="formula-row"><span>翻倍时间</span><code>ln2 ÷ ln(1 + 每期收益率) ÷ 每年期数</code></div>
<div class="formula-row"><span>年化收益</span><code>(1 + 每期收益率)^每年期数 − 1</code></div>
<div class="formula-row"><span>追加投入</span><code>期末 = 上期 ×(1 + r)+ 每期追加</code></div>
</div>
</div>
<div class="note-card">
<h3>四个必须说清的前提</h3>
<ol>
<li><b>恒定收益率只是算术假设。</b>真实市场会回撤:先亏 50%,要涨 100% 才回本;波动会明显拖慢复利。</li>
<li><b>20% 是极高标准。</b>长期年化 20% 已属全球顶尖;若要求每一次短线都赚 20%,对胜率和执行的要求更极端。把它当上限,而不是预期。</li>
<li><b>税、费、通胀没有计入。</b>短线换手更多,佣金、印花税、滑点和通胀都会吃掉一部分账面收益。</li>
<li><b>时间或次数,比收益率更难得。</b>20% 看似凶猛,10 万到 1 亿仍要近 38 年(或 38 次连续成功);先活得久,再谈复利。</li>
</ol>
</div>
</section>
<footer>
<span>本工具仅用于复利数学演示,不构成投资建议。稳定高收益不可承诺,本金可能亏损。</span>
<span class="footer-mark">COMPOUND GROWTH LAB · 2026</span>
</footer>
</div>
<div class="mobile-readout" aria-live="polite" aria-label="实时结果">
<div><span>到 100 万</span><b id="m1m">12.6 年</b></div>
<div><span>到 1000 万</span><b id="m10m">25.3 年</b></div>
<div><span id="mAnnualLabel">年化</span><b id="mAnnual">20%</b></div>
</div>
<script>
(() => {
"use strict";
const DEFAULTS = Object.freeze({
start: 100000,
rate: 20,
freq: 1,
years: 30,
tradeMode: false,
trades: 120,
extra: 0,
target: 1000000,
ctab: "curve",
scale: "log"
});
const MILESTONES = [200000, 500000, 1000000, 2000000, 5000000, 10000000, 20000000, 50000000, 100000000];
const COMPARE_RATES = [5, 8, 10, 15, 20, 30];
const RATE_COLORS = ["#93ab9d", "#63a78b", "#1f7a5e", "#b28733", "#0f5c47", "#bd583b"];
const CAP_YEARS = 150;
const CAP_TRADES = 1800;
const NOW_YEAR = new Date().getFullYear();
const state = { ...DEFAULTS };
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
const refs = {
annualLabel: $("#annualLabel"),
annualValue: $("#annualValue"),
annualCaption: $("#annualCaption"),
rateBadge: $("#rateBadge"),
gaugeMarker: $("#gaugeMarker"),
gaugeTrack: $(".gauge-track"),
rateFormula: $("#rateFormula"),
doubleBadge: $("#doubleBadge"),
factRate: $("#factRate"),
factRateLabel: $("#factRateLabel"),
factFreq: $("#factFreq"),
factFreqLabel: $("#factFreqLabel"),
factTarget: $("#factTarget"),
presetLabel: $("#presetLabel"),
rateRange: $("#rateRange"),
rateInput: $("#rateInput"),
rateLabel: $("#rateLabel"),
rateHint: $("#rateHint"),
freqAnnual: $("#freqAnnual"),
freqHint: $("#freqHint"),
horizonLabel: $("#horizonLabel"),
horizonHint: $("#horizonHint"),
yearChips: $("#yearChips"),
tradeChips: $("#tradeChips"),
yearsRange: $("#yearsRange"),
yearsValue: $("#yearsValue"),
startInput: $("#startInput"),
extraInput: $("#extraInput"),
extraLabel: $("#extraLabel"),
extraHint: $("#extraHint"),
targetHint: $("#targetHint"),
kpi1m: $("#kpi1m"),
kpi1mMeta: $("#kpi1mMeta"),
kpi1mSub: $("#kpi1mSub"),
kpi1mFoot: $("#kpi1mFoot"),
kpi10m: $("#kpi10m"),
kpi10mMeta: $("#kpi10mMeta"),
kpi10mSub: $("#kpi10mSub"),
kpi10mFoot: $("#kpi10mFoot"),
kpi100m: $("#kpi100m"),
kpi100mMeta: $("#kpi100mMeta"),
kpi100mSub: $("#kpi100mSub"),
kpi100mFoot: $("#kpi100mFoot"),
kpiEnd: $("#kpiEnd"),
kpiEndHead: $("#kpiEndHead"),
kpiEndMeta: $("#kpiEndMeta"),
kpiEndSub: $("#kpiEndSub"),
kpiEndFoot: $("#kpiEndFoot"),
stripText: $("#stripText"),
stripBase: $("#stripBase"),
stripGainAmount: $("#stripGainAmount"),
stripGainPct: $("#stripGainPct"),
stripEnd: $("#stripEnd"),
stripPrincipalBar: $("#stripPrincipalBar"),
curveCard: $("#curveCard"),
curveTitle: $("#curveTitle"),
curveSubtitle: $("#curveSubtitle"),
curveTabs: $("#curveTabs"),
curveYearlyTab: $("#curveYearlyTab"),
scaleSwitch: $("#scaleSwitch"),
curveWrap: $("#curveWrap"),
curveSvg: $("#curveSvg"),
curveTip: $("#curveTip"),
curveLegend: $("#curveLegend"),
curveNote: $("#curveNote"),
compareCard: $("#compareCard"),
compareTitle: $("#compareTitle"),
compareSubtitle: $("#compareSubtitle"),
compareBadge: $("#compareBadge"),
compareWrap: $("#compareWrap"),
compareSvg: $("#compareSvg"),
compareLegend: $("#compareLegend"),
compareNote: $("#compareNote"),
ladderTitle: $("#ladderTitle"),
ladderBadge: $("#ladderBadge"),
ladderSubtitle: $("#ladderSubtitle"),
milestoneLadder: $("#milestoneLadder"),
ladderInsight: $("#ladderInsight"),
chainTitle: $("#chainTitle"),
chainSubtitle: $("#chainSubtitle"),
chainBadge: $("#chainBadge"),
rollFlow: $("#rollFlow"),
detailEyebrow: $("#detailEyebrow"),
detailTitle: $("#detailTitle"),
detailYearHead: $("#detailYearHead"),
detailStartHead: $("#detailStartHead"),
detailGainHead: $("#detailGainHead"),
detailEndHead: $("#detailEndHead"),
detailSubtitle: $("#detailSubtitle"),
detailBadge: $("#detailBadge"),
detailBody: $("#detailBody"),
calcLine: $("#calcLine"),
m1m: $("#m1m"),
m10m: $("#m10m"),
mAnnualLabel: $("#mAnnualLabel"),
mAnnual: $("#mAnnual")
};
const svgNS = "http://www.w3.org/2000/svg";
let curveGeom = null;
let renderFrame = 0;
let resizeTimer = 0;
function trimNum(text) {
return String(text).replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}
const SUPERSCRIPT = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹" };
function sciText(value) {
const abs = Math.abs(value);
let exponent = Math.floor(Math.log10(abs));
let mantissa = value / Math.pow(10, exponent);
if (Math.abs(mantissa) >= 9.9995) {
mantissa /= 10;
exponent += 1;
}
const digits = String(exponent).split("").map(digit => SUPERSCRIPT[digit]).join("");
if (Math.abs(Math.abs(mantissa) - 1) < 0.0005) return "10" + digits;
return trimNum(mantissa.toFixed(2)) + "×10" + digits;
}
function money(value) {
if (!Number.isFinite(value)) return "—";
return "¥" + nf0.format(Math.round(value));
}
function numShort(value) {
if (!Number.isFinite(value)) return "—";
const abs = Math.abs(value);
if (abs >= 1e16) return sciText(value);
if (abs >= 1e12) return trimNum((value / 1e12).toFixed(1)) + "万亿";
if (abs >= 1e8) return trimNum((value / 1e8).toFixed(abs >= 1e9 ? 1 : 2)) + "亿";
if (abs >= 1e4) return trimNum((value / 1e4).toFixed(abs >= 1e6 ? 0 : 1)) + "万";
return nf0.format(Math.round(value));
}
function moneyShort(value) {
if (!Number.isFinite(value)) return "—";
return "¥" + numShort(value);
}
function pctText(value, digits = 1) {
if (!Number.isFinite(value)) return "—";
return trimNum((value * 100).toFixed(digits)) + "%";
}
const isTrade = () => state.tradeMode === true;
const unitName = () => isTrade() ? "次" : "年";
const countName = () => isTrade() ? "次" : "期";
function yearsText(value) {
if (value == null) return "做不到";
if (value <= 0.0001) return "已达成";
const unit = unitName();
if (value >= 100) return nf0.format(Math.round(value)) + " " + unit;
return trimNum(value.toFixed(1)) + " " + unit;
}
function periodsText(value) {
if (value == null) return "—";
if (value >= 100) return nf0.format(Math.ceil(value)) + " " + countName();
return trimNum(value.toFixed(1)) + " " + countName();
}
function multipleText(value) {
if (!Number.isFinite(value)) return "—";
if (value >= 1e7) return sciText(value) + " 倍";
if (value >= 10000) return "×" + nf0.format(value);
if (value >= 100) return "×" + trimNum(value.toFixed(0));
if (value >= 10) return "×" + trimNum(value.toFixed(1));
return "×" + trimNum(value.toFixed(2));
}
function moneyChain(value) {
return Math.abs(value) >= 1e8 ? moneyShort(value) : money(value);
}
function rateFraction() {
return state.rate / 100;
}
function effectiveAnnual() {
if (isTrade()) return null;
return Math.pow(1 + rateFraction(), state.freq) - 1;
}
function buildBalances() {
const r = rateFraction();
const extra = state.extra;
const capPeriods = isTrade() ? CAP_TRADES : CAP_YEARS * state.freq;
const balances = new Array(capPeriods + 1);
balances[0] = state.start;
for (let k = 1; k <= capPeriods; k += 1) {
balances[k] = balances[k - 1] * (1 + r) + extra;
}
return balances;
}
function crossAt(balances, target) {
if (balances[0] >= target) return { periods: 0, years: 0 };
const last = balances[balances.length - 1];
if (last < target) return null;
let lo = 1;
let hi = balances.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (balances[mid] >= target) hi = mid;
else lo = mid + 1;
}
const prev = balances[lo - 1];
const cur = balances[lo];
const frac = cur > prev && prev > 0
? clamp(Math.log(target / prev) / Math.log(cur / prev), 0, 1)
: 1;
const periods = lo - 1 + frac;
const f = isTrade() ? 1 : state.freq;
return { periods, years: periods / f };
}
function getData() {
const trade = isTrade();
const f = trade ? 1 : state.freq;
const years = trade ? state.trades : state.years;
const balances = buildBalances();
const horizonPeriods = Math.round(years * f);
const end = balances[horizonPeriods];
const yearRows = [];
for (let y = 1; y <= years; y += 1) {
const startBal = balances[(y - 1) * f];
const endBal = balances[y * f];
yearRows.push({
year: y,
start: startBal,
end: endBal,
interest: endBal - startBal - state.extra * f,
extra: state.extra * f,
multiple: endBal / state.start
});
}
const milestones = MILESTONES.map(amount => {
const cross = crossAt(balances, amount);
return {
amount,
cross,
years: cross ? cross.years : null,
periods: cross ? cross.periods : null,
multiple: amount / state.start
};
});
const targetCross = crossAt(balances, state.target);
const totalIn = state.start + state.extra * horizonPeriods;
const rate = rateFraction();
return {
f,
trade,
units: years,
years,
start: state.start,
extra: state.extra,
rate,
balances,
horizonPeriods,
end,
yearRows,
milestones,
targetCross,
totalIn,
gains: end - totalIn,
effective: effectiveAnnual(),
multiple: end / state.start,
doubleYears: rate > 0 ? (Math.LN2 / Math.log(1 + rate)) / f : null
};
}
function setRangeFill(element) {
if (!element) return;
const max = Number(element.max) || 100;
const value = Number(element.value) || 0;
element.style.setProperty("--fill", clamp((value / max) * 100, 0, 100).toFixed(2) + "%");
}
function setValueIfIdle(element, value) {
if (!element) return;
if (document.activeElement !== element) element.value = value;
}
function svgEl(tag, attrs = {}, parent = null) {
const element = document.createElementNS(svgNS, tag);
Object.entries(attrs).forEach(([key, value]) => {
if (value !== undefined && value !== null) element.setAttribute(key, value);
});
if (parent) parent.appendChild(element);
return element;
}
function svgText(parent, x, y, text, options = {}) {
const element = svgEl("text", {
x,
y,
"text-anchor": options.anchor || "start",
"dominant-baseline": options.baseline || "middle",
"font-size": options.size || 11,
"font-weight": options.weight || 400,
fill: options.fill || "#7a837c",
"letter-spacing": options.spacing || "0"
}, parent);
element.textContent = text;
return element;
}
function linePath(points) {
return points
.map((point, index) => (index === 0 ? "M" : "L") + point.x.toFixed(2) + " " + point.y.toFixed(2))
.join(" ");
}
function logTicks(min, max) {
const kStart = Math.floor(Math.log10(min));
const kEnd = Math.ceil(Math.log10(max));
const mults = (kEnd - kStart) >= 4 ? [1] : [1, 2, 5];
const out = [];
for (let k = kStart; k <= kEnd; k += 1) {
mults.forEach(mult => {
const value = mult * Math.pow(10, k);
if (value >= min * 0.999 && value <= max * 1.001) out.push(value);
});
}
if (out.length > 7) {
const stride = Math.ceil(out.length / 6);
const kept = [];
for (let i = 0; i < out.length; i += stride) kept.push(out[i]);
return kept;
}
return out;
}
function niceLinearScale(max, targetCount = 4) {
if (!Number.isFinite(max) || max <= 0) return { top: 1, ticks: [0, 1] };
const raw = max / targetCount;
const magnitude = Math.pow(10, Math.floor(Math.log10(raw)));
const normalized = raw / magnitude;
const factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10;
const step = factor * magnitude;
const top = Math.ceil(max / step) * step;
const ticks = [];
for (let value = 0; value <= top + step * 1e-9; value += step) ticks.push(value);
return { top, ticks };
}
function addTag(parent, geom, cx, cy, text, color, above = true) {
const chars = Array.from(text);
const width = chars.reduce((sum, ch) => sum + (ch.charCodeAt(0) > 255 ? 10 : 6), 0) + 16;
const height = 20;
const x = clamp(cx - width / 2, geom.margin.left + 3, geom.margin.left + geom.iw - width - 3);
let y = above ? cy - height - 8 : cy + 8;
if (y < geom.margin.top + 3) y = cy + 8;
if (y + height > geom.margin.top + geom.ih - 3) y = cy - height - 8;
y = clamp(y, geom.margin.top + 3, geom.margin.top + geom.ih - height - 3);
const group = svgEl("g", {}, parent);
svgEl("rect", {
x,
y,
width,
height,
rx: 6,
fill: "#fffdf8",
stroke: color,
"stroke-opacity": 0.34,
"stroke-width": 1
}, group);
svgText(group, x + 8, y + height / 2 + 0.5, text, {
size: 10.5,
weight: 650,
fill: color
});
return group;
}
function renderHero(data) {
const trade = isTrade();
const gaugePct = trade ? state.rate : data.effective * 100;
const millionCross = crossAt(data.balances, 1000000);
refs.annualLabel.textContent = trade ? "每次复利" : "年化复利";
refs.annualValue.textContent = gaugePct >= 100
? nf0.format(Math.round(gaugePct))
: trimNum(gaugePct.toFixed(1));
const position = clamp((gaugePct / 120) * 100, 1.5, 98.5);
refs.gaugeTrack.style.setProperty("--risk-pos", position.toFixed(2) + "%");
refs.gaugeMarker.style.left = position.toFixed(2) + "%";
let level = "low";
let label = "平衡";
if (trade) {
if (gaugePct >= 20) { level = "high"; label = "极激进"; }
else if (gaugePct >= 10) { level = "high"; label = "激进"; }
else if (gaugePct >= 5) { level = "mid"; label = "进取"; }
else if (gaugePct >= 2) { level = "low"; label = "平衡"; }
else { level = "low"; label = "稳健"; }
} else {
if (gaugePct >= 60) { level = "high"; label = "极激进"; }
else if (gaugePct >= 30) { level = "high"; label = "激进"; }
else if (gaugePct >= 12) { level = "mid"; label = "进取"; }
else if (gaugePct >= 5) { level = "low"; label = "平衡"; }
else { level = "low"; label = "稳健"; }
}
refs.rateBadge.dataset.level = level;
refs.rateBadge.textContent = label;
if (state.rate <= 0) {
refs.annualCaption.textContent = trade
? "每次收益 0:做再多次,复利也不会启动。"
: (state.extra > 0
? "收益率 0:只靠每期追加,资金线性增长,没有利滚利。"
: "收益率 0:复利不会产生任何增长。把收益率调大一点试试。");
} else if (trade) {
refs.annualCaption.textContent = "每次 " + pctText(rateFraction(), 1) + ":约 " +
yearsText(data.doubleYears) + "翻一倍;到 100 万需要 " +
(millionCross ? yearsText(millionCross.years) : "1800 次以上") + "。";
} else {
refs.annualCaption.textContent = "每期 " + pctText(rateFraction(), 1) + "、每年 " + state.freq +
" 期:年化 " + pctText(data.effective, 1) + ",约 " + yearsText(data.doubleYears) + "翻一倍。";
}
refs.rateFormula.innerHTML = trade
? "每次 <b>" + pctText(rateFraction(), 2) + "</b> × <b>" + state.trades +
" 次</b> → 期末 <b>" + moneyShort(data.end) + "</b>"
: "每期 <b>" + pctText(rateFraction(), 2) + "</b> × 每年 <b>" +
state.freq + " 期</b> → 年化 <b>" + pctText(data.effective, 1) + "</b>";
if (data.doubleYears == null) {
refs.doubleBadge.innerHTML = "收益率 0:<b>不会翻倍</b>";
} else if (trade) {
refs.doubleBadge.innerHTML = "翻倍需要 <b>" + yearsText(data.doubleYears) +
"</b>(第 " + Math.ceil(data.doubleYears) + " 次后)";
} else {
refs.doubleBadge.innerHTML = "翻倍需要 <b>" + yearsText(data.doubleYears) + "</b>(约 " +
periodsText(data.doubleYears * state.freq) + ")";
}
refs.factRateLabel.textContent = trade ? "每次收益" : "每期收益";
refs.factRate.textContent = pctText(rateFraction(), 1);
refs.factFreqLabel.textContent = trade ? "计算次数" : "每年期数";
refs.factFreq.textContent = trade ? state.trades + " 次" : state.freq + " 期";
refs.factTarget.textContent = millionCross
? yearsText(millionCross.years)
: (trade ? "1800 次+" : "150 年+");
}
function renderKpis(data) {
const ratio = value => value / state.start;
function fillMilestone(prefix, amount) {
const cross = crossAt(data.balances, amount);
const valueEl = refs[prefix];
const text = cross ? yearsText(cross.years) : "做不到";
const trade = isTrade();
valueEl.textContent = text;
valueEl.classList.toggle("is-long", text.length > 5);
refs[prefix + "Meta"].textContent = cross
? "第 " + nf0.format(Math.ceil(cross.periods)) + " " + countName()
: (trade ? "1800 次内" : "150 年内");
refs[prefix + "Sub"].textContent = cross
? (trade ? "本金 " + multipleText(ratio(amount)) : "约 " + (NOW_YEAR + Math.ceil(cross.years)) + " 年")
: "需要更高收益";
if (!cross) {
refs[prefix + "Foot"].textContent = trade ? "1800 次内无法达成" : "150 年内无法达成";
} else if (ratio(amount) >= 1) {
const doublings = Math.log2(ratio(amount));
refs[prefix + "Foot"].textContent = (trade ? "" : multipleText(ratio(amount)) + " · ") +
trimNum(doublings.toFixed(1)) + " 次翻倍" +
(trade ? " · 每次 +" + pctText(rateFraction(), 1) : "");
} else {
refs[prefix + "Foot"].textContent = "起始本金已高于目标";
}
}
fillMilestone("kpi1m", 1000000);
fillMilestone("kpi10m", 10000000);
fillMilestone("kpi100m", 100000000);
refs.kpiEndHead.textContent = isTrade()
? "第 " + state.trades + " 次后"
: "第 " + state.years + " 年末";
refs.kpiEnd.textContent = moneyShort(data.end);
refs.kpiEndMeta.textContent = multipleText(data.multiple);
refs.kpiEndSub.textContent = isTrade()
? "每次 +" + pctText(rateFraction(), 1)
: "第 " + state.years + " 年";
refs.kpiEndFoot.textContent = "复利收益 " + moneyShort(Math.max(0, data.gains));
}
function renderStrip(data) {
const base = data.totalIn;
const gains = Math.max(0, data.gains);
const basePct = data.end > 0 ? clamp(base / data.end, 0, 1) : 1;
const gainPct = 1 - basePct;
refs.stripBase.textContent = moneyShort(base);
refs.stripGainAmount.textContent = moneyShort(gains);
refs.stripPrincipalBar.style.width = (basePct * 100).toFixed(2) + "%";
refs.stripGainPct.textContent = pctText(gainPct, 1);
refs.stripEnd.textContent = moneyShort(data.end);
refs.stripText.textContent = (isTrade()
? "第 " + state.trades + " 次后,账户 "
: "第 " + state.years + " 年末,账户 ") + moneyShort(data.end) +
":本金占 " + pctText(basePct, 1) + ",复利收益占 " + pctText(gainPct, 1) + "。";
}
function renderCurveHeader(data) {
const trade = isTrade();
const unit = trade ? "次" : "年";
const every = trade ? "每次" : "每期";
refs.curveYearlyTab.textContent = trade ? "每次收益" : "每年收益";
if (state.ctab === "yearly") {
refs.curveTitle.textContent = trade ? "每次赚到的钱:复利收益" : "每年赚到的钱:复利收益";
if (state.rate <= 0) {
refs.curveSubtitle.textContent = state.extra > 0
? every + " 0%,但" + every + "追加 " + moneyShort(state.extra) + ":柱状图只显示追加投入。"
: every + " 0%:没有复利收益,也没有加速段。";
refs.curveNote.textContent = state.extra > 0
? "没有收益率时,资金只能靠追加变多,不会利滚利。"
: "把" + every + "收益率调大,或设置" + every + "追加投入,才能看到资金增长。";
} else {
const first = data.yearRows[0] ? Math.max(0, data.yearRows[0].interest + data.yearRows[0].extra) : 0;
const lastRow = data.yearRows[data.yearRows.length - 1];
const last = lastRow ? Math.max(0, lastRow.interest + lastRow.extra) : 0;
refs.curveSubtitle.textContent = "第 1 " + unit + " " + moneyShort(first) + " → 第 " + data.units +
" " + unit + " " + moneyShort(last) + " · 同一个 " + pctText(rateFraction(), 1) + ",绝对金额越来越大";
refs.curveNote.textContent = "复利的加速段在后期:越往后,一" + unit + "的收益越大。";
}
refs.curveLegend.innerHTML = '<span><i class="legend-line"></i>本' + unit + '复利收益</span>' +
(state.extra > 0 ? '<span><i class="legend-line target"></i>追加投入</span>' : "");
return;
}
refs.curveTitle.textContent = "复利曲线:从 " + moneyShort(state.start) + "到 " + moneyShort(data.end);
refs.curveSubtitle.textContent = "第 " + data.units + " " + unit + " · " +
(trade ? "每次 " + pctText(rateFraction(), 1) : "年化 " + pctText(data.effective, 1)) +
" · 纵轴" + (state.scale === "log" ? "对数刻度(每格 10 倍)" : "线性刻度(前期会被压扁)");
if (state.rate <= 0) {
refs.curveNote.textContent = state.extra > 0
? "收益率为 0,增长只来自" + every + "追加;没有利滚利。"
: "收益率为 0,曲线是一条水平线;提升收益率才能启动复利。";
} else {
refs.curveNote.textContent = state.scale === "log"
? "对数刻度下,10 倍、100 倍、1000 倍的差距被压缩成等距;曲线越平,滚得越慢。"
: "线性刻度更直观,但后期金额会把前期压成一条平线——这正是复利的形状。";
}
refs.curveLegend.innerHTML = '<span><i class="legend-line"></i>账户金额</span>' +
'<span><i class="legend-line target"></i>目标 ' + moneyShort(state.target) + '</span>';
}
function renderCurve(data) {
const wrap = refs.curveWrap;
const svg = refs.curveSvg;
if (!wrap || !svg) return;
const width = Math.max(300, Math.floor(wrap.clientWidth || 760));
const narrow = width < 600;
const isYearly = state.ctab === "yearly";
const height = isYearly ? (narrow ? 330 : 410) : (narrow ? 400 : 540);
const margin = {
left: narrow ? 46 : 66,
right: narrow ? 14 : 26,
top: narrow ? 26 : 32,
bottom: narrow ? 42 : 48
};
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const years = data.units;
const target = state.target;
const unit = data.trade ? "次" : "年";
svg.setAttribute("viewBox", "0 0 " + width + " " + height);
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.setAttribute("aria-label", isYearly
? (data.trade ? "每次复利收益柱状图" : "每年复利收益柱状图")
: (data.trade ? "按交易次数计算的复利增长曲线图" : "复利增长曲线图"));
svg.innerHTML = "";
hideCurveTip();
const defs = svgEl("defs", {}, svg);
const clip = svgEl("clipPath", { id: "curveClip" }, defs);
svgEl("rect", { x: margin.left, y: margin.top, width: iw, height: ih }, clip);
const gradient = svgEl("linearGradient", { id: "curveFill", x1: "0", y1: "0", x2: "0", y2: "1" }, defs);
svgEl("stop", { offset: "0%", "stop-color": "#0f5c47", "stop-opacity": ".2" }, gradient);
svgEl("stop", { offset: "100%", "stop-color": "#0f5c47", "stop-opacity": ".01" }, gradient);
const plot = svgEl("g", { "clip-path": "url(#curveClip)" }, svg);
svgEl("rect", { x: margin.left, y: margin.top, width: iw, height: ih, rx: 9, fill: "#faf7ef" }, plot);
const step = years <= 12 ? 2 : years <= 30 ? 5 : years <= 60 ? 10 :
years <= 120 ? 20 : years <= 300 ? 50 : 100;
const xTicks = [];
for (let t = 0; t <= years; t += step) xTicks.push(t);
if (xTicks[xTicks.length - 1] !== years) xTicks.push(years);
const drawXAxis = xPos => {
xTicks.forEach(t => {
const px = xPos(t);
svgEl("line", {
x1: px, x2: px, y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.045)", "stroke-width": 1
}, plot);
svgText(svg, px, margin.top + ih + 19, String(t), {
anchor: "middle", size: narrow ? 9.5 : 10.5, fill: "#8a928b"
});
});
};
if (isYearly) {
const interests = data.yearRows.map(row => Math.max(0, row.interest));
const extras = data.yearRows.map(row => Math.max(0, row.extra));
const totals = interests.map((value, index) => value + extras[index]);
const maxGain = Math.max(0, ...totals);
const slot = iw / years;
if (maxGain <= 0) {
svgText(svg, margin.left + iw / 2, margin.top + ih / 2, "收益率为 0:没有可展示的复利收益", {
anchor: "middle", size: 11, fill: "#8a928b"
});
curveGeom = null;
return;
}
const scale = niceLinearScale(maxGain, 4);
const yPos = value => margin.top + ih - (value / scale.top) * ih;
scale.ticks.forEach((value, index) => {
const y = yPos(value);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: y, y2: y,
stroke: "rgba(24,35,29,.07)", "stroke-width": index === 0 ? 1.2 : 1
}, plot);
svgText(svg, margin.left - 9, y + 0.5, numShort(value), {
anchor: "end", size: narrow ? 9 : 10, fill: "#8a928b"
});
});
const barWidth = Math.max(2, Math.min(34, slot * 0.6));
data.yearRows.forEach((row, index) => {
const x = margin.left + (index + 0.5) * slot - barWidth / 2;
const hInterest = Math.max(0, (Math.max(0, row.interest) / scale.top) * ih);
const hExtra = Math.max(0, (Math.max(0, row.extra) / scale.top) * ih);
if (hInterest > 0.3) {
svgEl("rect", {
x, y: margin.top + ih - hInterest, width: barWidth, height: hInterest,
rx: Math.min(2.5, barWidth / 3), fill: "#1f7a5e", opacity: 0.9
}, plot);
}
if (hExtra > 0.3) {
svgEl("rect", {
x, y: margin.top + ih - hInterest - hExtra, width: barWidth, height: hExtra,
rx: Math.min(2.5, barWidth / 3), fill: "#b28733", opacity: 0.9
}, plot);
}
});
drawXAxis(t => margin.left + (t / years) * iw);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: margin.top + ih, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgEl("line", {
x1: margin.left, x2: margin.left, y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgText(svg, margin.left, margin.top - 12, data.trade ? "本次收益" : "本年收益", {
size: 10, fill: "#7a837c", weight: 600
});
svgText(svg, margin.left + iw / 2, height - 10, data.trade ? "交易次数" : "持有年数", {
anchor: "middle", size: 10, fill: "#7a837c"
});
const hoverGroup = svgEl("g", { opacity: 0 }, svg);
const hoverLine = svgEl("line", {
y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.4)", "stroke-dasharray": "4 4", "stroke-width": 1
}, hoverGroup);
const hoverDot = svgEl("circle", {
r: 4.5, fill: "#1f7a5e", stroke: "#fffdf8", "stroke-width": 2
}, hoverGroup);
curveGeom = {
mode: "yearly",
width, height, margin, iw, ih, years, yPos, slot, data,
hoverGroup, hoverLine, hoverDot
};
return;
}
const yearBalances = [];
for (let y = 0; y <= years; y += 1) yearBalances.push(data.balances[y * data.f]);
const maxValue = Math.max(data.end, target, data.start);
let yMin;
let yMax;
let ticks;
if (state.scale === "log") {
yMin = Math.max(1, data.start * 0.8);
yMax = Math.max(maxValue * 1.45, yMin * 10);
ticks = logTicks(yMin, yMax);
} else {
const scale = niceLinearScale(maxValue, 4);
yMin = 0;
yMax = scale.top;
ticks = scale.ticks;
}
const logSpan = Math.log10(yMax) - Math.log10(yMin);
const yPos = state.scale === "log"
? value => margin.top + ((Math.log10(yMax) - Math.log10(Math.max(value, yMin))) / logSpan) * ih
: value => margin.top + ih - ((value - yMin) / (yMax - yMin)) * ih;
const xPos = t => margin.left + (t / years) * iw;
ticks.forEach(value => {
const y = yPos(value);
if (!Number.isFinite(y)) return;
const label = state.scale === "log" && yMax >= 1e13 ? sciText(value) : numShort(value);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: y, y2: y,
stroke: "rgba(24,35,29,.07)", "stroke-width": 1
}, plot);
svgText(svg, margin.left - 9, y + 0.5, label, {
anchor: "end", size: narrow ? 9 : 10, fill: "#8a928b"
});
});
[1000000, 10000000, 100000000].forEach(value => {
if (value === target) return;
if (value < yMin || value > yMax) return;
const y = yPos(value);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: y, y2: y,
stroke: "rgba(178,135,51,.22)", "stroke-dasharray": "3 6", "stroke-width": 1
}, plot);
});
if (target >= yMin && target <= yMax) {
const y = yPos(target);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: y, y2: y,
stroke: "rgba(178,135,51,.56)", "stroke-dasharray": "4 5", "stroke-width": 1.2
}, plot);
svgText(svg, margin.left + iw - 7, y - 8, "目标 " + moneyShort(target), {
anchor: "end", size: 9.5, fill: "#a17b2d", weight: 600
});
}
const points = yearBalances.map((value, year) => ({ x: xPos(year), y: yPos(value) }));
const areaPath = points
.map((point, index) => (index === 0 ? "M" : "L") + point.x.toFixed(2) + " " + point.y.toFixed(2))
.join(" ") +
" L" + xPos(years).toFixed(2) + " " + (margin.top + ih).toFixed(2) +
" L" + xPos(0).toFixed(2) + " " + (margin.top + ih).toFixed(2) + " Z";
svgEl("path", { d: areaPath, fill: "url(#curveFill)", stroke: "none" }, plot);
svgEl("path", {
d: linePath(points),
fill: "none",
stroke: "#0f5c47",
"stroke-width": 2.6,
"stroke-linecap": "round",
"stroke-linejoin": "round"
}, plot);
[1000000, 10000000, 100000000].forEach(value => {
const cross = crossAt(data.balances, value);
if (!cross || cross.years > years || value < yMin || value > yMax) return;
svgEl("circle", {
cx: xPos(cross.years),
cy: yPos(value),
r: value === target ? 4.6 : 3.4,
fill: value === target ? "#b28733" : "#fffdf8",
stroke: value === target ? "#fffdf8" : "#b28733",
"stroke-width": 2
}, plot);
});
const targetCross = crossAt(data.balances, target);
const geom = { margin, iw, ih, width, height };
if (targetCross && targetCross.years <= years && target >= yMin && target <= yMax) {
const cx = xPos(targetCross.years);
svgEl("line", {
x1: cx, x2: cx, y1: yPos(target), y2: margin.top + ih,
stroke: "rgba(178,135,51,.34)", "stroke-dasharray": "3 5", "stroke-width": 1
}, plot);
addTag(svg, geom, cx, yPos(target), yearsText(targetCross.years) + " · " + moneyShort(target), "#a17b2d", false);
}
drawXAxis(xPos);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: margin.top + ih, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgEl("line", {
x1: margin.left, x2: margin.left, y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgEl("circle", {
cx: xPos(0), cy: yPos(data.start), r: 4,
fill: "#0f5c47", stroke: "#fffdf8", "stroke-width": 2
}, plot);
svgText(svg, margin.left, margin.top - 12, state.scale === "log" ? "账户金额 · 对数" : "账户金额", {
size: 10, fill: "#7a837c", weight: 600
});
svgText(svg, margin.left + iw / 2, height - 10, data.trade ? "交易次数" : "持有年数", {
anchor: "middle", size: 10, fill: "#7a837c"
});
const hoverGroup = svgEl("g", { opacity: 0 }, svg);
const hoverLine = svgEl("line", {
y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.38)", "stroke-dasharray": "4 4", "stroke-width": 1
}, hoverGroup);
const hoverDot = svgEl("circle", {
r: 4.6, fill: "#0f5c47", stroke: "#fffdf8", "stroke-width": 2
}, hoverGroup);
curveGeom = {
mode: "curve",
width, height, margin, iw, ih, years, xPos, yPos, data,
hoverGroup, hoverLine, hoverDot
};
}
function hideCurveTip() {
if (curveGeom) curveGeom.hoverGroup.setAttribute("opacity", "0");
refs.curveTip.hidden = true;
}
function onCurveMove(event) {
const geom = curveGeom;
if (!geom) return;
const rect = refs.curveSvg.getBoundingClientRect();
if (!rect.width) return;
const localX = (event.clientX - rect.left) * (geom.width / rect.width);
if (localX < geom.margin.left - 6 || localX > geom.margin.left + geom.iw + 6) {
hideCurveTip();
return;
}
const data = geom.data;
const f = data.f;
let year;
let px;
let py;
if (geom.mode === "curve") {
year = clamp(Math.round(((localX - geom.margin.left) / geom.iw) * geom.years), 0, geom.years);
px = geom.xPos(year);
py = geom.yPos(data.balances[year * f]);
geom.hoverDot.setAttribute("visibility", "visible");
geom.hoverDot.setAttribute("cx", px);
geom.hoverDot.setAttribute("cy", py);
} else {
year = clamp(Math.floor((localX - geom.margin.left) / geom.slot) + 1, 1, geom.years);
px = geom.margin.left + (year - 0.5) * geom.slot;
const row = data.yearRows[year - 1];
py = geom.yPos(Math.max(0, row.interest + row.extra));
geom.hoverDot.setAttribute("visibility", "hidden");
}
geom.hoverGroup.setAttribute("opacity", "1");
geom.hoverLine.setAttribute("x1", px);
geom.hoverLine.setAttribute("x2", px);
const balance = data.balances[year * f];
const previous = year > 0 ? data.balances[(year - 1) * f] : data.start;
const interest = Math.max(0, balance - previous - state.extra * f);
const invested = state.start + state.extra * year * f;
const distance = state.target - balance;
const unit = data.trade ? "次" : "年";
refs.curveTip.innerHTML =
'<div class="tt-head"><span>第 ' + year + ' ' + unit + '</span><b>' + moneyShort(balance) + '</b></div>' +
'<div class="tt-row"><i class="tt-dot current"></i><span>本' + unit + '复利收益</span><b>+' + moneyShort(interest) + '</b></div>' +
'<div class="tt-row"><i class="tt-dot base"></i><span>累计投入</span><b>' + moneyShort(invested) + '</b></div>' +
'<div class="tt-sep"></div>' +
'<div class="tt-row plain"><span>累计倍数</span><b>' + multipleText(balance / state.start) + '</b></div>' +
'<div class="tt-row plain"><span>距目标 ' + moneyShort(state.target) + '</span><b>' +
(distance > 0 ? moneyShort(distance) : "已达成") + '</b></div>';
refs.curveTip.hidden = false;
const tipWidth = refs.curveTip.offsetWidth || 224;
const tipHeight = refs.curveTip.offsetHeight || 120;
let left = localX + 16;
if (left + tipWidth > geom.width - 6) left = localX - tipWidth - 16;
left = clamp(left, 6, geom.width - tipWidth - 6);
let top = py - tipHeight / 2;
top = clamp(top, 6, geom.height - tipHeight - 6);
refs.curveTip.style.left = left + "px";
refs.curveTip.style.top = top + "px";
}
function renderCompareHeader(data) {
const trade = isTrade();
refs.compareBadge.textContent = "目标 " + moneyShort(state.target);
refs.compareTitle.textContent = trade
? "每次 5%–30%,多少次到 " + moneyShort(state.target)
: "年化 5%–30%,多久到 " + moneyShort(state.target);
refs.compareSubtitle.textContent = trade
? "同一笔 " + moneyShort(state.start) + " 本金 · 只数交易次数、不看时间 · 纵轴对数刻度"
: "同一笔 " + moneyShort(state.start) + " 本金 · 对比曲线不含追加 · 纵轴对数刻度";
}
function renderCompare(data) {
const wrap = refs.compareWrap;
const svg = refs.compareSvg;
if (!wrap || !svg) return;
const width = Math.max(300, Math.floor(wrap.clientWidth || 760));
const narrow = width < 600;
const height = narrow ? 372 : 440;
const margin = {
left: narrow ? 46 : 66,
right: narrow ? 16 : 30,
top: 30,
bottom: narrow ? 42 : 48
};
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const start = state.start;
const target = state.target;
const trade = isTrade();
const ratePrefix = trade ? "每次" : "年化";
const unit = trade ? "次" : "年";
const cap = trade ? CAP_TRADES : CAP_YEARS;
const rates = COMPARE_RATES.map((rate, index) => ({
rate,
color: RATE_COLORS[index],
cross: start >= target ? 0 : (rate > 0 ? Math.log(target / start) / Math.log(1 + rate / 100) : null)
}));
const validCross = rates.map(item => item.cross).filter(value => value != null && value <= cap);
let maxX = 40;
if (validCross.length) maxX = Math.ceil(Math.max(...validCross)) + 1;
if (data.targetCross && data.targetCross.years <= cap) {
maxX = Math.max(maxX, Math.ceil(data.targetCross.years) + 1);
}
maxX = clamp(maxX, 8, cap);
const yMin = Math.max(1, start * 0.8);
const yMax = Math.max(target * 2.4, start * 2.2);
const logSpan = Math.log10(yMax) - Math.log10(yMin);
const yPos = value => margin.top + ((Math.log10(yMax) - Math.log10(Math.max(value, yMin))) / logSpan) * ih;
const xPos = t => margin.left + (t / maxX) * iw;
svg.setAttribute("viewBox", "0 0 " + width + " " + height);
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.innerHTML = "";
const defs = svgEl("defs", {}, svg);
const clip = svgEl("clipPath", { id: "compareClip" }, defs);
svgEl("rect", { x: margin.left, y: margin.top, width: iw, height: ih }, clip);
const plot = svgEl("g", { "clip-path": "url(#compareClip)" }, svg);
svgEl("rect", { x: margin.left, y: margin.top, width: iw, height: ih, rx: 9, fill: "#faf7ef" }, plot);
logTicks(yMin, yMax).forEach(value => {
const y = yPos(value);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: y, y2: y,
stroke: "rgba(24,35,29,.07)", "stroke-width": 1
}, plot);
svgText(svg, margin.left - 9, y + 0.5, numShort(value), {
anchor: "end", size: narrow ? 9 : 10, fill: "#8a928b"
});
});
const xStep = maxX <= 12 ? 2 : maxX <= 30 ? 5 : maxX <= 60 ? 10 :
maxX <= 120 ? 20 : maxX <= 300 ? 50 : 100;
const xValues = [];
for (let t = 0; t <= maxX; t += xStep) xValues.push(t);
if (xValues[xValues.length - 1] !== maxX && maxX - xValues[xValues.length - 1] >= xStep * 0.6) {
xValues.push(maxX);
}
xValues.forEach(t => {
const px = xPos(t);
svgEl("line", {
x1: px, x2: px, y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.045)", "stroke-width": 1
}, plot);
svgText(svg, px, margin.top + ih + 19, String(t), {
anchor: "middle", size: narrow ? 9 : 10, fill: "#8a928b"
});
});
const targetY = yPos(target);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: targetY, y2: targetY,
stroke: "rgba(178,135,51,.55)", "stroke-dasharray": "4 5", "stroke-width": 1.2
}, plot);
svgText(svg, margin.left + iw * 0.93, targetY - 8, "目标 " + moneyShort(target), {
anchor: "end", size: 9.5, fill: "#a17b2d", weight: 600
});
rates.forEach((item, index) => {
const points = [];
for (let t = 0; t <= maxX; t += 1) {
points.push({ x: xPos(t), y: yPos(start * Math.pow(1 + item.rate / 100, t)) });
}
svgEl("path", {
d: linePath(points),
fill: "none",
stroke: item.color,
"stroke-width": 1.8,
"stroke-linecap": "round",
opacity: 0.9
}, plot);
if (item.cross != null && item.cross <= maxX) {
const cx = xPos(item.cross);
const group = svgEl("g", {}, plot);
svgEl("circle", {
cx, cy: targetY, r: 4,
fill: item.color, stroke: "#fffdf8", "stroke-width": 1.8
}, group);
const title = svgEl("title", {}, group);
title.textContent = ratePrefix + " " + item.rate + "%:" + yearsText(item.cross) + " 到 " + moneyShort(target);
const down = index % 2 === 0;
svgText(plot, cx, targetY + (down ? 16 : -11), trimNum(item.cross.toFixed(1)) + unit, {
anchor: cx > margin.left + iw - 30 ? "end" : "middle",
size: 9.5,
weight: 650,
fill: item.color
});
}
});
const userRatePct = trade ? state.rate : data.effective * 100;
const showUser = state.extra > 0 ||
!rates.some(item => Math.abs(item.rate - userRatePct) < 0.15);
if (showUser) {
const points = [];
for (let t = 0; t <= maxX; t += 1) {
const index = Math.min(t * data.f, data.balances.length - 1);
points.push({ x: xPos(t), y: yPos(data.balances[index]) });
}
svgEl("path", {
d: linePath(points),
fill: "none",
stroke: "#18231d",
"stroke-width": 3,
"stroke-linecap": "round"
}, plot);
if (data.targetCross && data.targetCross.years <= maxX) {
const cx = xPos(data.targetCross.years);
const group = svgEl("g", {}, plot);
svgEl("circle", {
cx, cy: targetY, r: 5.5,
fill: "#18231d", stroke: "#fffdf8", "stroke-width": 2
}, group);
const title = svgEl("title", {}, group);
title.textContent = "你的方案:" + yearsText(data.targetCross.years) + " 到 " + moneyShort(target);
}
}
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: margin.top + ih, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgEl("line", {
x1: margin.left, x2: margin.left, y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgText(svg, margin.left, margin.top - 12, "账户金额 · 对数", {
size: 10, fill: "#7a837c", weight: 600
});
svgText(svg, margin.left + iw / 2, height - 10,
trade ? "到达目标所需交易次数" : "到达目标所需年数", {
anchor: "middle", size: 10, fill: "#7a837c"
});
const fallback = trade ? "1800 次+" : "150 年+";
const chips = rates.map(item => {
const span = item.cross == null || item.cross > cap ? fallback : yearsText(item.cross);
return '<span class="legend-chip"><i style="background:' + item.color + '"></i>' + ratePrefix + ' ' +
item.rate + '%<b>' + span + '</b></span>';
});
if (showUser) {
chips.push('<span class="legend-chip you"><i style="background:#18231d"></i>你的方案 · ' +
ratePrefix + ' ' + pctText(trade ? rateFraction() : data.effective, 1) + '<b>' +
(data.targetCross ? yearsText(data.targetCross.years) : fallback) + '</b></span>');
}
refs.compareLegend.innerHTML = chips.join("");
const rate20 = rates.find(item => item.rate === 20);
const rate5 = rates.find(item => item.rate === 5);
refs.compareNote.textContent = ratePrefix + " 20% 到 " + moneyShort(target) + " 需要 " +
(rate20 && rate20.cross != null && rate20.cross <= cap ? yearsText(rate20.cross) : "超出显示范围") +
";" + ratePrefix + " 5% 需要 " +
(rate5 && rate5.cross != null && rate5.cross <= cap ? yearsText(rate5.cross) : "超出显示范围") +
"。收益率只提高一点," + (trade ? "交易次数" : "到达时间") + "会明显缩短。";
}
function renderLadder(data) {
const trade = isTrade();
const startLog = Math.log10(Math.max(1, state.start));
const endLog = Math.log10(100000000);
const span = endLog - startLog;
refs.ladderTitle.textContent = trade
? "到每一个里程碑,需要多少次"
: "到每一个里程碑,需要多久";
refs.milestoneLadder.innerHTML = data.milestones.map((item, index) => {
const isTarget = item.amount === state.target;
const isHit = item.years != null && item.years <= data.units;
const ratio = item.amount / state.start;
const doublings = ratio >= 1 ? trimNum(Math.log2(ratio).toFixed(1)) + " 次翻倍" : "起点已高于此";
const barWidth = state.start >= 100000000
? 100
: clamp(((Math.log10(item.amount) - startLog) / span) * 100, 3, 100);
const classes = ["ms-row"];
if (isTarget) classes.push("is-target");
else if (isHit) classes.push("is-hit");
const firstMetric = trade
? '<div class="ladder-metric ms-years"><span>所需次数</span><b class="' +
(item.years != null ? "good" : "") + '">' + yearsText(item.years) + '</b><small>' +
(item.periods != null ? "第 " + nf0.format(Math.ceil(item.periods)) + " 次后" : "1800 次内") + '</small></div>'
: '<div class="ladder-metric ms-years"><span>所需年数</span><b class="' +
(item.years != null ? "good" : "") + '">' + yearsText(item.years) + '</b><small>' +
(item.periods != null ? "第 " + nf0.format(Math.ceil(item.periods)) + " 期后" : "150 年内") + '</small></div>';
const secondMetric = trade
? '<div class="ladder-metric ms-periods"><span>每次收益</span><b>' +
pctText(rateFraction(), 2) + '</b></div>'
: '<div class="ladder-metric ms-periods"><span>所需期数</span><b>' + periodsText(item.periods) + '</b></div>';
return '<div class="' + classes.join(" ") + '">' +
'<div class="ladder-no">' + String(index + 1).padStart(2, "0") + '</div>' +
'<div class="ladder-name"><strong>' + moneyShort(item.amount) + '</strong><small>' +
multipleText(ratio) + " · " + doublings + '</small></div>' +
firstMetric +
secondMetric +
'<div class="ladder-metric ms-amount"><span>达成时账户</span><b>' + money(item.amount) + '</b></div>' +
'<div class="ladder-metric ms-multiple"><span>相对本金</span><b>' + multipleText(ratio) + '</b></div>' +
'<div class="ladder-track"><i style="width:' + barWidth.toFixed(2) + '%"></i></div>' +
'</div>';
}).join("");
const targetCross = data.targetCross;
refs.ladderBadge.textContent = targetCross
? "目标 " + moneyShort(state.target) + " · " + yearsText(targetCross.years)
: "目标 " + moneyShort(state.target) + " · " + (trade ? "1800 次+" : "150 年+");
refs.ladderSubtitle.textContent = trade
? "每次 " + pctText(rateFraction(), 2) + " · 只数交易次数 · 从 " + moneyShort(state.start) +
" 出发;绿色为当前目标。"
: "每期 " + pctText(rateFraction(), 2) + " · 每年 " + state.freq +
" 期 · 从 " + moneyShort(state.start) + " 出发;绿色为当前目标。";
if (!targetCross) {
refs.ladderInsight.innerHTML = "按当前参数,<b>" + (trade ? "1800 次内" : "150 年内") + "到不了</b> " +
moneyShort(state.target) + "。要么提高" + (trade ? "每次" : "每期") +
"收益率,要么把" + (trade ? "交易次数" : "时间") + "拉长;复利最怕的不是收益低,而是中断。";
return;
}
const targetYears = targetCross.years;
const nextTarget = state.target * 10;
const nextCross = crossAt(data.balances, nextTarget);
const gainShare = data.end > 0 ? Math.max(0, data.gains) / data.end : 0;
refs.ladderInsight.innerHTML = "从 " + moneyShort(state.start) + " 到 " + moneyShort(state.target) +
" 需要 <b>" + yearsText(targetYears) + "</b>(约第 " + nf0.format(Math.ceil(targetCross.periods)) +
" " + countName() + ")。再走 <b>" + yearsText(targetYears) + "</b>,同样的倍数就会把你送到 " +
(nextCross ? moneyShort(nextTarget) : moneyShort(nextTarget) + " 附近") +
"。第 " + data.units + " " + (trade ? "次后" : "年末") + ",账户里 <b>" + pctText(gainShare, 1) +
"</b> 是复利赚出来的。";
}
function renderChain(data) {
const trade = isTrade();
const n = Math.min(12, data.horizonPeriods);
const unit = trade ? "次" : "期";
const parts = [];
refs.chainTitle.textContent = trade ? "利滚利:前 12 次长什么样" : "利滚利:前 12 期长什么样";
parts.push('<div class="roll-card start"><span>起点</span><b>' + moneyChain(state.start) +
'</b><small>本金</small></div>');
for (let k = 1; k <= n; k += 1) {
const balance = data.balances[k];
const previous = data.balances[k - 1];
const interest = previous * rateFraction();
parts.push('<span class="roll-arrow">→</span>');
parts.push('<div class="roll-card"><span>第 ' + k + ' ' + unit + '</span><b>' + moneyChain(balance) +
'</b><small>+' + moneyChain(interest) + ' 复利' +
(state.extra > 0 ? ' · +' + moneyChain(state.extra) + ' 追加' : '') + '</small></div>');
}
refs.rollFlow.innerHTML = parts.join("");
refs.chainBadge.textContent = (trade ? "每次 +" : "每期 +") + pctText(rateFraction(), 2);
const first = data.balances[1];
const second = data.horizonPeriods >= 2 ? data.balances[2] : null;
refs.chainSubtitle.textContent = moneyChain(state.start) + " → " + moneyChain(first) +
(second != null ? " → " + moneyChain(second) : "") +
" …… 每一" + (trade ? "次" : "期") + "的收益,下一" + (trade ? "次" : "期") +
"也去赚钱;可横向滑动。";
}
function renderDetail(data) {
const trade = isTrade();
const unit = trade ? "次" : "年";
refs.detailEyebrow.textContent = trade ? "TRADE BY TRADE" : "YEAR BY YEAR";
refs.detailTitle.textContent = trade ? "逐次明细" : "逐年明细";
refs.detailYearHead.textContent = trade ? "次数" : "年份";
refs.detailStartHead.textContent = trade ? "期初" : "年初";
refs.detailGainHead.textContent = trade ? "本次收益" : "本年收益";
refs.detailEndHead.textContent = trade ? "期末" : "年末";
const milestoneYears = new Map();
data.milestones.forEach(item => {
if (item.years == null || item.years > data.units) return;
const year = Math.max(1, Math.ceil(item.years));
if (!milestoneYears.has(year)) milestoneYears.set(year, []);
milestoneYears.get(year).push(item.amount);
});
const targetYear = data.targetCross && data.targetCross.years <= data.units
? Math.max(1, Math.ceil(data.targetCross.years))
: null;
refs.detailBody.innerHTML = data.yearRows.map(row => {
const tags = (milestoneYears.get(row.year) || [])
.map(amount => '<span class="ms-tag">' + numShort(amount) + '</span>')
.join("");
const distance = state.target - row.end;
const classes = [];
if (row.year === targetYear) classes.push("is-target");
else if (milestoneYears.has(row.year)) classes.push("mstone");
return '<tr class="' + classes.join(" ") + '">' +
'<td>第 ' + row.year + ' ' + unit + tags + '</td>' +
'<td>' + moneyShort(row.start) + '</td>' +
'<td class="gain">+' + moneyShort(row.interest) + '</td>' +
'<td>' + (row.extra > 0 ? moneyShort(row.extra) : "—") + '</td>' +
'<td>' + moneyShort(row.end) + '</td>' +
'<td>' + multipleText(row.multiple) + '</td>' +
'<td class="' + (distance <= 0 ? "finish" : "") + '">' +
(distance > 0 ? moneyShort(distance) : "已达成") + '</td>' +
'</tr>';
}).join("");
refs.detailSubtitle.textContent = trade
? "每次 " + pctText(rateFraction(), 2) + " · 只数交易次数 · 金色行为里程碑,绿色行为目标达成那次;表格可横向滑动。"
: "每期 " + pctText(rateFraction(), 2) + " · 每年 " + state.freq +
" 期 · 金色行为里程碑,绿色行为目标达成那年;表格可横向滑动。";
refs.detailBadge.textContent = trade
? state.trades + " 次"
: state.years + " 年 · " + state.years * state.freq + " 期";
}
function renderCalc(data) {
const trade = isTrade();
let text = "本金 " + money(state.start) + " × (1 + " + pctText(rateFraction(), 2) + ")^" +
data.horizonPeriods + " = <b>" + money(data.end) + "</b>";
if (trade) text += "(共 " + state.trades + " 次)";
if (state.extra > 0) {
text += ";另" + (trade ? "每次" : "每期") + "追加 " + money(state.extra) + ",累计 " +
money(state.extra * data.horizonPeriods);
}
refs.calcLine.innerHTML = text;
}
function renderMobile(data) {
const oneM = crossAt(data.balances, 1000000);
const tenM = crossAt(data.balances, 10000000);
const trade = isTrade();
refs.m1m.textContent = oneM ? yearsText(oneM.years) : "做不到";
refs.m10m.textContent = tenM ? yearsText(tenM.years) : "做不到";
refs.mAnnualLabel.textContent = trade ? "每次" : "年化";
refs.mAnnual.textContent = trade
? pctText(rateFraction(), 1)
: pctText(data.effective, 1);
}
function syncControls(data) {
const trade = isTrade();
refs.presetLabel.textContent = trade ? "快速场景(每次收益)" : "快速场景(年化)";
const presetCopy = trade
? {
steady: { title: "稳健 3%", sub: "每次小赚" },
pro: { title: "进取 5%", sub: "短线好手" },
master: { title: "高手 10%", sub: "极高胜率" }
}
: {
steady: { title: "稳健 8%", sub: "红利 · 宽基" },
pro: { title: "进取 15%", sub: "长期好手" },
master: { title: "高手 20%", sub: "年化天花板" }
};
$$("[data-preset]").forEach(button => {
const copy = presetCopy[button.dataset.preset];
if (!copy) return;
const title = button.querySelector("b");
const sub = button.querySelector("span");
if (title) title.textContent = copy.title;
if (sub) sub.textContent = copy.sub;
});
refs.rateRange.value = state.rate;
setRangeFill(refs.rateRange);
setValueIfIdle(refs.rateInput, state.rate);
refs.rateInput.setAttribute("aria-label", trade ? "每次收益率百分比" : "每期收益率百分比");
refs.rateLabel.textContent = trade ? "每次收益率" : "每期收益率";
refs.rateHint.textContent = trade
? "每次短线买卖固定赚这么多,收益不取出,下一次连本带利继续滚(按全仓复利计算)。"
: "每期固定赚这么多,收益不取出,下一期连本带利继续滚。";
const chipRates = trade ? [2, 3, 5, 10, 20] : [10, 15, 20, 25, 30];
$$("[data-rate]").forEach((button, index) => {
const value = chipRates[index];
if (value == null) return;
button.dataset.rate = String(value);
button.textContent = value + "%";
});
refs.freqAnnual.textContent = trade
? "每次 " + pctText(rateFraction(), 2)
: "年化 " + pctText(data.effective, 1);
refs.yearChips.hidden = trade;
refs.tradeChips.hidden = !trade;
if (trade) {
refs.yearsRange.min = "10";
refs.yearsRange.max = "600";
refs.yearsRange.value = state.trades;
refs.yearsValue.textContent = state.trades + " 次";
refs.horizonLabel.textContent = "计算次数";
refs.horizonHint.textContent = "只影响曲线和明细显示的长度;里程碑所需次数按实际复利单独计算。每次 = 一次买卖。";
} else {
refs.yearsRange.min = "5";
refs.yearsRange.max = "60";
refs.yearsRange.value = state.years;
refs.yearsValue.textContent = state.years + " 年";
refs.horizonLabel.textContent = "计算年限";
refs.horizonHint.textContent = "只影响曲线和明细显示的长度;里程碑所需年数按实际复利单独计算。";
}
setRangeFill(refs.yearsRange);
setValueIfIdle(refs.startInput, state.start);
setValueIfIdle(refs.extraInput, state.extra);
refs.extraLabel.textContent = trade ? "每次追加" : "每期追加";
refs.extraInput.setAttribute("aria-label", trade ? "每次追加投入" : "每期追加投入");
refs.extraHint.textContent = trade
? "按每次交易期末追加,追加额同样进入下一次复利;默认 0,只看纯利滚利。"
: "按期末追加,追加额同样进入下一期复利;默认 0,只看纯利滚利。";
if (state.rate <= 0) {
refs.freqHint.textContent = trade
? "每次收益是 0:做再多次也不会产生复利。"
: "收益率是 0:怎么结算都不会产生复利增长。";
} else if (trade) {
refs.freqHint.textContent = "每次交易结一次:没有固定时间单位,只数做了多少次;每次 " +
pctText(rateFraction(), 2) + ",下一次连本带利继续。";
} else if (state.freq === 1) {
refs.freqHint.textContent = "每年结一次:每期 " + pctText(rateFraction(), 2) +
",年化就是 " + pctText(rateFraction(), 2) + "。";
} else {
refs.freqHint.textContent = "每期 " + pctText(rateFraction(), 2) + " × 每年 " + state.freq +
" 期 ⇒ 年化 " + pctText(data.effective, 1) + ";结算越频繁,年化越高。";
}
refs.targetHint.textContent = "对比图、阶梯和明细围绕 " + moneyShort(state.target) + " 高亮;点击切换目标。";
$$("[data-freq]").forEach(button => {
const active = Number(button.dataset.freq) === (trade ? 0 : state.freq);
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
$$("[data-target]").forEach(button => {
const active = Number(button.dataset.target) === state.target;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
$$("[data-ctab]").forEach(button => {
const active = button.dataset.ctab === state.ctab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
$$("[data-scale]").forEach(button => {
const active = button.dataset.scale === state.scale;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
refs.scaleSwitch.hidden = state.ctab === "yearly";
const activePreset = state.extra !== 0 ? null : trade
? (state.rate === 3 ? "steady" : state.rate === 5 ? "pro" : state.rate === 10 ? "master" : null)
: (state.freq === 1
? (state.rate === 8 ? "steady" : state.rate === 15 ? "pro" : state.rate === 20 ? "master" : null)
: null);
$$("[data-preset]").forEach(button => {
const active = button.dataset.preset === activePreset;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
function renderAll() {
const data = getData();
syncControls(data);
renderHero(data);
renderKpis(data);
renderStrip(data);
renderCurveHeader(data);
renderCurve(data);
renderCompareHeader(data);
renderCompare(data);
renderLadder(data);
renderChain(data);
renderDetail(data);
renderCalc(data);
renderMobile(data);
hideCurveTip();
}
function scheduleRender() {
if (renderFrame) return;
renderFrame = requestAnimationFrame(() => {
renderFrame = 0;
renderAll();
});
}
function scheduleCharts() {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const data = getData();
renderCurveHeader(data);
renderCurve(data);
renderCompareHeader(data);
renderCompare(data);
hideCurveTip();
}, 110);
}
function clampNumber(value, min, max, fallback) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return clamp(number, min, max);
}
function applyPreset(name) {
state.extra = 0;
if (isTrade()) {
state.rate = name === "steady" ? 3 : name === "pro" ? 5 : 10;
state.trades = 120;
renderAll();
return;
}
state.tradeMode = false;
state.freq = 1;
if (name === "steady") {
state.rate = 8;
state.years = 40;
} else if (name === "pro") {
state.rate = 15;
state.years = 30;
} else {
state.rate = 20;
state.years = 30;
}
renderAll();
}
function resetAll() {
Object.assign(state, DEFAULTS);
renderAll();
}
refs.rateRange.addEventListener("input", event => {
state.rate = Math.round(clamp(Number(event.target.value), 0, 100) * 10) / 10;
scheduleRender();
});
refs.rateInput.addEventListener("input", event => {
if (event.target.value === "") return;
const value = Number(event.target.value);
if (Number.isFinite(value) && value >= 0 && value <= 100) {
state.rate = Math.round(value * 10) / 10;
scheduleRender();
}
});
refs.rateInput.addEventListener("blur", () => {
state.rate = Math.round(clamp(clampNumber(refs.rateInput.value, 0, 100, state.rate), 0, 100) * 10) / 10;
refs.rateInput.value = state.rate;
renderAll();
});
refs.yearsRange.addEventListener("input", event => {
if (isTrade()) {
state.trades = clamp(Math.round(Number(event.target.value)), 10, 600);
} else {
state.years = clamp(Math.round(Number(event.target.value)), 5, 60);
}
scheduleRender();
});
refs.startInput.addEventListener("input", event => {
const value = Number(event.target.value);
if (Number.isFinite(value) && value >= 100) {
state.start = clamp(value, 100, 1000000000);
scheduleRender();
}
});
refs.startInput.addEventListener("blur", () => {
state.start = Math.round(clamp(clampNumber(refs.startInput.value, 100, 1000000000, state.start), 100, 1000000000));
refs.startInput.value = state.start;
renderAll();
});
refs.extraInput.addEventListener("input", event => {
const value = Number(event.target.value);
if (Number.isFinite(value) && value >= 0) {
state.extra = clamp(value, 0, 10000000);
scheduleRender();
}
});
refs.extraInput.addEventListener("blur", () => {
state.extra = Math.round(clamp(clampNumber(refs.extraInput.value, 0, 10000000, state.extra), 0, 10000000));
refs.extraInput.value = state.extra;
renderAll();
});
$$("[data-rate]").forEach(button => {
button.addEventListener("click", () => {
state.rate = Number(button.dataset.rate);
renderAll();
});
});
$$("[data-years]").forEach(button => {
button.addEventListener("click", () => {
state.years = Number(button.dataset.years);
renderAll();
});
});
$$("[data-trades]").forEach(button => {
button.addEventListener("click", () => {
state.trades = Number(button.dataset.trades);
renderAll();
});
});
$$("[data-start]").forEach(button => {
button.addEventListener("click", () => {
state.start = Number(button.dataset.start);
renderAll();
});
});
$$("[data-extra]").forEach(button => {
button.addEventListener("click", () => {
state.extra = Number(button.dataset.extra);
renderAll();
});
});
$$("[data-freq]").forEach(button => {
button.addEventListener("click", () => {
const value = Number(button.dataset.freq);
if (value === 0) {
state.tradeMode = true;
} else {
state.tradeMode = false;
state.freq = value;
}
renderAll();
});
});
$$("[data-target]").forEach(button => {
button.addEventListener("click", () => {
state.target = Number(button.dataset.target);
renderAll();
});
});
$$("[data-ctab]").forEach(button => {
button.addEventListener("click", () => {
state.ctab = button.dataset.ctab;
renderAll();
});
});
$$("[data-scale]").forEach(button => {
button.addEventListener("click", () => {
state.scale = button.dataset.scale;
renderAll();
});
});
$$("[data-preset]").forEach(button => {
button.addEventListener("click", () => applyPreset(button.dataset.preset));
});
$("#resetBtn").addEventListener("click", resetAll);
refs.curveWrap.addEventListener("pointermove", onCurveMove);
refs.curveWrap.addEventListener("pointerleave", hideCurveTip);
refs.curveWrap.addEventListener("pointercancel", hideCurveTip);
window.addEventListener("resize", scheduleCharts);
if ("ResizeObserver" in window) {
let lastWidth = 0;
const observer = new ResizeObserver(entries => {
const width = Math.round(entries[0].contentRect.width);
if (lastWidth && Math.abs(width - lastWidth) > 2) {
lastWidth = width;
scheduleCharts();
} else if (!lastWidth) {
lastWidth = width;
}
});
observer.observe(refs.curveWrap);
}
if ("IntersectionObserver" in window) {
const readout = $(".mobile-readout");
const controls = $(".controls");
if (readout && controls) {
const readoutObserver = new IntersectionObserver(entries => {
readout.classList.toggle("is-visible", entries[0].isIntersecting);
}, { threshold: 0.02 });
readoutObserver.observe(controls);
}
}
renderAll();
})();
</script>
</body>
</html>