Add chart start year and quantile band controls

This commit is contained in:
2026-06-09 18:12:53 +08:00
parent 2384bcc6a6
commit f8853d67c6
2 changed files with 58 additions and 14 deletions
+29 -7
View File
@@ -233,7 +233,7 @@ def seasonal_percentile(series: pd.Series) -> dict[str, Any] | None:
raw_pct = 100.0 * (less + 0.5 * equal) / len(sample)
latest_year = int(latest_date.year)
yearly: dict[str, list[tuple[str, float]]] = {}
clipped = series[series.index.year >= latest_year - 8]
clipped = series[series.index.year >= 2021]
for dt, val in clipped.items():
yearly.setdefault(str(int(dt.year)), []).append((dt.strftime("%m-%d"), float(val)))
return {
@@ -493,6 +493,7 @@ svg {{ width:100%; height:230px; display:block; }}
<label class="ctrl">窗口 <input class="mini" id="bandWindow" type="number" min="3" max="90" step="1" value="15"></label>
<label class="ctrl">倍数 <input class="mini" id="bandK" type="number" min="0.5" max="6" step="0.5" value="3"></label>
<label class="ctrl">去尾 <input class="mini" id="bandTrim" type="number" min="0" max="40" step="5" value="10"></label>
<label class="ctrl">图始 <select id="chartStartYear"></select></label>
<label class="ctrl"><input id="includeDemand" type="checkbox" checked>需求计分</label>
<button class="tabbtn active" id="dashboardTab">面板</button>
<button class="tabbtn" id="historyTab">评分走势</button>
@@ -547,6 +548,7 @@ const boardSel = document.getElementById("board");
const bandWindow = document.getElementById("bandWindow");
const bandK = document.getElementById("bandK");
const bandTrim = document.getElementById("bandTrim");
const chartStartYear = document.getElementById("chartStartYear");
const includeDemand = document.getElementById("includeDemand");
const dashboardTab = document.getElementById("dashboardTab");
const historyTab = document.getElementById("historyTab");
@@ -560,6 +562,9 @@ const historyChartWrap = document.getElementById("historyChartWrap");
const refreshData = document.getElementById("refreshData");
for (const d of dims) dimSel.append(new Option(d, d));
for (const b of DATA.boards) boardSel.append(new Option(b, b));
const maxChartYear = new Date(DATA.generated_at.slice(0,10)).getFullYear();
for (let y=2018; y<=maxChartYear; y++) chartStartYear.append(new Option(String(y), String(y)));
chartStartYear.value = chartStartYear.querySelector('option[value="2021"]') ? "2021" : chartStartYear.options[0]?.value;
document.getElementById("generated").textContent = DATA.generated_at;
document.getElementById("completion").textContent = `${{DATA.completion.count}}/${{DATA.completion.total}}${{DATA.completion.ratio.toFixed(1)}}%`;
function todayString() {{
@@ -582,11 +587,11 @@ function majorityHistoryEndDate() {{
}}
historyEnd.value = majorityHistoryEndDate();
let activeView = "dashboard";
document.getElementById("reset").onclick = () => {{ search.value=""; dimSel.value="全部"; boardSel.value="全部"; bandWindow.value="15"; bandK.value="3"; bandTrim.value="10"; includeDemand.checked=true; historyStart.value="2026-01-01"; historyEnd.value = majorityHistoryEndDate(); render(); }};
document.getElementById("reset").onclick = () => {{ search.value=""; dimSel.value="全部"; boardSel.value="全部"; bandWindow.value="15"; bandK.value="3"; bandTrim.value="10"; chartStartYear.value="2021"; includeDemand.checked=true; historyStart.value="2026-01-01"; historyEnd.value = majorityHistoryEndDate(); render(); }};
dashboardTab.onclick = () => {{ activeView="dashboard"; dashboardTab.classList.add("active"); historyTab.classList.remove("active"); render(); }};
historyTab.onclick = () => {{ activeView="history"; historyTab.classList.add("active"); dashboardTab.classList.remove("active"); render(); }};
refreshData.onclick = refreshFromLocalProgram;
search.oninput = dimSel.onchange = boardSel.onchange = bandWindow.oninput = bandK.oninput = bandTrim.oninput = includeDemand.onchange = historyStart.onchange = historyEnd.onchange = render;
search.oninput = dimSel.onchange = boardSel.onchange = bandWindow.oninput = bandK.oninput = bandTrim.oninput = chartStartYear.onchange = includeDemand.onchange = historyStart.onchange = historyEnd.onchange = render;
function fmt(v, n=1) {{ return v == null || Number.isNaN(v) ? "--" : Number(v).toFixed(n); }}
function matches(c) {{
@@ -679,6 +684,8 @@ function chartParams() {{
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
k: Math.max(0.5, Math.min(6, Number(bandK.value) || 3)),
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
startYear: Number(chartStartYear.value) || 2021,
excludeYear: maxChartYear,
}};
}}
function mean(arr) {{ return arr.reduce((s,v)=>s+v,0) / arr.length; }}
@@ -698,6 +705,14 @@ function trimmed(arr, trim) {{
if (sorted.length - cut * 2 < 4) return sorted;
return sorted.slice(cut, sorted.length - cut);
}}
function quantile(sorted, q) {{
if (!sorted.length) return NaN;
const pos = (sorted.length - 1) * Math.max(0, Math.min(1, q));
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}}
function smoothBand(band, radius) {{
if (!band.length || radius <= 0) return band;
const n = band.length;
@@ -726,6 +741,7 @@ function buildNormalizedChart(years) {{
const params = chartParams();
const rawPoints = [];
Object.entries(years).forEach(([year, pts]) => {{
if (Number(year) < params.startYear) return;
pts.forEach(p => {{
const value = Number(p[1]);
if (Number.isFinite(value)) rawPoints.push({{year, md:p[0], day:parseDay(p[0]), value}});
@@ -739,17 +755,23 @@ function buildNormalizedChart(years) {{
const z = v => (v - m) / s;
const normYears = {{}};
Object.entries(years).forEach(([year, pts]) => {{
if (Number(year) < params.startYear) return;
normYears[year] = pts.map(p => [p[0], z(Number(p[1]))]).filter(p => Number.isFinite(p[1]));
}});
const band = [];
for (let day=1; day<=366; day++) {{
const samples = trimmed(rawPoints.filter(p => circularDiff(p.day, day) <= params.window).map(p=>p.value), params.trim);
const samples = rawPoints.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear && circularDiff(p.day, day) <= params.window).map(p=>p.value).filter(Number.isFinite).sort((a,b)=>a-b);
if (samples.length < 4) continue;
const bm = mean(samples);
const bs = std(samples);
const tail = Math.max(0.02, Math.min(0.45, params.trim || 0.10));
const mid = quantile(samples, 0.50);
let low = quantile(samples, tail);
let high = quantile(samples, 1 - tail);
const widthScale = Math.max(0.25, Math.min(2.5, params.k / 3));
low = mid - (mid - low) * widthScale;
high = mid + (high - mid) * widthScale;
const date = new Date(2024, 0, day);
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
band.push([md, z(bm - params.k * bs), z(bm + params.k * bs), z(bm)]);
band.push([md, z(low), z(high), z(mid)]);
}}
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
return {{years:normYears, band:smoothBand(band, smoothRadius)}};
File diff suppressed because one or more lines are too long