evui 3.4.156 → 3.4.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evui",
3
- "version": "3.4.156",
3
+ "version": "3.4.157",
4
4
  "description": "A EXEM Library project",
5
5
  "author": "exem <dev_client@ex-em.com>",
6
6
  "license": "MIT",
@@ -424,4 +424,27 @@ export default {
424
424
 
425
425
  return `${color}80`;
426
426
  },
427
+
428
+ /**
429
+ * 클릭 좌표(cx, cy)에서 데이터 포인트까지의 거리²를 반환한다.
430
+ * w/h 가 있으면 박스 외벽까지의 거리(내부면 0), 없으면 포인트까지의 유클리드 거리².
431
+ * @param {object} data - 데이터 포인트 (xp, yp, w?, h?)
432
+ * @param {number} cx - 클릭 x 좌표
433
+ * @param {number} cy - 클릭 y 좌표
434
+ * @returns {number}
435
+ */
436
+ calcBoxDistance(data, cx, cy) {
437
+ if (data.w !== null && data.w !== undefined && data.h !== null && data.h !== undefined) {
438
+ const sx = data.xp;
439
+ const sy = data.yp;
440
+ const xMin = Math.min(sx, sx + data.w);
441
+ const xMax = Math.max(sx, sx + data.w);
442
+ const yMin = Math.min(sy, sy + data.h);
443
+ const yMax = Math.max(sy, sy + data.h);
444
+ const dx = Math.max(0, xMin - cx, cx - xMax);
445
+ const dy = Math.max(0, yMin - cy, cy - yMax);
446
+ return dx * dx + dy * dy;
447
+ }
448
+ return (data.xp - cx) ** 2 + (data.yp - cy) ** 2;
449
+ },
427
450
  };
@@ -865,23 +865,68 @@ const modules = {
865
865
  * 선택 우선순위:
866
866
  * 1. directHit (bar 박스 내부 클릭) — 가장 가까운 것
867
867
  * 2. hit (line 포인트 근접 등) — 가장 가까운 것
868
- * 3. hit 전혀 없으면 데이터가 있는 시리즈로 fallback (기존 동작 호환)
869
- *
870
- * 과거에는 "같은 라벨 위에서 값이 가장 큰 시리즈"를 돌려주는 max-value 덮어쓰기 방식이었으나,
871
- * bar + line combo 차트에서 작은 bar를 클릭해도 큰 값의 line이 선택되는 버그의 원인이었다.
872
- * 이번 수정으로 사용자가 실제로 가리킨 시리즈(hit)가 선택되도록 바뀐다.
868
+ * 3. hit 없으면 클릭 좌표에 가장 가까운 시리즈로 fallback (distance 기반)
873
869
  *
874
870
  * @param {array} offset position x and y
875
871
  * @param {boolean} useApproximate if it's true. it'll look for closed item on mouse position
876
872
  * @param {number} dataIndex selected data index
877
873
  * @param {boolean} useSelectLabelOrItem used to display select label/item at tooltip location
874
+ * @param {boolean} disableNullLabelSnap true 이면 all-null 라벨도 그대로 반환 (click/dblclick 용)
878
875
  *
879
876
  * @returns {object} hit item information
880
877
  */
881
- getHitItemByPosition(offset, useApproximate = false, dataIndex, useSelectLabelOrItem = false) {
878
+ getHitItemByPosition(
879
+ offset,
880
+ useApproximate = false,
881
+ dataIndex,
882
+ useSelectLabelOrItem = false,
883
+ disableNullLabelSnap = false,
884
+ ) {
882
885
  const seriesIDs = Object.keys(this.seriesList);
883
886
  const isHorizontal = !!this.options.horizontal;
884
887
 
888
+ const [cx, cy] = offset;
889
+
890
+ // dataIndex 미지정 시 클릭 좌표에 가장 가까운 valid 라벨 인덱스 결정.
891
+ // disableNullLabelSnap=true 이면 all-null 라벨도 후보로 인정.
892
+ let resolvedDataIndex = dataIndex;
893
+ if (resolvedDataIndex === undefined && !useApproximate) {
894
+ const refSeriesID = seriesIDs.find((sId) => {
895
+ const s = this.seriesList[sId];
896
+ return s?.show && s?.data?.length > 0;
897
+ });
898
+ if (refSeriesID) {
899
+ const refData = this.seriesList[refSeriesID].data;
900
+ const clickPos = isHorizontal ? offset[1] : offset[0];
901
+ let nearestDistance = Infinity;
902
+ let nearestIndex = -1;
903
+ for (let i = 0; i < refData.length; i++) {
904
+ const hasValidData = disableNullLabelSnap || seriesIDs.some((sId) => {
905
+ const s = this.seriesList[sId];
906
+ return s?.show && s.data?.[i]?.o !== null && s.data?.[i]?.o !== undefined;
907
+ });
908
+
909
+ const p = refData[i];
910
+ if (hasValidData && p) {
911
+ let labelPos;
912
+ if (isHorizontal) {
913
+ labelPos = p.h ? p.yp + (p.h / 2) : p.yp;
914
+ } else {
915
+ labelPos = p.w ? p.xp + (p.w / 2) : p.xp;
916
+ }
917
+ if (labelPos !== null && labelPos !== undefined) {
918
+ const d = Math.abs(clickPos - labelPos);
919
+ if (d < nearestDistance) {
920
+ nearestDistance = d;
921
+ nearestIndex = i;
922
+ }
923
+ }
924
+ }
925
+ }
926
+ if (nearestIndex !== -1) resolvedDataIndex = nearestIndex;
927
+ }
928
+ }
929
+
885
930
  // hit 기반 결과 (최우선)
886
931
  let hitType = null;
887
932
  let hitLabel = null;
@@ -892,13 +937,14 @@ const modules = {
892
937
  let hitDistance = Infinity;
893
938
  let hasDirectHit = false;
894
939
 
895
- // fallback: hit 전혀 없을 때 사용할 "데이터 있는 시리즈" 정보
940
+ // hit 없을 때 fallback — 값이 있는 시리즈 중 클릭 좌표에 가장 가까운 것.
896
941
  let fallbackType = null;
897
942
  let fallbackLabel = null;
898
943
  let fallbackValuePos = null;
899
944
  let fallbackValue = null;
900
945
  let fallbackSeriesID = '';
901
946
  let fallbackDataIndex = null;
947
+ let fallbackDistance = Infinity;
902
948
 
903
949
  let acc = 0;
904
950
  let useStack = false;
@@ -913,7 +959,7 @@ const modules = {
913
959
  series,
914
960
  offset,
915
961
  isHorizontal,
916
- dataIndex,
962
+ resolvedDataIndex,
917
963
  useSelectLabelOrItem,
918
964
  );
919
965
  const data = item.data;
@@ -942,8 +988,24 @@ const modules = {
942
988
  acc += data.y;
943
989
  }
944
990
 
945
- // fallback 기록: 데이터가 있는 시리즈를 저장
946
- if (fallbackSeriesID === '') {
991
+ // fallback 후보: 값이 있는 시리즈 거리가 가장 가까운 쪽.
992
+ // 값이 null 시리즈는 제외.
993
+ const hasMeaningfulValue = g !== null && g !== undefined && !Number.isNaN(g);
994
+ const hasCoords = data.xp !== null && data.xp !== undefined
995
+ && data.yp !== null && data.yp !== undefined;
996
+ if (hasMeaningfulValue && hasCoords) {
997
+ const distance = Util.calcBoxDistance(data, cx, cy);
998
+ if (fallbackSeriesID === '' || distance < fallbackDistance) {
999
+ fallbackDistance = distance;
1000
+ fallbackType = series.type;
1001
+ fallbackLabel = ldata;
1002
+ fallbackValuePos = lp;
1003
+ fallbackValue = g;
1004
+ fallbackSeriesID = seriesID;
1005
+ fallbackDataIndex = index;
1006
+ }
1007
+ } else if (hasMeaningfulValue && fallbackSeriesID === '') {
1008
+ // 좌표 없는 예외 케이스 — 첫 후보로만 등록
947
1009
  fallbackType = series.type;
948
1010
  fallbackLabel = ldata;
949
1011
  fallbackValuePos = lp;
@@ -988,6 +1050,27 @@ const modules = {
988
1050
  }
989
1051
  }
990
1052
 
1053
+ // all-null 라벨인 경우 label/dataIndex 만 채워 반환 (sId='', value=0).
1054
+ if (
1055
+ disableNullLabelSnap
1056
+ && hitSeriesID === ''
1057
+ && fallbackSeriesID === ''
1058
+ && resolvedDataIndex !== undefined
1059
+ && resolvedDataIndex >= 0
1060
+ ) {
1061
+ const refSeriesID = seriesIDs.find((sId) => {
1062
+ const s = this.seriesList[sId];
1063
+ return s?.show && s?.data?.length > 0;
1064
+ });
1065
+ const refPoint = refSeriesID
1066
+ ? this.seriesList[refSeriesID].data?.[resolvedDataIndex]
1067
+ : null;
1068
+ if (refPoint) {
1069
+ fallbackLabel = isHorizontal ? refPoint.y : refPoint.x;
1070
+ fallbackDataIndex = resolvedDataIndex;
1071
+ }
1072
+ }
1073
+
991
1074
  const hasHit = hitSeriesID !== '';
992
1075
 
993
1076
  return {
@@ -2,6 +2,7 @@ import { numberWithComma } from '@/common/utils';
2
2
  import throttle from '@/common/utils.throttle';
3
3
  import { cloneDeep, defaultsDeep, inRange, isEqual } from 'lodash-es';
4
4
  import dayjs from 'dayjs';
5
+ import Util from '../helpers/helpers.util';
5
6
 
6
7
  const modules = {
7
8
  /**
@@ -180,10 +181,10 @@ const modules = {
180
181
  }
181
182
 
182
183
  const setSelectedItemInfo = () => {
183
- const hitInfo = this.findHitItem(offset);
184
+ const hitInfo = this.findHitItem(offset, true);
184
185
 
185
186
  // 실제 클릭된 아이템의 정보 추출 (hitId가 있으면 해당 아이템, 없으면 첫 번째 아이템)
186
- const hitItemId = hitInfo.hitId || Object.keys(hitInfo.items)[0];
187
+ const hitItemId = hitInfo.hitId ?? Object.keys(hitInfo.items)[0];
187
188
  const hitItem = hitInfo.items[hitItemId];
188
189
 
189
190
  if (hitItem) {
@@ -196,8 +197,8 @@ const modules = {
196
197
  };
197
198
 
198
199
  const setSelectedLabelInfo = (targetAxis) => {
199
- const hitInfo = this.findHitItem(offset);
200
- const hitItemId = hitInfo.hitId || Object.keys(hitInfo.items)[0];
200
+ const hitInfo = this.findHitItem(offset, true);
201
+ const hitItemId = hitInfo.hitId ?? Object.keys(hitInfo.items)[0];
201
202
  const hitItem = hitInfo.items[hitItemId];
202
203
 
203
204
  const {
@@ -220,8 +221,8 @@ const modules = {
220
221
  };
221
222
 
222
223
  const setSelectedSeriesInfo = () => {
223
- const hitInfo = this.findHitItem(offset);
224
- const hitItemId = hitInfo.hitId || Object.keys(hitInfo.items)[0];
224
+ const hitInfo = this.findHitItem(offset, true);
225
+ const hitItemId = hitInfo.hitId ?? Object.keys(hitInfo.items)[0];
225
226
  const hitItem = hitInfo.items[hitItemId];
226
227
 
227
228
  if (hitItemId !== null) {
@@ -294,7 +295,7 @@ const modules = {
294
295
  const useSelectSeries = selectSeriesOpt?.use && selectSeriesOpt?.useClick;
295
296
 
296
297
  const setSelectedItemInfo = () => {
297
- const hitInfo = this.getHitItemByPosition(offset, false);
298
+ const hitInfo = this.getHitItemByPosition(offset, false, undefined, false, true);
298
299
 
299
300
  ({
300
301
  label: args.label,
@@ -337,8 +338,8 @@ const modules = {
337
338
  };
338
339
 
339
340
  const setSelectedSeriesInfo = () => {
340
- const hitInfo = this.findHitItem(offset);
341
- const hitItemId = hitInfo.hitId || Object.keys(hitInfo.items)[0];
341
+ const hitInfo = this.findHitItem(offset, true);
342
+ const hitItemId = hitInfo.hitId ?? Object.keys(hitInfo.items)[0];
342
343
  const hitItem = hitInfo.items[hitItemId];
343
344
 
344
345
  if (hitItemId !== null) {
@@ -904,12 +905,14 @@ const modules = {
904
905
  * maxHighlight: [string, number] | null,
905
906
  * }} hit item information
906
907
  */
907
- findHitItem(offset) {
908
+ findHitItem(offset, disableNullLabelSnap = false) {
908
909
  const sIds = Object.keys(this.seriesList);
909
910
  const items = {};
910
911
  const isHorizontal = !!this.options.horizontal;
911
912
  const ctx = this.tooltipCtx;
912
913
 
914
+ const [cx, cy] = offset;
915
+
913
916
  let hitId = null;
914
917
  let maxs = '';
915
918
  let maxsw = 0;
@@ -917,12 +920,14 @@ const modules = {
917
920
  let maxg = null;
918
921
  let maxSID = null;
919
922
  let minDistance = Infinity;
920
- // directHit(bar 박스 내부 클릭/hover) 시리즈가 발견되었는지 추적.
921
- // 한 번이라도 directHit가 있으면 line의 근접 포인트 히트는 hitId 후보에서 배제된다.
923
+ // directHit 하나라도 있으면 일반 hit 는 hitId 후보에서 배제.
922
924
  let hasDirectHit = false;
925
+ // hit 이 없을 때 거리 기반으로 선택할 fallback (기존 "첫 시리즈 고정" 대체).
926
+ let fallbackId = null;
927
+ let fallbackDistance = Infinity;
923
928
 
924
929
  // 1. 먼저 공통으로 사용할 데이터 인덱스 결정
925
- const targetDataIndex = this.findClosestDataIndex(offset, sIds);
930
+ const targetDataIndex = this.findClosestDataIndex(offset, sIds, disableNullLabelSnap);
926
931
 
927
932
  if (targetDataIndex === -1 && !this.isNotUseIndicator()) {
928
933
  return { items, hitId, maxTip: [maxs, maxv], maxHighlight: null };
@@ -989,35 +994,69 @@ const modules = {
989
994
  maxSID = sId;
990
995
  }
991
996
 
992
- // 마우스 위치와의 거리 계산하여 가장 가까운 시리즈 선택.
993
- // directHit(bar 박스 내부)가 하나라도 있으면 그중에서만 선택하고,
994
- // 라인의 근접 포인트 히트(item.hit=true, directHit=false)는 hitId 후보에서 배제한다.
995
- // bar + line combo 차트에서 작은 bar 클릭 시 큰 값의 line이 잡히던 버그 방지.
997
+ // hit 기반 선택: directHit 최우선, 일반 hit 는 directHit 없을 때만.
996
998
  if (item.hit && item.data.xp !== undefined && item.data.yp !== undefined) {
997
999
  const distance = (item.data.xp - offset[0]) ** 2
998
1000
  + (item.data.yp - offset[1]) ** 2;
999
1001
 
1000
1002
  if (item.directHit) {
1001
- // directHit는 최우선. 여러 directHit 중에서는 가장 가까운 것 선택.
1002
1003
  if (!hasDirectHit || distance < minDistance) {
1003
1004
  minDistance = distance;
1004
1005
  hitId = sId;
1005
1006
  }
1006
1007
  hasDirectHit = true;
1007
1008
  } else if (!hasDirectHit && distance < minDistance) {
1008
- // directHit가 없을 때만 일반 hit 거리 비교
1009
1009
  minDistance = distance;
1010
1010
  hitId = sId;
1011
1011
  }
1012
1012
  }
1013
+
1014
+ // fallback 후보: hit 여부와 무관하게 거리가 가장 가까운 시리즈.
1015
+ // 참고: 이 블록은 outer `if (gdata !== null && gdata !== undefined)` 안에 있어서
1016
+ // 값이 null 인 시리즈는 items 수집 단계에서 이미 걸러진 상태. 별도 null 값 가드 불필요.
1017
+ if (item.data.xp !== undefined && item.data.yp !== undefined
1018
+ && item.data.xp !== null && item.data.yp !== null) {
1019
+ const fbDistance = Util.calcBoxDistance(item.data, cx, cy);
1020
+ if (fbDistance < fallbackDistance) {
1021
+ fallbackDistance = fbDistance;
1022
+ fallbackId = sId;
1023
+ }
1024
+ }
1013
1025
  }
1014
1026
  }
1015
1027
  }
1016
1028
  }
1017
1029
 
1018
- hitId = hitId === null ? Object.keys(items)[0] : hitId;
1030
+ // hit 없으면 거리 기반 fallback, 그것도 없으면 items 키(항상 비어있을 가능성 방어).
1031
+ if (hitId === null) {
1032
+ hitId = fallbackId !== null ? fallbackId : Object.keys(items)[0];
1033
+ }
1019
1034
  const maxHighlight = maxg !== null ? [maxSID, maxg] : null;
1020
1035
 
1036
+ // all-null 라벨인 경우 synthetic items[''] 로 label/index 만 채워 전달.
1037
+ if (disableNullLabelSnap
1038
+ && Object.keys(items).length === 0
1039
+ && targetDataIndex !== -1) {
1040
+ const refSeriesID = sIds.find((sId) => {
1041
+ const s = this.seriesList[sId];
1042
+ return s?.show && s?.data?.length > 0;
1043
+ });
1044
+ const refPoint = refSeriesID
1045
+ ? this.seriesList[refSeriesID].data?.[targetDataIndex]
1046
+ : null;
1047
+ if (refPoint) {
1048
+ items[''] = {
1049
+ id: '',
1050
+ name: '',
1051
+ label: isHorizontal ? refPoint.y : refPoint.x,
1052
+ index: targetDataIndex,
1053
+ axis: { x: 0, y: 0 },
1054
+ data: { o: undefined, x: refPoint.x, y: refPoint.y },
1055
+ };
1056
+ hitId = '';
1057
+ }
1058
+ }
1059
+
1021
1060
  return { items, hitId, maxTip: [maxs, maxv], maxHighlight };
1022
1061
  },
1023
1062
 
@@ -1027,7 +1066,7 @@ const modules = {
1027
1066
  * @param {array} sIds series IDs
1028
1067
  * @returns {number} closest data index
1029
1068
  */
1030
- findClosestDataIndex(offset, sIds) {
1069
+ findClosestDataIndex(offset, sIds, disableNullLabelSnap = false) {
1031
1070
  const [xp, yp] = offset;
1032
1071
  const isHorizontal = !!this.options.horizontal;
1033
1072
  const mousePos = isHorizontal ? yp : xp;
@@ -1075,10 +1114,9 @@ const modules = {
1075
1114
  let closestDistance = Infinity;
1076
1115
  let closestIndex = -1;
1077
1116
 
1078
- // 각 라벨에서 가장 가까운 것 찾기
1117
+ // 각 라벨에서 가장 가까운 것 찾기 (disableNullLabelSnap=true 면 all-null 라벨도 후보)
1079
1118
  for (let i = 0; i < referenceData.length; i++) {
1080
- // 라벨에 유효한 데이터가 있는 시리즈가 하나 이상 있는지 확인
1081
- const hasValidData = sIds.some((sId) => {
1119
+ const hasValidData = disableNullLabelSnap || sIds.some((sId) => {
1082
1120
  const series = this.seriesList[sId];
1083
1121
  return series?.show && series.data?.[i]?.o !== null && series.data?.[i]?.o !== undefined;
1084
1122
  });
@@ -113,7 +113,15 @@ const module = {
113
113
  : this.options.axesY?.[0]?.scrollbar?.resetPosition;
114
114
 
115
115
  if (isUpdateAxesRange) {
116
- this.scrollbar[dir].range = newOpt?.[0]?.range?.length ? [...newOpt?.[0]?.range] : null;
116
+ const newOptRange = newOpt?.[0]?.range;
117
+ const currentRange = this.scrollbar[dir].range;
118
+ if (!isResetPosition && newOptRange?.length && currentRange?.length) {
119
+ // 리사이즈 등으로 range 크기만 변경된 경우, 현재 스크롤 위치(min)를 유지하고 크기만 조정
120
+ const newSize = newOptRange[1] - newOptRange[0];
121
+ this.scrollbar[dir].range = [currentRange[0], currentRange[0] + newSize];
122
+ } else {
123
+ this.scrollbar[dir].range = newOptRange?.length ? [...newOptRange] : null;
124
+ }
117
125
  }
118
126
 
119
127
  if (isResetPosition || updateData) {
@@ -5,12 +5,37 @@ import { truthyNumber } from '@/common/utils';
5
5
  import Scale from './scale';
6
6
  import Util from '../helpers/helpers.util';
7
7
 
8
+ /**
9
+ * scrollbar 사용 시 스크롤마다 labels 전체를 재순회하지 않도록 결과를 캐시
10
+ */
11
+ const stringMinMaxByLabels = new WeakMap();
12
+
8
13
  class StepScale extends Scale {
9
14
  constructor(type, axisOpt, ctx, labels, options) {
10
15
  super(type, axisOpt, ctx, options);
11
16
  this.labels = labels;
12
17
  }
13
18
 
19
+ /**
20
+ * labels 배열의 문자열 min/max를 반환
21
+ * - alignToGridLine: 전달받은 minMax 그대로 사용
22
+ * - scrollbar 사용: WeakMap 캐시를 통해 O(n) → O(1)로 단축
23
+ * - 일반: 매번 getStringMinMax 계산
24
+ * @param {object} minMax 축 min/max 정보 (alignToGridLine 시 사용)
25
+ * @param {object} scrollbarOpt 스크롤바 옵션
26
+ * @returns {{ min: string, max: string }}
27
+ */
28
+ getStepMinMax(minMax, scrollbarOpt) {
29
+ if (this.labelStyle.alignToGridLine) return minMax;
30
+
31
+ if (!scrollbarOpt?.use) return Util.getStringMinMax(this.labels);
32
+
33
+ if (!stringMinMaxByLabels.has(this.labels)) {
34
+ stringMinMaxByLabels.set(this.labels, Util.getStringMinMax(this.labels));
35
+ }
36
+ return stringMinMaxByLabels.get(this.labels);
37
+ }
38
+
14
39
  /**
15
40
  * Calculate min/max value, label and size information for step scale
16
41
  * @param {object} minMax min/max information (unused on step scale)
@@ -20,8 +45,7 @@ class StepScale extends Scale {
20
45
  * @returns {object} min/max value and label
21
46
  */
22
47
  calculateScaleRange(minMax, scrollbarOpt, chartRect) {
23
- const stepMinMax = this.labelStyle.alignToGridLine
24
- ? minMax : Util.getStringMinMax(this.labels);
48
+ const stepMinMax = this.getStepMinMax(minMax, scrollbarOpt);
25
49
  let maxValue = stepMinMax.max;
26
50
  let minValue = stepMinMax.min;
27
51