posts

ECharts-GL 이벤트 처리

Oct 1, 2025 updated Oct 1, 2025 3djavascriptlegacyseovisualization

목차

  1. 이벤트 메커니즘
  2. 3D 공간에서 상호작용

이벤트 메커니즘

ECharts-GL은 사용자 상호작용을 위한 강력한 이벤트 처리 시스템을 제공합니다. 이 시스템은 ECharts의 기본 이벤트 시스템을 확장하여 3D 환경에서의 상호작용을 지원합니다.

일반적인 이벤트 흐름

ECharts-GL에서 이벤트가 처리되는 일반적인 흐름은 다음과 같습니다:

sequenceDiagram
    participant User
    participant DOM
    participant ZRender
    participant ECharts
    participant EChartsGL
    participant Mesh

    User->>DOM: 마우스/터치 이벤트
    DOM->>ZRender: 이벤트 전달
    ZRender->>ECharts: 이벤트 처리
    ECharts->>EChartsGL: GL 이벤트 처리
    EChartsGL->>Mesh: 레이캐스팅
    Mesh-->>EChartsGL: 교차점 반환
    EChartsGL-->>ECharts: 이벤트 결과 전달
    ECharts-->>User: 시각적 피드백

이벤트 처리 단계

  1. 이벤트 캡처: DOM 이벤트(마우스, 터치)가 캡처됩니다.
  2. 좌표 변환: 화면 좌표가 ECharts 내부 좌표로 변환됩니다.
  3. 레이캐스팅: 3D 공간에서 어떤 객체가 선택되었는지 결정합니다.
  4. 이벤트 디스패치: 적절한 컴포넌트나 시리즈에 이벤트가 전달됩니다.
  5. 이벤트 핸들링: 컴포넌트나 시리즈가 이벤트에 응답합니다.
  6. 시각적 피드백: 하이라이트, 툴팁 등의 시각적 피드백이 제공됩니다.

마우스/터치 이벤트 처리

ECharts-GL은 다음과 같은 마우스/터치 이벤트를 처리합니다:

// 이벤트 리스너 등록 예시
chart.on('click', function(params) {
    if (params.componentType === 'series' && params.seriesType === 'bar3D') {
        console.log('Clicked on bar:', params.data);
    }
});

// 지원되는 이벤트 유형
// 'click', 'dblclick', 'mousedown', 'mouseup', 'mousemove',
// 'mouseout', 'mouseover', 'globalout', 'contextmenu'

내부적으로 ECharts-GL은 이벤트를 다음과 같이 처리합니다:

// Cone3DView.js 내부 이벤트 처리 예시
_initHandler: function (seriesModel, api) {
    var data = seriesModel.getData();
    var coneMesh = this._coneMesh;

    var lastDataIndex = -1;
    coneMesh.off('mousemove');
    coneMesh.off('mouseout');

    // 마우스 이동 이벤트 처리
    coneMesh.on('mousemove', function (e) {
        // 레이캐스팅으로 선택된 데이터 인덱스 가져오기
        var dataIndex = coneMesh.geometry.getDataIndexOfVertex(e.triangle[0]);

        if (dataIndex !== lastDataIndex) {
            // 이전 항목 다운플레이, 새 항목 하이라이트
            this._downplay(lastDataIndex);
            this._highlight(dataIndex);

            // 라벨 업데이트
            this._labelsBuilder.updateLabels([dataIndex]);

            // 축 포인터 표시 (좌표계가 cartesian3D인 경우)
            if (seriesModel.coordinateSystem.type === 'cartesian3D') {
                api.dispatchAction({
                    type: 'grid3DShowAxisPointer',
                    value: [
                        data.get('x', dataIndex),
                        data.get('y', dataIndex),
                        data.get('z', dataIndex, true)
                    ]
                });
            }
        }

        lastDataIndex = dataIndex;
        coneMesh.dataIndex = dataIndex;
    }, this);

    // 마우스 아웃 이벤트 처리
    coneMesh.on('mouseout', function (e) {
        this._downplay(lastDataIndex);
        this._labelsBuilder.updateLabels();
        lastDataIndex = -1;
        coneMesh.dataIndex = -1;

        // 축 포인터 숨기기
        if (seriesModel.coordinateSystem.type === 'cartesian3D') {
            api.dispatchAction({
                type: 'grid3DHideAxisPointer'
            });
        }
    }, this);
}

레이캐스팅 구현 방식

레이캐스팅(Raycasting)은 3D 공간에서 마우스 위치에 해당하는 객체를 찾는 기술입니다. ECharts-GL에서는 다음과 같이 구현됩니다:

graph TD
    A[마우스 좌표] --> B[NDC 좌표 변환]
    B --> C[레이 생성]
    C --> D[장면 객체와 교차 테스트]
    D --> E[가장 가까운 교차점 선택]
    E --> F[데이터 인덱스 반환]
// 레이캐스팅 구현 예시
ViewGL.prototype.pickObject = function (x, y) {
    // 정규화된 장치 좌표(NDC)로 변환
    var ndc = new Vector2();
    ndc.x = (x / this.renderer.getWidth()) * 2 - 1;
    ndc.y = -(y / this.renderer.getHeight()) * 2 + 1;

    // 레이 생성
    this.camera.update();
    var ray = new Ray();
    ray.setFromCamera(ndc, this.camera);

    // 교차 테스트
    var intersects = [];
    this.scene.traverse(function (mesh) {
        if (mesh.isRenderable && mesh.isRenderable()) {
            // 메시와 레이의 교차 테스트
            ray.intersectMesh(mesh, intersects);
        }
    });

    // 가장 가까운 교차점 반환
    if (intersects.length) {
        intersects.sort(function (a, b) {
            return a.distance - b.distance;
        });
        return intersects[0];
    }

    return null;
};

이벤트 확장

ECharts-GL은 기본 이벤트 시스템을 확장하여 다양한 상호작용을 구현할 수 있습니다.

컨텍스트 메뉴 구현

3D 차트에 컨텍스트 메뉴를 추가하는 방법은 다음과 같습니다:

// 컨텍스트 메뉴 구현 예시
chart.on('contextmenu', function (params) {
    // 기본 컨텍스트 메뉴 방지
    params.event.event.preventDefault();

    if (params.componentType === 'series') {
        // 컨텍스트 메뉴 표시
        var menu = document.getElementById('custom-context-menu');
        menu.style.display = 'block';
        menu.style.left = params.event.event.clientX + 'px';
        menu.style.top = params.event.event.clientY + 'px';

        // 선택된 데이터 저장
        menu.dataset.dataIndex = params.dataIndex;
        menu.dataset.seriesIndex = params.seriesIndex;
    }
});

// 문서 클릭 시 컨텍스트 메뉴 숨기기
document.addEventListener('click', function () {
    document.getElementById('custom-context-menu').style.display = 'none';
});

// 컨텍스트 메뉴 항목 클릭 처리
document.getElementById('menu-item-detail').addEventListener('click', function () {
    var menu = document.getElementById('custom-context-menu');
    var dataIndex = parseInt(menu.dataset.dataIndex);
    var seriesIndex = parseInt(menu.dataset.seriesIndex);

    // 데이터 세부 정보 표시
    var data = chart.getModel().getSeries()[seriesIndex].getData().get('value', dataIndex);
    console.log('Data detail:', data);

    // 메뉴 숨기기
    menu.style.display = 'none';
});

드래깅 구현

3D 객체를 드래그하는 기능을 구현하는 방법은 다음과 같습니다:

// 드래깅 구현 예시
var isDragging = false;
var dragTarget = null;
var lastMousePosition = null;

// 마우스 다운 이벤트
chart.on('mousedown', function (params) {
    if (params.componentType === 'series' && params.seriesType === 'scatter3D') {
        isDragging = true;
        dragTarget = {
            seriesIndex: params.seriesIndex,
            dataIndex: params.dataIndex
        };
        lastMousePosition = [params.event.offsetX, params.event.offsetY];

        // 드래그 시작 시각적 피드백
        chart.setOption({
            series: [{
                id: params.seriesId,
                data: chart.getOption().series[params.seriesIndex].data.map(function (item, idx) {
                    if (idx === params.dataIndex) {
                        // 드래그 중인 항목 강조
                        return Object.assign({}, item, {
                            itemStyle: {
                                opacity: 0.8,
                                borderWidth: 2,
                                borderColor: '#fff'
                            }
                        });
                    }
                    return item;
                })
            }]
        });
    }
});

// 마우스 이동 이벤트
chart.getZr().on('mousemove', function (e) {
    if (isDragging && dragTarget) {
        // 마우스 이동량 계산
        var deltaX = e.offsetX - lastMousePosition[0];
        var deltaY = e.offsetY - lastMousePosition[1];
        lastMousePosition = [e.offsetX, e.offsetY];

        // 3D 좌표계에서의 이동량 계산
        var series = chart.getModel().getSeries()[dragTarget.seriesIndex];
        var coordSys = series.coordinateSystem;

        // 현재 데이터 가져오기
        var data = series.getData();
        var oldValue = data.getValues(['x', 'y', 'z'], dragTarget.dataIndex);

        // 새 위치 계산 (간단한 예시, 실제로는 더 복잡한 변환 필요)
        var newValue = [
            oldValue[0] + deltaX * 0.01,
            oldValue[1] - deltaY * 0.01,
            oldValue[2]
        ];

        // 데이터 업데이트
        var option = chart.getOption();
        option.series[dragTarget.seriesIndex].data[dragTarget.dataIndex] = newValue;
        chart.setOption(option);
    }
});

// 마우스 업 이벤트
chart.getZr().on('mouseup', function () {
    if (isDragging && dragTarget) {
        // 드래그 종료 시각적 피드백
        var option = chart.getOption();
        var item = option.series[dragTarget.seriesIndex].data[dragTarget.dataIndex];
        if (typeof item === 'object' && item.itemStyle) {
            delete item.itemStyle;
        }
        chart.setOption(option);

        isDragging = false;
        dragTarget = null;
    }
});

3D 공간에서 상호작용

3D 공간에서의 상호작용은 2D와 다른 접근 방식이 필요합니다. ECharts-GL은 다양한 3D 상호작용 방법을 제공합니다.

3D 객체와의 상호작용 구현 방법

객체 선택 및 하이라이트

3D 객체를 선택하고 하이라이트하는 방법은 다음과 같습니다:

// 객체 선택 및 하이라이트 예시
chart.on('click', function (params) {
    if (params.componentType === 'series') {
        // 모든 시리즈 다운플레이
        chart.dispatchAction({
            type: 'downplay',
            seriesIndex: 'all'
        });

        // 선택된 시리즈 하이라이트
        chart.dispatchAction({
            type: 'highlight',
            seriesIndex: params.seriesIndex,
            dataIndex: params.dataIndex
        });

        // 선택된 데이터 정보 표시
        console.log('Selected data:', params.data);
    }
});

내부적으로 하이라이트는 다음과 같이 구현됩니다:

// Cone3DView.js 내부 하이라이트 구현
_highlight: function (dataIndex) {
    var data = this._data;
    if (!data) {
        return;
    }

    var coneIndex = this._coneIndexOfData[dataIndex];
    if (coneIndex < 0) {
        return;
    }

    // 강조 스타일 가져오기
    var itemModel = data.getItemModel(dataIndex);
    var emphasisItemStyleModel = itemModel.getModel('emphasis.itemStyle');
    var emphasisColor = emphasisItemStyleModel.get('color');
    var emphasisOpacity = emphasisItemStyleModel.get('opacity');

    // 기본 색상에서 강조 색상 생성
    if (emphasisColor == null) {
        var color = getItemVisualColor(data, dataIndex);
        emphasisColor = echarts.color.lift(color, -0.4);
    }

    if (emphasisOpacity == null) {
        emphasisOpacity = getItemVisualOpacity(data, dataIndex);
    }

    // 색상 설정
    var colorArr = graphicGL.parseColor(emphasisColor);
    colorArr[3] *= emphasisOpacity;

    // 기하 데이터의 색상 변경
    this._coneMesh.geometry.setColor(coneIndex, colorArr);

    // 화면 갱신
    this._api.getZr().refresh();
}

카메라 제어

ECharts-GL은 OrbitControl을 통해 3D 장면의 카메라를 제어합니다:

// 카메라 제어 옵션 설정
chart.setOption({
    grid3D: {
        viewControl: {
            // 자동 회전
            autoRotate: true,
            // 자동 회전 속도
            autoRotateSpeed: 10,
            // 댐핑 계수 (관성)
            damping: 0.8,
            // 회전 감도
            rotateSensitivity: 1.5,
            // 줌 감도
            zoomSensitivity: 1,
            // 이동 감도
            panSensitivity: 1,
            // 초기 카메라 거리
            distance: 150,
            // 초기 카메라 각도
            alpha: 40,
            beta: 30,
            // 카메라 중심
            center: [0, 0, 0],
            // 최소/최대 거리
            minDistance: 50,
            maxDistance: 300
        }
    }
});

내부적으로 OrbitControl은 다음과 같이 구현됩니다:

// OrbitControl 클래스 구현 (간략화)
var OrbitControl = function (options) {
    this.target = new Vector3();
    this.camera = null;

    // 카메라 각도 및 거리
    this._alpha = 0;
    this._beta = 0;
    this._distance = 100;

    // 애니메이션 상태
    this._animating = false;
    this._zoomSpeed = 0;
    this._rotateSpeed = new Vector2();

    // 이벤트 핸들러
    this._mouseDownHandler = this._mouseDownHandler.bind(this);
    this._mouseWheelHandler = this._mouseWheelHandler.bind(this);
    this._mouseMoveHandler = this._mouseMoveHandler.bind(this);
    this._mouseUpHandler = this._mouseUpHandler.bind(this);

    // 옵션 설정
    if (options) {
        this.setOption(options);
    }
};

OrbitControl.prototype = {
    // 마우스 다운 이벤트 처리
    _mouseDownHandler: function (e) {
        // 마우스 위치 저장
        // 드래그 시작 플래그 설정
    },

    // 마우스 이동 이벤트 처리
    _mouseMoveHandler: function (e) {
        // 마우스 이동량 계산
        // 카메라 각도 업데이트
    },

    // 마우스 휠 이벤트 처리
    _mouseWheelHandler: function (e) {
        // 휠 방향에 따라 줌 인/아웃
    },

    // 카메라 업데이트
    update: function () {
        // 애니메이션 상태 업데이트
        // 카메라 위치 및 방향 계산
        // 카메라 매트릭스 업데이트
    }
};

객체 조작

3D 객체를 조작(이동, 회전, 크기 조절)하는 방법은 다음과 같습니다:

// 객체 조작 예시
var isTransforming = false;
var transformType = null; // 'translate', 'rotate', 'scale'
var transformTarget = null;
var startPosition = null;

// 변환 모드 버튼 이벤트 리스너
document.getElementById('translate-btn').addEventListener('click', function () {
    transformType = 'translate';
    updateTransformUI();
});

document.getElementById('rotate-btn').addEventListener('click', function () {
    transformType = 'rotate';
    updateTransformUI();
});

document.getElementById('scale-btn').addEventListener('click', function () {
    transformType = 'scale';
    updateTransformUI();
});

// 객체 선택
chart.on('click', function (params) {
    if (params.componentType === 'series' && transformType) {
        transformTarget = {
            seriesIndex: params.seriesIndex,
            dataIndex: params.dataIndex
        };

        // 선택된 객체 하이라이트
        chart.dispatchAction({
            type: 'highlight',
            seriesIndex: params.seriesIndex,
            dataIndex: params.dataIndex
        });
    }
});

// 마우스 다운 이벤트
chart.getZr().on('mousedown', function (e) {
    if (transformTarget && transformType) {
        isTransforming = true;
        startPosition = [e.offsetX, e.offsetY];
    }
});

// 마우스 이동 이벤트
chart.getZr().on('mousemove', function (e) {
    if (isTransforming && transformTarget) {
        var deltaX = e.offsetX - startPosition[0];
        var deltaY = e.offsetY - startPosition[1];
        startPosition = [e.offsetX, e.offsetY];

        var option = chart.getOption();
        var series = option.series[transformTarget.seriesIndex];
        var data = series.data[transformTarget.dataIndex];

        // 데이터 형식에 따라 처리
        var newData;
        if (Array.isArray(data)) {
            // 배열 형식 데이터
            newData = data.slice();
        } else {
            // 객체 형식 데이터
            newData = Object.assign({}, data);
        }

        // 변환 유형에 따라 처리
        switch (transformType) {
            case 'translate':
                // 이동 처리
                if (Array.isArray(newData)) {
                    newData[0] += deltaX * 0.1;
                    newData[1] -= deltaY * 0.1;
                } else {
                    newData.value[0] += deltaX * 0.1;
                    newData.value[1] -= deltaY * 0.1;
                }
                break;

            case 'rotate':
                // 회전 처리 (3D 회전은 복잡하므로 간단한 예시만 제공)
                // 실제로는 quaternion이나 rotation matrix를 사용해야 함
                break;

            case 'scale':
                // 크기 조절 처리
                if (series.type === 'bar3D' || series.type === 'cone3D') {
                    // 높이 조절
                    if (Array.isArray(newData)) {
                        newData[2] *= (1 + deltaY * 0.01);
                    } else {
                        newData.value[2] *= (1 + deltaY * 0.01);
                    }
                } else if (series.type === 'scatter3D') {
                    // 심볼 크기 조절
                    if (!newData.symbolSize) {
                        newData.symbolSize = series.symbolSize || 10;
                    }
                    newData.symbolSize *= (1 + deltaY * 0.01);
                }
                break;
        }

        // 데이터 업데이트
        series.data[transformTarget.dataIndex] = newData;
        chart.setOption(option);
    }
});

// 마우스 업 이벤트
chart.getZr().on('mouseup', function () {
    isTransforming = false;
});

// UI 업데이트 함수
function updateTransformUI() {
    document.getElementById('translate-btn').classList.toggle('active', transformType === 'translate');
    document.getElementById('rotate-btn').classList.toggle('active', transformType === 'rotate');
    document.getElementById('scale-btn').classList.toggle('active', transformType === 'scale');
}

고급 상호작용 기법

피킹 및 선택

복잡한 3D 장면에서 정확한 객체 선택을 위한 고급 피킹 기법:

// 고급 피킹 구현 예시
function advancedPicking(chart, x, y) {
    var zr = chart.getZr();
    var egl = zr.__egl;

    // 모든 레이어 순회
    for (var zlevel in egl._layers) {
        var layer = egl._layers[zlevel];

        // 각 뷰 순회
        for (var i = 0; i < layer.views.length; i++) {
            var view = layer.views[i];

            // 레이캐스팅 수행
            var result = view.pickObject(x, y);
            if (result) {
                // 교차점 정보 추출
                var mesh = result.mesh;
                var dataIndex = mesh.geometry.getDataIndexOfVertex(result.triangle[0]);

                // 시리즈 및 컴포넌트 정보 찾기
                var seriesIndex = -1;
                var componentModel = null;

                chart.getModel().eachComponent(function (componentType, model) {
                    if (model.coordinateSystem && model.coordinateSystem.viewGL === view) {
                        componentModel = model;
                    }
                });

                chart.getModel().eachSeries(function (seriesModel, idx) {
                    if (seriesModel.coordinateSystem && 
                        seriesModel.coordinateSystem.viewGL === view) {
                        seriesIndex = idx;
                    }
                });

                return {
                    componentType: componentModel ? componentModel.mainType : null,
                    componentIndex: componentModel ? componentModel.componentIndex : -1,
                    seriesIndex: seriesIndex,
                    dataIndex: dataIndex,
                    mesh: mesh,
                    point: result.point.toArray()
                };
            }
        }
    }

    return null;
}

// 사용 예시
chart.getZr().on('click', function (e) {
    var result = advancedPicking(chart, e.offsetX, e.offsetY);
    if (result) {
        console.log('Picked:', result);

        // 선택된 객체 처리
        if (result.seriesIndex >= 0 && result.dataIndex >= 0) {
            chart.dispatchAction({
                type: 'highlight',
                seriesIndex: result.seriesIndex,
                dataIndex: result.dataIndex
            });
        }
    }
});

제스처 인식

터치 기기에서의 제스처 인식 및 처리:

// 제스처 인식 구현 예시
var hammer = new Hammer(document.getElementById('main'));

// 핀치 제스처 활성화
hammer.get('pinch').set({ enable: true });
hammer.get('rotate').set({ enable: true });

// 핀치 제스처 처리 (줌)
hammer.on('pinch', function (e) {
    var viewControl = chart.getModel().getComponent('grid3D').getModel('viewControl');
    var distance = viewControl.get('distance');

    // 핀치 스케일에 따라 거리 조정
    var newDistance = distance / e.scale;

    // 최소/최대 거리 제한
    var minDistance = viewControl.get('minDistance') || 50;
    var maxDistance = viewControl.get('maxDistance') || 400;
    newDistance = Math.max(minDistance, Math.min(maxDistance, newDistance));

    chart.setOption({
        grid3D: {
            viewControl: {
                distance: newDistance
            }
        }
    });
});

// 회전 제스처 처리
hammer.on('rotate', function (e) {
    var viewControl = chart.getModel().getComponent('grid3D').getModel('viewControl');
    var alpha = viewControl.get('alpha');
    var beta = viewControl.get('beta');

    // 회전 각도에 따라 카메라 각도 조정
    chart.setOption({
        grid3D: {
            viewControl: {
                alpha: alpha - e.rotation * 0.5,
                beta: beta + e.rotation * 0.1
            }
        }
    });
});

// 팬 제스처 처리
hammer.on('pan', function (e) {
    var viewControl = chart.getModel().getComponent('grid3D').getModel('viewControl');
    var center = viewControl.get('center') || [0, 0, 0];

    // 팬 이동에 따라 카메라 중심 조정
    chart.setOption({
        grid3D: {
            viewControl: {
                center: [
                    center[0] - e.deltaX * 0.1,
                    center[1] + e.deltaY * 0.1,
                    center[2]
                ]
            }
        }
    });
});

이러한 이벤트 처리 메커니즘을 통해 ECharts-GL은 사용자에게 풍부한 상호작용 경험을 제공하며, 3D 시각화의 탐색과 분석을 더욱 직관적으로 만듭니다.