# zingchart 自製 crosshair & 互動視窗
> **實作目標**
> 在 zingchart 圖表上方實作使用者互動的 crosshair 及顯示數值用的小視窗(plotlabel)。
> crosshair 應隨手指點擊到的線段變色,並跟隨著手指拖曳而移動。而互動視窗固定顯示在 crosshair 上方,並在視窗中顯示 crosshair 經過的即時數值。
>

在使用 zingchart 時,嘗試用官方提供的 crosshair 及 plotlabel 功能來實作客戶需求,但因為官方未提供讓 crosshair 變換顏色的功能(若強制變換 crosshair 顏色會造成閃爍問題),且用來顯示 crosshair 數值用的視窗無法固定顯示位置,因此在 zingchart 上層實作模擬該功能。
---
### 監聽使用者點擊畫面
使用 zingchart 提供的 guide_mousemove event 來監聽使用者點擊圖表線段,其中提供了使用者點擊到的 X Y 軸數值,並在 event.ev 包裹了 touchstart touchmove 事件。
> 在 zingchart 的 crosshair 開啟時 guide_mousemove event 才會被回傳,因此需要在 config 中將其開啟,並設為透明:
> ```json=
> // zingchart render config
> crosshairX: {
> alpha: 0,
> trigger: 'hover',
> plotLabel: {
> text: ``,
> visible: false,
> },
> scaleLabel: {
> visible: false,
> },
> }
> ```
監聽 zingchart 發出的 guide_mousemove 事件
```javascript=
zingchart.guide_mousemove = event => {
const x = event.guide.x;
const age = event.items[0].keyvalue;
const value = event.items[0].value;
// 當使用者點擊到圖表線段時,添加 crosshair 及互動視窗至畫面上
if ( event.ev.type === 'touchstart' ) {
// 取得點擊圖表線段的 index
const plotIdx = event.items[0].plotindex;
// 取得圖表各線段 lineColor 陣列
const graphset = this.chartConfig.data.graphset[0];
const seriesColors = graphset.series.map( item => item.lineColor );
// 計算 crosshair 應顯示的顏色
const lineColor = seriesColors[plotIdx];
// create new crosshair
this.newCrosshair(x, lineColor);
// 準備互動視窗中要顯示的資料
const roiList = this.planDetail.po_PremiumTable.map( item => item.ROI);
roiList.push(null);
const roi = roiList[plotIdx];
const decimalPoint = this.decimalPoint;
// create new plotlabel
this.newPlotLabel({x, roi, age, value, decimalPoint});
// 手指後續拖曳時,更新 crosshair 及互動視窗位置及數值
} else {
this.updateCrosshairPosition(x);
this.updatePlotLabelData({x, age, value});
}
};
```
其中,在使用者點擊圖表(`event.ev.type === 'touchstart'`)時,會添加新的 crosshair 及 plotlabel 至畫面上,否則(`event.ev.type === 'touchmove'`)僅會更新 crosshair 及 plotlabel 的 x 軸位置及資料數值。
---
### Create & Update Crosshair
用 div 繪製模擬 crosshair,容器高度扣除自行設定的 margin 即是 crosshair 的 height。
```javascript=
newCrosshair(x: number, lineColor: string) {
const corsshairHeight = this.chartContainer.nativeElement.clientHeight - this.plotAreaMargin.top - this.plotAreaMargin.bottom;
const crosshair = this.renderer.createElement('div');
this.renderer.addClass(crosshair, 'crosshair');
this.renderer.setStyle(crosshair, 'borderColor', lineColor);
this.renderer.setStyle(crosshair, 'height', `${corsshairHeight}px`);
this.renderer.setStyle(crosshair, 'left', `${x}px`);
this.renderer.appendChild(this.chartContainer.nativeElement, crosshair);
}
```
更新 crosshair 位置
```javascript=
updateCrosshairPosition(x: number) {
const crosshair = this.el.nativeElement.querySelector('.crosshair');
if (crosshair) {
this.renderer.setStyle(crosshair, 'left', `${x}px`);
}
}
```
---
### Create & Update Plotlabel
要將 PlotLabelComponent 動態添加到 dom tree 上,需要使用 angular 提供的 componentFactoryResolver
```javascript=
@ViewChild('plotLabel', {read: ViewContainerRef}) plotLabel: ViewContainerRef;
// ...
newPlotLabel(data) {
const compFactory = this.componentFactoryResolver.resolveComponentFactory(PlotLabelComponent);
this.plotLabelCompRef = this.plotLabel.createComponent(compFactory);
(<PlotLabelComponent>this.plotLabelCompRef.instance).data = data;
}
```
更新 plotlabel 部分資料時,將舊資料與欲更新的資料合併再塞回 PlotLabelComponent 中。
```javascript=
updatePlotLabelData(newData) {
const oldData = (<PlotLabelComponent>this.plotLabelCompRef.instance).data;
(<PlotLabelComponent>this.plotLabelCompRef.instance).data = {...oldData, ...newData};
}
```
---
### Clear Crosshair & Plotlabel
在使用者手指離開螢幕時,crosshair 和 plotlabel 就應該消失,所以需要將清除這兩個元件的功能掛載在 `touchend` event 上
zingchart 的 guide_mousemove event 除了 `touchstart`、`touchmove` 外,並沒有提供 `touchend` event,所以我們利用 `HostListener` 監聽 `touchend` 事件:
```javascript=
@HostListener('touchend', ['$event'])
onTouchEnd(e) {
this.clearCrosshairs();
this.plotLabel.clear(); // ViewContainerRef 提供的 clear method
}
```
為了避免畫面上可能殘留未清除的 crosshair,使用 `querySelectorAll` 選取畫面上所有的 crosshair 一併清理。
```javascript=
clearCrosshairs() {
const crosshairList: NodeList = this.el.nativeElement.querySelectorAll('.crosshair');
if (crosshairList.length) {
Array.from(crosshairList).forEach( node => {
this.renderer.removeChild(this.chartContainer.nativeElement, node);
});
}
}
```