Document dashboard handoff and dynamic band scoring
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user