Add chart start year and quantile band controls
This commit is contained in:
@@ -233,7 +233,7 @@ def seasonal_percentile(series: pd.Series) -> dict[str, Any] | None:
|
|||||||
raw_pct = 100.0 * (less + 0.5 * equal) / len(sample)
|
raw_pct = 100.0 * (less + 0.5 * equal) / len(sample)
|
||||||
latest_year = int(latest_date.year)
|
latest_year = int(latest_date.year)
|
||||||
yearly: dict[str, list[tuple[str, float]]] = {}
|
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():
|
for dt, val in clipped.items():
|
||||||
yearly.setdefault(str(int(dt.year)), []).append((dt.strftime("%m-%d"), float(val)))
|
yearly.setdefault(str(int(dt.year)), []).append((dt.strftime("%m-%d"), float(val)))
|
||||||
return {
|
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="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="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">去尾 <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>
|
<label class="ctrl"><input id="includeDemand" type="checkbox" checked>需求计分</label>
|
||||||
<button class="tabbtn active" id="dashboardTab">面板</button>
|
<button class="tabbtn active" id="dashboardTab">面板</button>
|
||||||
<button class="tabbtn" id="historyTab">评分走势</button>
|
<button class="tabbtn" id="historyTab">评分走势</button>
|
||||||
@@ -547,6 +548,7 @@ const boardSel = document.getElementById("board");
|
|||||||
const bandWindow = document.getElementById("bandWindow");
|
const bandWindow = document.getElementById("bandWindow");
|
||||||
const bandK = document.getElementById("bandK");
|
const bandK = document.getElementById("bandK");
|
||||||
const bandTrim = document.getElementById("bandTrim");
|
const bandTrim = document.getElementById("bandTrim");
|
||||||
|
const chartStartYear = document.getElementById("chartStartYear");
|
||||||
const includeDemand = document.getElementById("includeDemand");
|
const includeDemand = document.getElementById("includeDemand");
|
||||||
const dashboardTab = document.getElementById("dashboardTab");
|
const dashboardTab = document.getElementById("dashboardTab");
|
||||||
const historyTab = document.getElementById("historyTab");
|
const historyTab = document.getElementById("historyTab");
|
||||||
@@ -560,6 +562,9 @@ const historyChartWrap = document.getElementById("historyChartWrap");
|
|||||||
const refreshData = document.getElementById("refreshData");
|
const refreshData = document.getElementById("refreshData");
|
||||||
for (const d of dims) dimSel.append(new Option(d, d));
|
for (const d of dims) dimSel.append(new Option(d, d));
|
||||||
for (const b of DATA.boards) boardSel.append(new Option(b, b));
|
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("generated").textContent = DATA.generated_at;
|
||||||
document.getElementById("completion").textContent = `${{DATA.completion.count}}/${{DATA.completion.total}}(${{DATA.completion.ratio.toFixed(1)}}%)`;
|
document.getElementById("completion").textContent = `${{DATA.completion.count}}/${{DATA.completion.total}}(${{DATA.completion.ratio.toFixed(1)}}%)`;
|
||||||
function todayString() {{
|
function todayString() {{
|
||||||
@@ -582,11 +587,11 @@ function majorityHistoryEndDate() {{
|
|||||||
}}
|
}}
|
||||||
historyEnd.value = majorityHistoryEndDate();
|
historyEnd.value = majorityHistoryEndDate();
|
||||||
let activeView = "dashboard";
|
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(); }};
|
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(); }};
|
historyTab.onclick = () => {{ activeView="history"; historyTab.classList.add("active"); dashboardTab.classList.remove("active"); render(); }};
|
||||||
refreshData.onclick = refreshFromLocalProgram;
|
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 fmt(v, n=1) {{ return v == null || Number.isNaN(v) ? "--" : Number(v).toFixed(n); }}
|
||||||
function matches(c) {{
|
function matches(c) {{
|
||||||
@@ -679,6 +684,8 @@ function chartParams() {{
|
|||||||
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
|
window: Math.max(3, Math.min(90, Number(bandWindow.value) || 15)),
|
||||||
k: Math.max(0.5, Math.min(6, Number(bandK.value) || 3)),
|
k: Math.max(0.5, Math.min(6, Number(bandK.value) || 3)),
|
||||||
trim: Math.max(0, Math.min(40, Number(bandTrim.value) || 0)) / 100,
|
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; }}
|
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;
|
if (sorted.length - cut * 2 < 4) return sorted;
|
||||||
return sorted.slice(cut, sorted.length - cut);
|
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) {{
|
function smoothBand(band, radius) {{
|
||||||
if (!band.length || radius <= 0) return band;
|
if (!band.length || radius <= 0) return band;
|
||||||
const n = band.length;
|
const n = band.length;
|
||||||
@@ -726,6 +741,7 @@ function buildNormalizedChart(years) {{
|
|||||||
const params = chartParams();
|
const params = chartParams();
|
||||||
const rawPoints = [];
|
const rawPoints = [];
|
||||||
Object.entries(years).forEach(([year, pts]) => {{
|
Object.entries(years).forEach(([year, pts]) => {{
|
||||||
|
if (Number(year) < params.startYear) return;
|
||||||
pts.forEach(p => {{
|
pts.forEach(p => {{
|
||||||
const value = Number(p[1]);
|
const value = Number(p[1]);
|
||||||
if (Number.isFinite(value)) rawPoints.push({{year, md:p[0], day:parseDay(p[0]), value}});
|
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 z = v => (v - m) / s;
|
||||||
const normYears = {{}};
|
const normYears = {{}};
|
||||||
Object.entries(years).forEach(([year, pts]) => {{
|
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]));
|
normYears[year] = pts.map(p => [p[0], z(Number(p[1]))]).filter(p => Number.isFinite(p[1]));
|
||||||
}});
|
}});
|
||||||
const band = [];
|
const band = [];
|
||||||
for (let day=1; day<=366; day++) {{
|
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;
|
if (samples.length < 4) continue;
|
||||||
const bm = mean(samples);
|
const tail = Math.max(0.02, Math.min(0.45, params.trim || 0.10));
|
||||||
const bs = std(samples);
|
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 date = new Date(2024, 0, day);
|
||||||
const md = `${{String(date.getMonth()+1).padStart(2,"0")}}-${{String(date.getDate()).padStart(2,"0")}}`;
|
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)));
|
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
|
||||||
return {{years:normYears, band:smoothBand(band, smoothRadius)}};
|
return {{years:normYears, band:smoothBand(band, smoothRadius)}};
|
||||||
|
|||||||
+29
-7
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user