Document dashboard handoff and dynamic band scoring

This commit is contained in:
2026-06-12 10:27:02 +08:00
parent b4558d2bde
commit 2c2ebd4c34
4 changed files with 248 additions and 85 deletions
+77 -29
View File
@@ -515,7 +515,7 @@ svg {{ width:100%; height:230px; display:block; }}
<div class="chips" id="weak"></div>
</div>
</section>
<div class="note">维度得分按同期正常波动区间计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。阴影带参数只影响图表展示,总分使用默认 ±15 天、3 倍标准差、去尾 10%。</div>
<div class="note">维度得分按当前阴影带范围计算:正向指标高于上沿为 100,低于下沿为 0,区间内线性映射;库存反向。顶部阴影带参数同步影响维度分数和总分。</div>
<main class="rows" id="rows"></main>
<section class="history-view" id="historyView">
<div class="history-tools">
@@ -541,6 +541,7 @@ function yearColor(year, years) {{
}}
const dimColors = {json.dumps(DIM_COLORS, ensure_ascii=False)};
const dims = DATA.dimensions;
const inverseDims = new Set({json.dumps(list(INVERSE_DIMS), ensure_ascii=False)});
const search = document.getElementById("search");
const dimSel = document.getElementById("dim");
const boardSel = document.getElementById("board");
@@ -613,13 +614,26 @@ function matches(c) {{
return hay.includes(q);
}}
function activeDims() {{ return includeDemand.checked ? ["利润","产量","库存","需求"] : ["利润","产量","库存"]; }}
function currentDimScore(dim, v) {{
const dynamic = scoreFromCurrentBand(v, inverseDims.has(dim));
if (dynamic != null && Number.isFinite(dynamic)) return dynamic;
return v?.score;
}}
function scoreCommodity(c) {{
const next = Object.assign({{}}, c);
next.dims = Object.fromEntries(Object.entries(c.dims).map(([d, v]) => {{
const score = currentDimScore(d, v);
return [d, Object.assign({{}}, v, {{score: score == null ? null : score}})];
}}));
return Object.assign(next, computeTotal(next));
}}
function computeTotal(c) {{
const keys = activeDims();
const values = keys.map(d => c.dims[d]?.score).filter(v => v != null && Number.isFinite(Number(v))).map(Number);
return {{total: values.length ? values.reduce((s,v)=>s+v,0)/values.length : null, valid: values.length, denom: keys.length}};
}}
function rankedList(list) {{
const mapped = list.map(c => Object.assign({{}}, c, computeTotal(c)));
const mapped = list.map(scoreCommodity);
const scored = mapped.filter(c => c.total != null).sort((a,b)=>b.total-a.total);
scored.forEach((c,i)=>c.displayRank=i+1);
return scored.concat(mapped.filter(c => c.total == null));
@@ -757,6 +771,64 @@ function robustAnnualSeasonValues(points, useRobust=true) {{
.map(x=>x.value)
.sort((a,b)=>a-b);
}}
function rawPointsFromYears(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:Number(year), md:p[0], day:parseDay(p[0]), value}});
}});
}});
return rawPoints;
}}
function buildRawBand(years, rawPoints=null) {{
const params = chartParams();
const points = rawPoints || rawPointsFromYears(years);
const bandUniverse = points.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear);
const trendLike = trendLikeAnnualLevel(bandUniverse);
const band = [];
for (let day=1; day<=366; day++) {{
const samples = robustAnnualSeasonValues(bandUniverse.filter(p => circularDiff(p.day, day) <= params.window), !trendLike);
if (samples.length < 3) continue;
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, low, high, mid]);
}}
const smoothRadius = Math.max(3, Math.min(18, Math.round(params.window * 0.75)));
return smoothBand(band, smoothRadius);
}}
function latestRawPoint(years) {{
const points = rawPointsFromYears(years);
if (!points.length) return null;
return points.sort((a,b) => a.year-b.year || a.day-b.day).at(-1);
}}
function scoreFromCurrentBand(v, inverse=false) {{
if (!v || !v.years) return null;
const latest = latestRawPoint(v.years);
if (!latest) return null;
const rawBand = buildRawBand(v.years);
const row = rawBand.find(p => p[0] === latest.md);
if (!row) return null;
let low = Number(row[1]), high = Number(row[2]);
if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
if (low > high) {{ const tmp = low; low = high; high = tmp; }}
let rawScore;
if (high === low) rawScore = latest.value >= high ? 100 : 0;
else if (latest.value >= high) rawScore = 100;
else if (latest.value <= low) rawScore = 0;
else rawScore = 100 * (latest.value - low) / (high - low);
const score = inverse ? 100 - rawScore : rawScore;
return Math.max(0, Math.min(100, score));
}}
function smoothBand(band, radius) {{
if (!band.length || radius <= 0) return band;
const n = band.length;
@@ -783,18 +855,9 @@ function smoothBand(band, radius) {{
}}
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}});
}});
}});
const rawPoints = rawPointsFromYears(years);
const rawValues = rawPoints.map(p=>p.value);
if (!rawValues.length) return {{years:{{}}, band:[]}};
const bandUniverse = rawPoints.filter(p => Number(p.year) >= params.startYear && Number(p.year) < params.excludeYear);
const trendLike = trendLikeAnnualLevel(bandUniverse);
const m = mean(rawValues);
let s = std(rawValues);
if (!Number.isFinite(s) || s === 0) s = 1;
@@ -804,23 +867,8 @@ function buildNormalizedChart(years) {{
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 = robustAnnualSeasonValues(bandUniverse.filter(p => circularDiff(p.day, day) <= params.window), !trendLike);
if (samples.length < 3) continue;
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(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)}};
const band = buildRawBand(years, rawPoints).map(p => [p[0], z(p[1]), z(p[2]), z(p[3])]);
return {{years:normYears, band}};
}}
function drawChart(svg, years) {{
const normalized = buildNormalizedChart(years);