185 lines
5.6 KiB
TypeScript
185 lines
5.6 KiB
TypeScript
export const DASHBOARD_LINE_TOP_N = 10;
|
|
export const DASHBOARD_SERIES_NONE = 'none';
|
|
export const DASHBOARD_SERIES_OTHER = 'other';
|
|
export const DASHBOARD_SERIES_ALL = 'all';
|
|
|
|
export type DashboardSeriesPoint = {
|
|
seriesId: string;
|
|
period: string;
|
|
value: number;
|
|
};
|
|
|
|
export type DashboardNamedSeries = {
|
|
id: string;
|
|
name: string;
|
|
values: number[];
|
|
};
|
|
|
|
export function normalizeSeriesId(raw: string | number | bigint | null | undefined): string {
|
|
if (raw == null) return DASHBOARD_SERIES_NONE;
|
|
const s = String(raw).trim();
|
|
return s ? s : DASHBOARD_SERIES_NONE;
|
|
}
|
|
|
|
export function periodValueMap(
|
|
periodKeys: string[],
|
|
points: DashboardSeriesPoint[],
|
|
): Map<string, number[]> {
|
|
const index = new Map(periodKeys.map((k, i) => [k, i]));
|
|
const map = new Map<string, number[]>();
|
|
const ensure = (id: string) => {
|
|
let arr = map.get(id);
|
|
if (!arr) {
|
|
arr = periodKeys.map(() => 0);
|
|
map.set(id, arr);
|
|
}
|
|
return arr;
|
|
};
|
|
for (const p of points) {
|
|
const i = index.get(p.period);
|
|
if (i === undefined) continue;
|
|
ensure(p.seriesId)[i] += p.value;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
export function cumulativeValues(increments: number[], baseline = 0): number[] {
|
|
let acc = baseline;
|
|
return increments.map((n) => {
|
|
acc += n;
|
|
return acc;
|
|
});
|
|
}
|
|
|
|
export function seriesEndWeight(increments: number[], baseline = 0): number {
|
|
return baseline + increments.reduce((sum, n) => sum + n, 0);
|
|
}
|
|
|
|
export function pickTopSeriesIds(
|
|
weights: Array<{ id: string; weight: number }>,
|
|
topN = DASHBOARD_LINE_TOP_N,
|
|
reserved: string[] = [DASHBOARD_SERIES_NONE],
|
|
): { keep: string[]; rest: string[] } {
|
|
const reservedSet = new Set(reserved);
|
|
const reservedIds = weights
|
|
.filter((w) => reservedSet.has(w.id) && w.weight !== 0)
|
|
.map((w) => w.id);
|
|
const ranked = weights
|
|
.filter((w) => !reservedSet.has(w.id))
|
|
.sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id));
|
|
return {
|
|
keep: [...reservedIds, ...ranked.slice(0, topN).map((w) => w.id)],
|
|
rest: ranked.slice(topN).map((w) => w.id),
|
|
};
|
|
}
|
|
|
|
function sumSeries(
|
|
incrementMap: Map<string, number[]>,
|
|
baselineMap: Map<string, number>,
|
|
ids: string[],
|
|
periodLen: number,
|
|
): { increments: number[]; baseline: number } {
|
|
const increments = Array.from({ length: periodLen }, () => 0);
|
|
let baseline = 0;
|
|
for (const id of ids) {
|
|
const arr = incrementMap.get(id);
|
|
if (arr) {
|
|
for (let i = 0; i < periodLen; i += 1) increments[i] += arr[i] ?? 0;
|
|
}
|
|
baseline += baselineMap.get(id) ?? 0;
|
|
}
|
|
return { increments, baseline };
|
|
}
|
|
|
|
function applyRound(values: number[], round?: (n: number) => number): number[] {
|
|
return round ? values.map(round) : values;
|
|
}
|
|
|
|
export function buildDimensionLines(opts: {
|
|
periodKeys: string[];
|
|
points: DashboardSeriesPoint[];
|
|
baselines?: Array<{ seriesId: string; value: number }>;
|
|
names?: Map<string, string>;
|
|
noneLabel: string;
|
|
otherLabel?: string;
|
|
topN?: number;
|
|
round?: (n: number) => number;
|
|
}): { total: DashboardNamedSeries[]; increment: DashboardNamedSeries[] } {
|
|
const periodLen = opts.periodKeys.length;
|
|
const incrementMap = periodValueMap(opts.periodKeys, opts.points);
|
|
const baselineMap = new Map<string, number>();
|
|
for (const row of opts.baselines ?? []) {
|
|
const id = normalizeSeriesId(row.seriesId);
|
|
baselineMap.set(id, (baselineMap.get(id) ?? 0) + row.value);
|
|
if (!incrementMap.has(id)) incrementMap.set(id, opts.periodKeys.map(() => 0));
|
|
}
|
|
for (const id of incrementMap.keys()) {
|
|
if (!baselineMap.has(id)) baselineMap.set(id, 0);
|
|
}
|
|
|
|
const weights = [...incrementMap.keys()].map((id) => ({
|
|
id,
|
|
weight: seriesEndWeight(incrementMap.get(id) ?? [], baselineMap.get(id) ?? 0),
|
|
}));
|
|
const { keep, rest } = pickTopSeriesIds(weights, opts.topN ?? DASHBOARD_LINE_TOP_N);
|
|
const nameOf = (id: string) => {
|
|
if (id === DASHBOARD_SERIES_NONE) return opts.noneLabel;
|
|
if (id === DASHBOARD_SERIES_OTHER) return opts.otherLabel ?? '其他';
|
|
return opts.names?.get(id) || `#${id}`;
|
|
};
|
|
|
|
const orderedIds = [...keep];
|
|
if (rest.length) orderedIds.push(DASHBOARD_SERIES_OTHER);
|
|
|
|
const total: DashboardNamedSeries[] = [];
|
|
const increment: DashboardNamedSeries[] = [];
|
|
for (const id of orderedIds) {
|
|
const packed =
|
|
id === DASHBOARD_SERIES_OTHER
|
|
? sumSeries(incrementMap, baselineMap, rest, periodLen)
|
|
: {
|
|
increments: incrementMap.get(id) ?? opts.periodKeys.map(() => 0),
|
|
baseline: baselineMap.get(id) ?? 0,
|
|
};
|
|
increment.push({
|
|
id,
|
|
name: nameOf(id),
|
|
values: applyRound(packed.increments, opts.round),
|
|
});
|
|
total.push({
|
|
id,
|
|
name: nameOf(id),
|
|
values: applyRound(cumulativeValues(packed.increments, packed.baseline), opts.round),
|
|
});
|
|
}
|
|
return { total, increment };
|
|
}
|
|
|
|
export function buildSingleLine(opts: {
|
|
periodKeys: string[];
|
|
points: DashboardSeriesPoint[];
|
|
baseline?: number;
|
|
name: string;
|
|
round?: (n: number) => number;
|
|
}): { total: DashboardNamedSeries; increment: DashboardNamedSeries } {
|
|
const merged: DashboardSeriesPoint[] = opts.points.map((p) => ({
|
|
...p,
|
|
seriesId: DASHBOARD_SERIES_ALL,
|
|
}));
|
|
const incrementMap = periodValueMap(opts.periodKeys, merged);
|
|
const increments = incrementMap.get(DASHBOARD_SERIES_ALL) ?? opts.periodKeys.map(() => 0);
|
|
const baseline = opts.baseline ?? 0;
|
|
return {
|
|
increment: {
|
|
id: DASHBOARD_SERIES_ALL,
|
|
name: opts.name,
|
|
values: applyRound(increments, opts.round),
|
|
},
|
|
total: {
|
|
id: DASHBOARD_SERIES_ALL,
|
|
name: opts.name,
|
|
values: applyRound(cumulativeValues(increments, baseline), opts.round),
|
|
},
|
|
};
|
|
}
|