Skip to content

Commit 529a9ec

Browse files
authored
Merge pull request #291 from zjyhhhher/feat/add_new_spec_insights
feat: add_new_spec_insights, abnormal trend & abnormal band
2 parents b9bcf84 + b6c1f79 commit 529a9ec

File tree

3 files changed

+238
-7
lines changed

3 files changed

+238
-7
lines changed

packages/vmind/src/atom/dataInsight/algorithms/abnormalTrend/index.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ const abnormalTrendAlgo = (context: DataInsightExtractContext, options: Abnormal
2626
const result: Insight[] = [];
2727
const { threshold = 0.2 } = options || {};
2828
const { insights, seriesDataMap, cell, spec } = context;
29-
const { y: celly, color } = cell;
29+
const { y: celly, color, x: cellx } = cell;
3030
const yField: string[] = isArray(celly) ? celly.flat() : [celly];
31+
const xField: string[] = isArray(cellx) ? cellx.flat() : [cellx];
3132
const seriesField: string = isArray(color) ? color[0] : color;
3233
if (!seriesField) {
3334
return [];
@@ -38,28 +39,46 @@ const abnormalTrendAlgo = (context: DataInsightExtractContext, options: Abnormal
3839
if (isStackSeries(spec, measureId) || isPercenSeries(spec, measureId)) {
3940
return;
4041
}
41-
const seriesDataset: number[] = seriesDataMap[series]
42-
.map(d => Number(d.dataItem[measureId]))
43-
.filter(v => isValidData(v) && !isNaN(v));
42+
43+
const xyPairs: { x: string; y: number }[] = [];
44+
seriesDataMap[series].forEach(d => {
45+
const xValue = String(d.dataItem[xField[0]]);
46+
const yValue = Number(d.dataItem[measureId]);
47+
48+
if (isValidData(yValue) && !isNaN(yValue)) {
49+
xyPairs.push({
50+
x: xValue,
51+
y: yValue
52+
});
53+
}
54+
});
55+
56+
const seriesDataset: number[] = xyPairs.map(pair => pair.y);
57+
4458
const { trend, pValue, zScore } = originalMKTest(seriesDataset, 0.05, false);
4559
if (trend !== TrendType.NO_TREND) {
4660
const startValue = seriesDataset[0];
4761
const endValue = seriesDataset[seriesDataset.length - 1];
62+
const startDimValue = xyPairs[0].x;
63+
const endDimValue = xyPairs[xyPairs.length - 1].x;
4864
seriesTrendInfo.push({
4965
trend,
5066
pValue,
5167
zScore,
5268
measureId,
5369
series,
5470
info: {
71+
startDimValue,
5572
startValue,
73+
endDimValue,
5674
endValue,
5775
change: endValue / startValue - 1
5876
}
5977
});
6078
}
6179
});
6280
});
81+
//console.log(seriesTrendInfo)
6382

6483
let overallTrendInsights = insights.filter(v => v.type === InsightType.OverallTrend);
6584
if (overallTrendInsights.length === 0) {

packages/vmind/src/atom/dataInsight/algorithms/revised.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const getBandInsightByOutliear = (context: DataInsightExtractContext, outliearFi
6464
})
6565
.sort((a, b) => a.xIndex - b.xIndex);
6666
let band = [indexOfInsights[0]];
67-
for (let i = 1; i <= indexOfInsights.length; i++) {
67+
for (let i = 0; i <= indexOfInsights.length; i++) {
6868
const curIndex = indexOfInsights[i]?.xIndex;
6969
const prevIndex = band[band.length - 1].xIndex;
7070
if (i < indexOfInsights.length && curIndex - prevIndex === 1) {
@@ -86,7 +86,9 @@ const getBandInsightByOutliear = (context: DataInsightExtractContext, outliearFi
8686
});
8787
bandInsightKeys.push(...band.map(v => v.content.key));
8888
}
89-
band = [indexOfInsights[i]];
89+
if (i < indexOfInsights.length) {
90+
band = [indexOfInsights[i]];
91+
}
9092
}
9193
}
9294
});
@@ -111,6 +113,7 @@ export const mergePointInsight = (
111113
const key = `${data[0].index}-&&&-${seriesName}`;
112114
if (!outliear[key]) {
113115
outliear[key] = [];
116+
114117
if (!outliearFieldMapping[seriesName]) {
115118
outliearFieldMapping[seriesName] = [];
116119
}

packages/vmind/src/atom/specInsight/index.ts

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,21 @@ import { isStackChart } from '../dataInsight/utils';
99
import { Factory } from '../../core/factory';
1010
import type { BaseAtomConstructor } from '../../types';
1111
import type { DataItem } from '@visactor/generate-vchart';
12+
import VChart from '@visactor/vchart';
13+
// 辅助函数:通用值比较
14+
function compareValues(a: any, b: any): number {
15+
// 如果是日期字符串,转换为时间戳比较
16+
if (isDateString(a) && isDateString(b)) {
17+
return new Date(a).getTime() - new Date(b).getTime();
18+
}
19+
// 默认按数字或字符串比较
20+
return a > b ? 1 : a < b ? -1 : 0;
21+
}
1222

23+
// 辅助函数:检测是否是日期字符串
24+
function isDateString(value: any): boolean {
25+
return typeof value === 'string' && (/^\d{4}-\d{2}-\d{2}$/.test(value) || /^[A-Za-z]{3}-\d{2}$/.test(value));
26+
}
1327
export class SpecInsightAtom extends BaseAtom<SpecInsightCtx, SpecInsightOptions> {
1428
name = AtomName.SPEC_INSIGHT;
1529

@@ -267,14 +281,189 @@ export class SpecInsightAtom extends BaseAtom<SpecInsightCtx, SpecInsightOptions
267281
});
268282
}
269283

284+
/** generate markline of Abnormal trend by coordinates */
285+
protected getAbnormalTrendMarkLine(
286+
spec: any,
287+
options: {
288+
series_name: string;
289+
text: string;
290+
isTransposed: boolean;
291+
info: any;
292+
}
293+
) {
294+
if (!spec.markLine) {
295+
spec.markLine = [];
296+
}
297+
298+
const { series_name, text, isTransposed = false, info } = options;
299+
300+
const datavalues = spec.data[0].values;
301+
const xField = Array.isArray(spec.xField) ? spec.xField[0] : spec.xField;
302+
const yField = Array.isArray(spec.yField) ? spec.yField[0] : spec.yField;
303+
const { startDimValue, startValue, endDimValue, endValue } = info;
304+
305+
const coordinates = [
306+
{
307+
[xField]: startDimValue,
308+
[yField]: startValue
309+
},
310+
{
311+
[xField]: endDimValue,
312+
[yField]: endValue
313+
}
314+
];
315+
316+
spec.markLine.push({
317+
type: 'type-step',
318+
coordinates,
319+
connectDirection: 'right',
320+
expandDistance: -100,
321+
line: {
322+
multiSegment: true,
323+
mainSegmentIndex: 1,
324+
style: [
325+
{
326+
lineDash: [2, 2],
327+
stroke: '#000',
328+
lineWidth: 2
329+
},
330+
{
331+
stroke: '#000',
332+
lineWidth: 2
333+
},
334+
{
335+
lineDash: [2, 2],
336+
stroke: '#000',
337+
lineWidth: 2
338+
}
339+
]
340+
},
341+
label: {
342+
position: 'middle',
343+
text: text,
344+
labelBackground: {
345+
padding: { left: 4, right: 4, top: 4, bottom: 4 },
346+
style: {
347+
fill: '#fff',
348+
fillOpacity: 1,
349+
stroke: '#000',
350+
lineWidth: 1,
351+
cornerRadius: 4
352+
}
353+
},
354+
style: {
355+
fill: '#000'
356+
}
357+
},
358+
endSymbol: {
359+
size: 12,
360+
refX: -4
361+
}
362+
});
363+
}
364+
365+
protected getAbnormalBandMarkArea(
366+
spec: any,
367+
options: {
368+
series_name: string;
369+
isTransposed: boolean;
370+
info: any;
371+
text: string;
372+
}
373+
) {
374+
if (!spec.markArea) {
375+
spec.markArea = [];
376+
}
377+
if (!spec.line) {
378+
spec.line = {};
379+
}
380+
381+
const { series_name, isTransposed = false, info, text } = options;
382+
383+
const timeField = Array.isArray(spec.xField) ? spec.xField[0] : spec.xField;
384+
385+
if (!timeField) {
386+
console.error('No time field found in spec.xField');
387+
return;
388+
}
389+
390+
const { startValue, endValue } = options.info;
391+
const datavalues = spec.data[0].values;
392+
393+
// 2. 标记预测数据
394+
datavalues.forEach((item: any) => {
395+
const timeValue = item[timeField];
396+
if (timeValue === undefined) {
397+
return;
398+
}
399+
400+
// 3. 通用比较逻辑(支持字符串、数字或日期)
401+
if (compareValues(timeValue, startValue) >= 0 && compareValues(timeValue, endValue) <= 0) {
402+
item.forecast = true;
403+
}
404+
});
405+
406+
// 添加 lineDash
407+
if (!spec.line.style) {
408+
spec.line.style = {};
409+
}
410+
// spec.line.style.lineDash =
411+
// `__FUNCTION__: (data => {
412+
// if (data.forecast) {
413+
// return [5, 5]; // 预测数据用虚线
414+
// }
415+
// return [0]; // 其他数据用实线
416+
// })`
417+
418+
spec.line.style.lineDash = function (data) {
419+
return data.forecast ? [5, 5] : [0];
420+
};
421+
422+
//console.log("lineDash exists?", typeof spec.line.style.lineDash === 'function'); // 应为 true
423+
424+
if (spec.type === 'waterfall' || spec.type === 'bar') {
425+
spec.markArea.push({
426+
x: startValue,
427+
x1: endValue,
428+
label: {
429+
text: text,
430+
position: 'insideTop',
431+
labelBackground: {
432+
padding: 2,
433+
style: {
434+
fill: '#E8346D'
435+
}
436+
}
437+
}
438+
});
439+
} else {
440+
spec.markArea.push({
441+
x: startValue,
442+
x1: endValue,
443+
label: {
444+
text: text,
445+
position: 'insideTop',
446+
labelBackground: {
447+
padding: 2,
448+
style: {
449+
fill: '#E8346D'
450+
}
451+
}
452+
}
453+
});
454+
}
455+
//console.log(spec.markArea)
456+
}
457+
270458
protected runBeforeLLM(): SpecInsightCtx {
271459
const { spec, insights, chartType } = this.context;
272460
const newSpec = merge({}, spec);
273461
const { cell, transpose } = getCellFromSpec(spec, chartType);
274462
const pointIndexMap: Record<string, boolean> = {};
275463
const isStack = isStackChart(spec, chartType, cell);
276464
insights.forEach(insight => {
277-
const { type, data, value, fieldId, info } = insight;
465+
const { type, data, value, fieldId, info, seriesName, textContent } = insight;
466+
const series_name = Array.isArray(seriesName) ? seriesName[0] : seriesName;
278467
const direction = transpose
279468
? Number(value) >= 0
280469
? 'right'
@@ -329,6 +518,26 @@ export class SpecInsightAtom extends BaseAtom<SpecInsightCtx, SpecInsightOptions
329518
? `+${(info.change * 100).toFixed(1)}%`
330519
: `${(info.change * 100).toFixed(1)}%`
331520
});
521+
break;
522+
case InsightType.AbnormalTrend:
523+
this.getAbnormalTrendMarkLine(newSpec, {
524+
series_name: String(series_name),
525+
isTransposed: transpose,
526+
text:
527+
value === TrendType.INCREASING
528+
? `Abnormal upward trend +${(info.change * 100).toFixed(1)}%`
529+
: `Abnormal downward trend ${(info.change * 100).toFixed(1)}%`,
530+
info: info
531+
});
532+
break;
533+
case InsightType.AbnormalBand:
534+
this.getAbnormalBandMarkArea(newSpec, {
535+
series_name: String(series_name),
536+
isTransposed: transpose,
537+
info: info,
538+
text: textContent.plainText
539+
});
540+
break;
332541
}
333542
});
334543
this.updateContext({

0 commit comments

Comments
 (0)