evui 3.4.155 → 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.155",
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",
@@ -454,10 +454,10 @@ class EvChart {
454
454
  const lastHitInfo = this.lastHitInfo;
455
455
  const defaultSelectInfo = this.defaultSelectItemInfo;
456
456
 
457
- if (lastHitInfo?.maxIndex || lastHitInfo?.maxIndex === 0) {
457
+ if (lastHitInfo?.dataIndex || lastHitInfo?.dataIndex === 0) {
458
458
  selectInfo = {
459
459
  seriesID: lastHitInfo.sId,
460
- dataIndex: lastHitInfo.maxIndex,
460
+ dataIndex: lastHitInfo.dataIndex,
461
461
  };
462
462
  } else if (defaultSelectInfo?.dataIndex || defaultSelectInfo?.dataIndex === 0) {
463
463
  selectInfo = { ...defaultSelectInfo };
@@ -301,6 +301,9 @@ class Bar {
301
301
  item.data = barData[clampedIndex];
302
302
  item.index = clampedIndex;
303
303
  item.hit = this.isPointInBar(offset, barData[clampedIndex]);
304
+ // bar 박스 내부 클릭은 "직접 박스 히트"로 표시.
305
+ // findHitItem에서 line 포인트 근접 히트보다 우선 선택되도록 하기 위함.
306
+ item.directHit = item.hit;
304
307
  }
305
308
 
306
309
  return item;
@@ -347,6 +350,8 @@ class Bar {
347
350
  item.data = barData;
348
351
  item.index = barData.index;
349
352
  item.hit = this.isPointInBar(offset, barData);
353
+ // bar 박스 내부 클릭은 "직접 박스 히트"로 표시 (findHitItem 우선순위용).
354
+ item.directHit = item.hit;
350
355
  return item;
351
356
  }
352
357
 
@@ -328,6 +328,24 @@ class Line {
328
328
  const gdata = this.data.filter(data => !Util.isNullOrUndefined(data.x));
329
329
  const isLinearInterpolation = this.useLinearInterpolation();
330
330
 
331
+ // line 포인트 "정확 히트" 판정용 반경.
332
+ // combo 차트에서 line 포인트 중심을 직격한 경우, 같은 좌표의 bar(directHit)보다
333
+ // line이 우선되도록 item.directHit = true로 표시한다. 그 외(단순 Y축 근접)는 기존처럼 hit만.
334
+ // 포인트 반지름에 기본 포인트 크기(LINE_OPTION.pointSize)만큼의 클릭 여유 마진을 더하고,
335
+ // 시각적으로 하이라이트되는 포인트 반경(highlight.maxSize)을 최소 보장값으로 사용한다.
336
+ const directHitRadius = Math.max(
337
+ (this.pointSize ?? LINE_OPTION.pointSize) + LINE_OPTION.pointSize,
338
+ LINE_OPTION.highlight.maxSize,
339
+ );
340
+ const isLinePointDirectHit = (point) => {
341
+ if (!point || point.xp === undefined || point.yp === undefined) {
342
+ return false;
343
+ }
344
+ const dx = xp - point.xp;
345
+ const dy = yp - point.yp;
346
+ return dx * dx + dy * dy <= directHitRadius * directHitRadius;
347
+ };
348
+
331
349
  if (gdata?.length) {
332
350
  if (typeof dataIndex === 'number' && this.show) {
333
351
  item.data = gdata[dataIndex];
@@ -340,6 +358,10 @@ class Line {
340
358
  if (yDist <= directHitThreshold) {
341
359
  item.hit = true;
342
360
  }
361
+ if (isLinePointDirectHit(point)) {
362
+ item.hit = true;
363
+ item.directHit = true;
364
+ }
343
365
  }
344
366
  } else if (typeof this.beforeFindItemIndex === 'number' && this.beforeFindItemIndex !== -1 && this.show && useSelectLabelOrItem) {
345
367
  item.data = gdata[this.beforeFindItemIndex];
@@ -472,6 +494,10 @@ class Line {
472
494
  if (yDist <= directHitThreshold) {
473
495
  item.hit = true;
474
496
  }
497
+ if (isLinePointDirectHit(point)) {
498
+ item.hit = true;
499
+ item.directHit = true;
500
+ }
475
501
  }
476
502
  }
477
503
  }
@@ -155,7 +155,7 @@ const modules = {
155
155
 
156
156
  if (tipType === 'sel') {
157
157
  if (hitInfo && hitInfo.label !== null) {
158
- lastTip.pos = type === 'bar' ? hitInfo.maxIndex : hitInfo.label;
158
+ lastTip.pos = type === 'bar' ? hitInfo.dataIndex : hitInfo.label;
159
159
  ldata = lastTip.pos;
160
160
  } else if (lastTip.pos !== null) {
161
161
  ldata = lastTip.pos;
@@ -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
  };
@@ -809,12 +809,9 @@ const modules = {
809
809
  return null;
810
810
  }
811
811
 
812
- itemPosition = [this.getItemByPosition(
813
- [dataInfo.xp, dataInfo.yp],
814
- useApproximate,
815
- dataIndex,
816
- true,
817
- )];
812
+ itemPosition = [
813
+ this.getHitItemByPosition([dataInfo.xp, dataInfo.yp], useApproximate, dataIndex, true),
814
+ ];
818
815
  } else {
819
816
  const seriesList = Object.entries(this.seriesList);
820
817
  let firShowSeriesID;
@@ -835,7 +832,7 @@ const modules = {
835
832
  return null;
836
833
  }
837
834
 
838
- return this.getItemByPosition(
835
+ return this.getHitItemByPosition(
839
836
  [dataInfo?.xp ?? 0, dataInfo?.yp ?? 0],
840
837
  useApproximate,
841
838
  idx,
@@ -863,31 +860,94 @@ const modules = {
863
860
  },
864
861
 
865
862
  /**
866
- * Find graph item by position x and y
863
+ * Find the hit item at the given position (x, y).
864
+ *
865
+ * 선택 우선순위:
866
+ * 1. directHit (bar 박스 내부 클릭) — 가장 가까운 것
867
+ * 2. hit (line 포인트 근접 등) — 가장 가까운 것
868
+ * 3. hit 없으면 클릭 좌표에 가장 가까운 시리즈로 fallback (distance 기반)
869
+ *
867
870
  * @param {array} offset position x and y
868
871
  * @param {boolean} useApproximate if it's true. it'll look for closed item on mouse position
869
872
  * @param {number} dataIndex selected data index
870
873
  * @param {boolean} useSelectLabelOrItem used to display select label/item at tooltip location
874
+ * @param {boolean} disableNullLabelSnap true 이면 all-null 라벨도 그대로 반환 (click/dblclick 용)
871
875
  *
872
- * @returns {object} clicked item information
876
+ * @returns {object} hit item information
873
877
  */
874
- getItemByPosition(
878
+ getHitItemByPosition(
875
879
  offset,
876
880
  useApproximate = false,
877
881
  dataIndex,
878
882
  useSelectLabelOrItem = false,
883
+ disableNullLabelSnap = false,
879
884
  ) {
880
885
  const seriesIDs = Object.keys(this.seriesList);
881
886
  const isHorizontal = !!this.options.horizontal;
882
887
 
883
- let maxType = null;
884
- let maxLabel = null;
885
- let maxValuePos = null;
886
- let maxValue = null;
887
- let maxSeriesID = '';
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
+
930
+ // hit 기반 결과 (최우선)
931
+ let hitType = null;
932
+ let hitLabel = null;
933
+ let hitValuePos = null;
934
+ let hitValue = null;
935
+ let hitSeriesID = '';
936
+ let hitDataIndex = null;
937
+ let hitDistance = Infinity;
938
+ let hasDirectHit = false;
939
+
940
+ // hit 없을 때 쓸 fallback — 값이 있는 시리즈 중 클릭 좌표에 가장 가까운 것.
941
+ let fallbackType = null;
942
+ let fallbackLabel = null;
943
+ let fallbackValuePos = null;
944
+ let fallbackValue = null;
945
+ let fallbackSeriesID = '';
946
+ let fallbackDataIndex = null;
947
+ let fallbackDistance = Infinity;
948
+
888
949
  let acc = 0;
889
950
  let useStack = false;
890
- let maxIndex = null;
891
951
 
892
952
  for (let ix = 0; ix < seriesIDs.length; ix++) {
893
953
  const seriesID = seriesIDs[ix];
@@ -899,7 +959,7 @@ const modules = {
899
959
  series,
900
960
  offset,
901
961
  isHorizontal,
902
- dataIndex,
962
+ resolvedDataIndex,
903
963
  useSelectLabelOrItem,
904
964
  );
905
965
  const data = item.data;
@@ -907,12 +967,13 @@ const modules = {
907
967
 
908
968
  if (data) {
909
969
  if (Util.isPieType(item.type)) {
910
- maxLabel = seriesID;
911
- maxSeriesID = seriesID;
912
- maxValuePos = (data.ea - data.sa) / 2;
913
- maxValue = data.o;
914
- maxIndex = data.index;
915
- maxType = item.type;
970
+ // pie 차트는 hit detection 체계가 달라 기존 동작 유지 (단일 pie 시리즈가 일반적)
971
+ hitType = item.type;
972
+ hitLabel = seriesID;
973
+ hitSeriesID = seriesID;
974
+ hitValuePos = (data.ea - data.sa) / 2;
975
+ hitValue = data.o;
976
+ hitDataIndex = data.index;
916
977
  } else {
917
978
  const ldata = isHorizontal ? data.y : data.x;
918
979
  const lp = isHorizontal ? data.yp : data.xp;
@@ -927,23 +988,61 @@ const modules = {
927
988
  acc += data.y;
928
989
  }
929
990
 
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
+ // 좌표 없는 예외 케이스 — 첫 후보로만 등록
1009
+ fallbackType = series.type;
1010
+ fallbackLabel = ldata;
1011
+ fallbackValuePos = lp;
1012
+ fallbackValue = g;
1013
+ fallbackSeriesID = seriesID;
1014
+ fallbackDataIndex = index;
1015
+ }
930
1016
 
931
- if (maxType === 'bar' && useStack) {
932
- if (item.hit) {
933
- maxValue = g;
934
- maxSeriesID = seriesID;
935
- maxIndex = index;
936
- maxLabel = ldata;
937
- maxValuePos = lp;
938
- maxType = series.type;
1017
+ // hit 기반 선택: item.hit이 true이고 유효한 좌표가 있을 때만 고려
1018
+ if (item.hit && data.xp !== undefined && data.yp !== undefined) {
1019
+ const distance = (data.xp - offset[0]) ** 2 + (data.yp - offset[1]) ** 2;
1020
+
1021
+ if (item.directHit) {
1022
+ // 직접 박스 히트는 최우선. 여러 개이면 가장 가까운 것.
1023
+ if (!hasDirectHit || distance < hitDistance) {
1024
+ hitDistance = distance;
1025
+ hitType = series.type;
1026
+ hitLabel = ldata;
1027
+ hitValuePos = lp;
1028
+ hitValue = g;
1029
+ hitSeriesID = seriesID;
1030
+ hitDataIndex = index;
1031
+ }
1032
+ hasDirectHit = true;
1033
+ } else if (!hasDirectHit) {
1034
+ // directHit가 없을 때만 일반 hit 거리 비교 참여
1035
+ // (라인 근접 히트가 박스 직접 히트를 이기지 못하도록)
1036
+ if (distance < hitDistance) {
1037
+ hitDistance = distance;
1038
+ hitType = series.type;
1039
+ hitLabel = ldata;
1040
+ hitValuePos = lp;
1041
+ hitValue = g;
1042
+ hitSeriesID = seriesID;
1043
+ hitDataIndex = index;
1044
+ }
939
1045
  }
940
- } else if (maxValue === null || maxValue <= g) {
941
- maxValue = g;
942
- maxSeriesID = seriesID;
943
- maxLabel = ldata;
944
- maxValuePos = lp;
945
- maxIndex = index;
946
- maxType = series.type;
947
1046
  }
948
1047
  }
949
1048
  }
@@ -951,22 +1050,45 @@ const modules = {
951
1050
  }
952
1051
  }
953
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
+
1074
+ const hasHit = hitSeriesID !== '';
1075
+
954
1076
  return {
955
- type: maxType,
956
- label: maxLabel,
957
- pos: maxValuePos,
958
- value: maxValue ?? 0,
959
- sId: maxSeriesID,
1077
+ type: hasHit ? hitType : fallbackType,
1078
+ label: hasHit ? hitLabel : fallbackLabel,
1079
+ pos: hasHit ? hitValuePos : fallbackValuePos,
1080
+ value: (hasHit ? hitValue : fallbackValue) ?? 0,
1081
+ sId: hasHit ? hitSeriesID : fallbackSeriesID,
960
1082
  acc,
961
1083
  useStack,
962
- maxIndex,
1084
+ dataIndex: hasHit ? hitDataIndex : fallbackDataIndex,
963
1085
  };
964
1086
  },
965
1087
 
966
1088
  /**
967
1089
  * @typedef {Object} LabelInfoResult
968
1090
  * @property {number} labelIndex - 선택된 라벨의 인덱스
969
- * @property {object} hitInfo - 해당 위치에서의 히트 정보 (getItemByPosition 반환값)
1091
+ * @property {object} hitInfo - 해당 위치에서의 히트 정보 (getHitItemByPosition 반환값)
970
1092
  */
971
1093
  /**
972
1094
  * Find label info by position x and y
@@ -1040,13 +1162,13 @@ const modules = {
1040
1162
  offsetX = x;
1041
1163
  }
1042
1164
 
1043
- hitInfo = this.getItemByPosition(
1165
+ hitInfo = this.getHitItemByPosition(
1044
1166
  [offsetX, y],
1045
1167
  selectLabel?.useApproximateValue,
1046
1168
  dataIndex,
1047
1169
  true,
1048
1170
  );
1049
- labelIndex = hitInfo.maxIndex ?? -1;
1171
+ labelIndex = hitInfo.dataIndex ?? -1;
1050
1172
  }
1051
1173
 
1052
1174
  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,13 +295,13 @@ const modules = {
294
295
  const useSelectSeries = selectSeriesOpt?.use && selectSeriesOpt?.useClick;
295
296
 
296
297
  const setSelectedItemInfo = () => {
297
- const hitInfo = this.getItemByPosition(offset, false);
298
+ const hitInfo = this.getHitItemByPosition(offset, false, undefined, false, true);
298
299
 
299
300
  ({
300
301
  label: args.label,
301
302
  value: args.value,
302
303
  sId: args.seriesId,
303
- maxIndex: args.dataIndex,
304
+ dataIndex: args.dataIndex,
304
305
  acc: args.acc,
305
306
  } = hitInfo);
306
307
 
@@ -308,7 +309,7 @@ const modules = {
308
309
  args.selected = {
309
310
  eventTarget: 'item',
310
311
  seriesId: this.isDeselectItem(hitInfo) ? null : hitInfo.sId,
311
- dataIndex: this.isDeselectItem(hitInfo) ? null : hitInfo.maxIndex,
312
+ dataIndex: this.isDeselectItem(hitInfo) ? null : hitInfo.dataIndex,
312
313
  };
313
314
  }
314
315
  };
@@ -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,9 +920,14 @@ const modules = {
917
920
  let maxg = null;
918
921
  let maxSID = null;
919
922
  let minDistance = Infinity;
923
+ // directHit 가 하나라도 있으면 일반 hit 는 hitId 후보에서 배제.
924
+ let hasDirectHit = false;
925
+ // hit 이 없을 때 거리 기반으로 선택할 fallback (기존 "첫 시리즈 고정" 대체).
926
+ let fallbackId = null;
927
+ let fallbackDistance = Infinity;
920
928
 
921
929
  // 1. 먼저 공통으로 사용할 데이터 인덱스 결정
922
- const targetDataIndex = this.findClosestDataIndex(offset, sIds);
930
+ const targetDataIndex = this.findClosestDataIndex(offset, sIds, disableNullLabelSnap);
923
931
 
924
932
  if (targetDataIndex === -1 && !this.isNotUseIndicator()) {
925
933
  return { items, hitId, maxTip: [maxs, maxv], maxHighlight: null };
@@ -986,24 +994,69 @@ const modules = {
986
994
  maxSID = sId;
987
995
  }
988
996
 
989
- // 마우스 위치와의 거리 계산하여 가장 가까운 시리즈 선택
997
+ // hit 기반 선택: directHit 최우선, 일반 hit 는 directHit 없을 때만.
990
998
  if (item.hit && item.data.xp !== undefined && item.data.yp !== undefined) {
991
999
  const distance = (item.data.xp - offset[0]) ** 2
992
1000
  + (item.data.yp - offset[1]) ** 2;
993
1001
 
994
- if (distance < minDistance) {
1002
+ if (item.directHit) {
1003
+ if (!hasDirectHit || distance < minDistance) {
1004
+ minDistance = distance;
1005
+ hitId = sId;
1006
+ }
1007
+ hasDirectHit = true;
1008
+ } else if (!hasDirectHit && distance < minDistance) {
995
1009
  minDistance = distance;
996
1010
  hitId = sId;
997
1011
  }
998
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
+ }
999
1025
  }
1000
1026
  }
1001
1027
  }
1002
1028
  }
1003
1029
 
1004
- 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
+ }
1005
1034
  const maxHighlight = maxg !== null ? [maxSID, maxg] : null;
1006
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
+
1007
1060
  return { items, hitId, maxTip: [maxs, maxv], maxHighlight };
1008
1061
  },
1009
1062
 
@@ -1013,7 +1066,7 @@ const modules = {
1013
1066
  * @param {array} sIds series IDs
1014
1067
  * @returns {number} closest data index
1015
1068
  */
1016
- findClosestDataIndex(offset, sIds) {
1069
+ findClosestDataIndex(offset, sIds, disableNullLabelSnap = false) {
1017
1070
  const [xp, yp] = offset;
1018
1071
  const isHorizontal = !!this.options.horizontal;
1019
1072
  const mousePos = isHorizontal ? yp : xp;
@@ -1061,10 +1114,9 @@ const modules = {
1061
1114
  let closestDistance = Infinity;
1062
1115
  let closestIndex = -1;
1063
1116
 
1064
- // 각 라벨에서 가장 가까운 것 찾기
1117
+ // 각 라벨에서 가장 가까운 것 찾기 (disableNullLabelSnap=true 면 all-null 라벨도 후보)
1065
1118
  for (let i = 0; i < referenceData.length; i++) {
1066
- // 라벨에 유효한 데이터가 있는 시리즈가 하나 이상 있는지 확인
1067
- const hasValidData = sIds.some((sId) => {
1119
+ const hasValidData = disableNullLabelSnap || sIds.some((sId) => {
1068
1120
  const series = this.seriesList[sId];
1069
1121
  return series?.show && series.data?.[i]?.o !== null && series.data?.[i]?.o !== undefined;
1070
1122
  });
@@ -1622,9 +1674,9 @@ const modules = {
1622
1674
  */
1623
1675
  isDeselectItem(hitInfo) {
1624
1676
  return this.options.selectItem.useDeselectItem
1625
- && hitInfo?.maxIndex === this.defaultSelectItemInfo?.dataIndex
1677
+ && hitInfo?.dataIndex === this.defaultSelectItemInfo?.dataIndex
1626
1678
  && hitInfo?.sId === this.defaultSelectItemInfo?.seriesID
1627
- && !isNaN(hitInfo?.maxIndex);
1679
+ && !isNaN(hitInfo?.dataIndex);
1628
1680
  },
1629
1681
 
1630
1682
  /**
@@ -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) {