evui 3.4.154 → 3.4.156

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.154",
3
+ "version": "3.4.156",
4
4
  "description": "A EXEM Library project",
5
5
  "author": "exem <dev_client@ex-em.com>",
6
6
  "license": "MIT",
@@ -268,7 +268,13 @@ class EvChart {
268
268
 
269
269
  this.axesRange = this.getAxesRange();
270
270
  this.labelOffset = this.getLabelOffset();
271
+
271
272
  this.labelRange = this.getAxesLabelRange();
273
+
274
+ if (this.scrollbar?.x?.use || this.scrollbar?.y?.use) {
275
+ this.updateScrollbarPosition();
276
+ }
277
+
272
278
  this.axesSteps = this.calculateSteps();
273
279
 
274
280
  this.adjustXAndYAxisWidth();
@@ -276,10 +282,6 @@ class EvChart {
276
282
  this.drawAxis(hitInfo);
277
283
  this.drawSeries(hitInfo);
278
284
 
279
- if (this.scrollbar?.x?.use || this.scrollbar?.y?.use) {
280
- this.updateScrollbarPosition();
281
- }
282
-
283
285
  this.drawTip();
284
286
 
285
287
  if (
@@ -452,10 +454,10 @@ class EvChart {
452
454
  const lastHitInfo = this.lastHitInfo;
453
455
  const defaultSelectInfo = this.defaultSelectItemInfo;
454
456
 
455
- if (lastHitInfo?.maxIndex || lastHitInfo?.maxIndex === 0) {
457
+ if (lastHitInfo?.dataIndex || lastHitInfo?.dataIndex === 0) {
456
458
  selectInfo = {
457
459
  seriesID: lastHitInfo.sId,
458
- dataIndex: lastHitInfo.maxIndex,
460
+ dataIndex: lastHitInfo.dataIndex,
459
461
  };
460
462
  } else if (defaultSelectInfo?.dataIndex || defaultSelectInfo?.dataIndex === 0) {
461
463
  selectInfo = { ...defaultSelectInfo };
@@ -856,12 +858,18 @@ class EvChart {
856
858
  * @param {boolean} updateData is update data
857
859
  * @returns {undefined}
858
860
  */
859
- updateScrollbar(updateData) {
860
- if (this.scrollbar?.x?.isInit || this.options.axesX?.[0]?.scrollbar?.use) {
861
+ updateScrollbar(updateData, updateByScrollbar) {
862
+ const isForceUpdate = updateByScrollbar || updateData;
863
+ const xUse = this.options.axesX?.[0]?.scrollbar?.use ?? false;
864
+ const yUse = this.options.axesY?.[0]?.scrollbar?.use ?? false;
865
+ const prevXUse = this.scrollbar?.x?.use ?? false;
866
+ const prevYUse = this.scrollbar?.y?.use ?? false;
867
+
868
+ if (xUse !== prevXUse || xUse || (isForceUpdate && xUse)) {
861
869
  this.updateScrollbarInfo('x', updateData);
862
870
  }
863
871
 
864
- if (this.scrollbar?.y?.isInit || this.options.axesY?.[0]?.scrollbar?.use) {
872
+ if (yUse !== prevYUse || yUse || (isForceUpdate && yUse)) {
865
873
  this.updateScrollbarInfo('y', updateData);
866
874
  }
867
875
  }
@@ -886,13 +894,14 @@ class EvChart {
886
894
  updateData,
887
895
  updateTooltip,
888
896
  lightUpdate,
897
+ updateByScrollbar,
889
898
  } = updateInfo;
890
899
 
891
900
  if (!this.isInit) {
892
901
  return;
893
902
  }
894
903
 
895
- this.updateScrollbar(updateData);
904
+ this.updateScrollbar(updateData, updateByScrollbar);
896
905
 
897
906
  this.resetProps();
898
907
 
@@ -115,13 +115,12 @@ class Bar {
115
115
  const startIndex = truthyNumber(minIndex) ? minIndex : 0;
116
116
  const endIndex = truthyNumber(maxIndex) ? maxIndex : this.data.length - 1;
117
117
 
118
- // 스크롤 범위 내에서만 루프 돌림
118
+ this.visibleStartIndex = startIndex;
119
+
119
120
  for (let i = startIndex; i <= endIndex; i++) {
120
- const screenIndex = i - startIndex; // 현재 화면상의 위치 인덱스
121
- const item = this.data[i]; // 실제 데이터 인덱스에 해당하는 항목
121
+ const screenIndex = i - startIndex;
122
+ const item = this.data[i];
122
123
  if (item) {
123
- // 스크롤 offset(minIndex)만큼 보정해서 그리기
124
-
125
124
  const categoryPoint = isHorizontal
126
125
  ? ysp - (cArea * screenIndex) - cPad
127
126
  : xsp + (cArea * screenIndex) + cPad;
@@ -214,11 +213,7 @@ class Bar {
214
213
  item.yp = y; // eslint-disable-line
215
214
  item.w = w; // eslint-disable-line
216
215
  item.h = isHorizontal ? -h : h; // eslint-disable-line
217
- item.index = i; // 실제 데이터 인덱스 (스크롤 offset 포함)
218
-
219
- // 검색(hitInfo) 로직은 this.data[0..filteredCount-1] 범위만 검사하므로,
220
- // 현재 화면에 그린 항목을 배열 앞쪽으로 매핑해준다.
221
- this.data[screenIndex] = item;
216
+ item.index = i;
222
217
  }
223
218
  }
224
219
  }
@@ -294,13 +289,21 @@ class Bar {
294
289
  */
295
290
  findGraphData(offset, isHorizontal, dataIndex, useIndicatorOnLabel) {
296
291
  if (typeof dataIndex === 'number' && this.show && useIndicatorOnLabel) {
297
- const gdata = this.data;
292
+ const barData = this.data;
298
293
  const item = { data: null, hit: false, color: this.color };
299
294
 
300
- if (gdata[dataIndex]) {
301
- item.data = gdata[dataIndex];
302
- item.index = dataIndex;
303
- item.hit = this.isPointInBar(offset, gdata[dataIndex]);
295
+ // dataIndex 현재 화면에 보이는 범위로 clamp하여 stale xp/yp 참조 방지
296
+ const visStart = this.visibleStartIndex ?? 0;
297
+ const visEnd = visStart + (this.filteredCount ?? barData.length) - 1;
298
+ const clampedIndex = Math.max(visStart, Math.min(dataIndex, visEnd));
299
+
300
+ if (barData[clampedIndex]) {
301
+ item.data = barData[clampedIndex];
302
+ item.index = clampedIndex;
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;
@@ -326,10 +329,11 @@ class Bar {
326
329
  const [xp, yp] = offset;
327
330
  const item = { data: null, hit: false, color: this.color };
328
331
  const gdata = this.data;
332
+ const startIdx = this.visibleStartIndex ?? 0;
329
333
  const totalCount = this.filteredCount ?? gdata.length;
330
334
 
331
- let s = 0;
332
- let e = totalCount - 1;
335
+ let s = startIdx;
336
+ let e = startIdx + totalCount - 1;
333
337
 
334
338
  while (s <= e) {
335
339
  const m = Math.floor((s + e) / 2);
@@ -346,6 +350,8 @@ class Bar {
346
350
  item.data = barData;
347
351
  item.index = barData.index;
348
352
  item.hit = this.isPointInBar(offset, barData);
353
+ // bar 박스 내부 클릭은 "직접 박스 히트"로 표시 (findHitItem 우선순위용).
354
+ item.directHit = item.hit;
349
355
  return item;
350
356
  }
351
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;
@@ -339,7 +339,7 @@ const modules = {
339
339
  let labelCount = labelAxes.labels.length;
340
340
  if (scrollbarOpt?.use) {
341
341
  const { range, interval, type } = scrollbarOpt;
342
- const [min, max] = range;
342
+ const [min, max] = range ?? [];
343
343
  if (truthyNumber(min) && truthyNumber(max)) {
344
344
  labelCount = Math.floor((+max - +min) / interval) + 1;
345
345
  startIndex = type === 'step' ? min : labelAxes.labels.findIndex(v => v === +min);
@@ -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,48 @@ 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 (기존 동작 호환)
869
+ *
870
+ * 과거에는 "같은 라벨 위에서 값이 가장 큰 시리즈"를 돌려주는 max-value 덮어쓰기 방식이었으나,
871
+ * bar + line combo 차트에서 작은 bar를 클릭해도 큰 값의 line이 선택되는 버그의 원인이었다.
872
+ * 이번 수정으로 사용자가 실제로 가리킨 시리즈(hit)가 선택되도록 바뀐다.
873
+ *
867
874
  * @param {array} offset position x and y
868
875
  * @param {boolean} useApproximate if it's true. it'll look for closed item on mouse position
869
876
  * @param {number} dataIndex selected data index
870
877
  * @param {boolean} useSelectLabelOrItem used to display select label/item at tooltip location
871
878
  *
872
- * @returns {object} clicked item information
879
+ * @returns {object} hit item information
873
880
  */
874
- getItemByPosition(
875
- offset,
876
- useApproximate = false,
877
- dataIndex,
878
- useSelectLabelOrItem = false,
879
- ) {
881
+ getHitItemByPosition(offset, useApproximate = false, dataIndex, useSelectLabelOrItem = false) {
880
882
  const seriesIDs = Object.keys(this.seriesList);
881
883
  const isHorizontal = !!this.options.horizontal;
882
884
 
883
- let maxType = null;
884
- let maxLabel = null;
885
- let maxValuePos = null;
886
- let maxValue = null;
887
- let maxSeriesID = '';
885
+ // hit 기반 결과 (최우선)
886
+ let hitType = null;
887
+ let hitLabel = null;
888
+ let hitValuePos = null;
889
+ let hitValue = null;
890
+ let hitSeriesID = '';
891
+ let hitDataIndex = null;
892
+ let hitDistance = Infinity;
893
+ let hasDirectHit = false;
894
+
895
+ // fallback: hit이 전혀 없을 때 사용할 "데이터 있는 첫 시리즈" 정보
896
+ let fallbackType = null;
897
+ let fallbackLabel = null;
898
+ let fallbackValuePos = null;
899
+ let fallbackValue = null;
900
+ let fallbackSeriesID = '';
901
+ let fallbackDataIndex = null;
902
+
888
903
  let acc = 0;
889
904
  let useStack = false;
890
- let maxIndex = null;
891
905
 
892
906
  for (let ix = 0; ix < seriesIDs.length; ix++) {
893
907
  const seriesID = seriesIDs[ix];
@@ -907,12 +921,13 @@ const modules = {
907
921
 
908
922
  if (data) {
909
923
  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;
924
+ // pie 차트는 hit detection 체계가 달라 기존 동작 유지 (단일 pie 시리즈가 일반적)
925
+ hitType = item.type;
926
+ hitLabel = seriesID;
927
+ hitSeriesID = seriesID;
928
+ hitValuePos = (data.ea - data.sa) / 2;
929
+ hitValue = data.o;
930
+ hitDataIndex = data.index;
916
931
  } else {
917
932
  const ldata = isHorizontal ? data.y : data.x;
918
933
  const lp = isHorizontal ? data.yp : data.xp;
@@ -927,23 +942,45 @@ const modules = {
927
942
  acc += data.y;
928
943
  }
929
944
 
945
+ // fallback 기록: 데이터가 있는 첫 시리즈를 저장
946
+ if (fallbackSeriesID === '') {
947
+ fallbackType = series.type;
948
+ fallbackLabel = ldata;
949
+ fallbackValuePos = lp;
950
+ fallbackValue = g;
951
+ fallbackSeriesID = seriesID;
952
+ fallbackDataIndex = index;
953
+ }
930
954
 
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;
955
+ // hit 기반 선택: item.hit이 true이고 유효한 좌표가 있을 때만 고려
956
+ if (item.hit && data.xp !== undefined && data.yp !== undefined) {
957
+ const distance = (data.xp - offset[0]) ** 2 + (data.yp - offset[1]) ** 2;
958
+
959
+ if (item.directHit) {
960
+ // 직접 박스 히트는 최우선. 여러 개이면 가장 가까운 것.
961
+ if (!hasDirectHit || distance < hitDistance) {
962
+ hitDistance = distance;
963
+ hitType = series.type;
964
+ hitLabel = ldata;
965
+ hitValuePos = lp;
966
+ hitValue = g;
967
+ hitSeriesID = seriesID;
968
+ hitDataIndex = index;
969
+ }
970
+ hasDirectHit = true;
971
+ } else if (!hasDirectHit) {
972
+ // directHit가 없을 때만 일반 hit 거리 비교 참여
973
+ // (라인 근접 히트가 박스 직접 히트를 이기지 못하도록)
974
+ if (distance < hitDistance) {
975
+ hitDistance = distance;
976
+ hitType = series.type;
977
+ hitLabel = ldata;
978
+ hitValuePos = lp;
979
+ hitValue = g;
980
+ hitSeriesID = seriesID;
981
+ hitDataIndex = index;
982
+ }
939
983
  }
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
984
  }
948
985
  }
949
986
  }
@@ -951,22 +988,24 @@ const modules = {
951
988
  }
952
989
  }
953
990
 
991
+ const hasHit = hitSeriesID !== '';
992
+
954
993
  return {
955
- type: maxType,
956
- label: maxLabel,
957
- pos: maxValuePos,
958
- value: maxValue ?? 0,
959
- sId: maxSeriesID,
994
+ type: hasHit ? hitType : fallbackType,
995
+ label: hasHit ? hitLabel : fallbackLabel,
996
+ pos: hasHit ? hitValuePos : fallbackValuePos,
997
+ value: (hasHit ? hitValue : fallbackValue) ?? 0,
998
+ sId: hasHit ? hitSeriesID : fallbackSeriesID,
960
999
  acc,
961
1000
  useStack,
962
- maxIndex,
1001
+ dataIndex: hasHit ? hitDataIndex : fallbackDataIndex,
963
1002
  };
964
1003
  },
965
1004
 
966
1005
  /**
967
1006
  * @typedef {Object} LabelInfoResult
968
1007
  * @property {number} labelIndex - 선택된 라벨의 인덱스
969
- * @property {object} hitInfo - 해당 위치에서의 히트 정보 (getItemByPosition 반환값)
1008
+ * @property {object} hitInfo - 해당 위치에서의 히트 정보 (getHitItemByPosition 반환값)
970
1009
  */
971
1010
  /**
972
1011
  * Find label info by position x and y
@@ -1040,13 +1079,13 @@ const modules = {
1040
1079
  offsetX = x;
1041
1080
  }
1042
1081
 
1043
- hitInfo = this.getItemByPosition(
1082
+ hitInfo = this.getHitItemByPosition(
1044
1083
  [offsetX, y],
1045
1084
  selectLabel?.useApproximateValue,
1046
1085
  dataIndex,
1047
1086
  true,
1048
1087
  );
1049
- labelIndex = hitInfo.maxIndex ?? -1;
1088
+ labelIndex = hitInfo.dataIndex ?? -1;
1050
1089
  }
1051
1090
 
1052
1091
  return {
@@ -294,13 +294,13 @@ const modules = {
294
294
  const useSelectSeries = selectSeriesOpt?.use && selectSeriesOpt?.useClick;
295
295
 
296
296
  const setSelectedItemInfo = () => {
297
- const hitInfo = this.getItemByPosition(offset, false);
297
+ const hitInfo = this.getHitItemByPosition(offset, false);
298
298
 
299
299
  ({
300
300
  label: args.label,
301
301
  value: args.value,
302
302
  sId: args.seriesId,
303
- maxIndex: args.dataIndex,
303
+ dataIndex: args.dataIndex,
304
304
  acc: args.acc,
305
305
  } = hitInfo);
306
306
 
@@ -308,7 +308,7 @@ const modules = {
308
308
  args.selected = {
309
309
  eventTarget: 'item',
310
310
  seriesId: this.isDeselectItem(hitInfo) ? null : hitInfo.sId,
311
- dataIndex: this.isDeselectItem(hitInfo) ? null : hitInfo.maxIndex,
311
+ dataIndex: this.isDeselectItem(hitInfo) ? null : hitInfo.dataIndex,
312
312
  };
313
313
  }
314
314
  };
@@ -917,6 +917,9 @@ const modules = {
917
917
  let maxg = null;
918
918
  let maxSID = null;
919
919
  let minDistance = Infinity;
920
+ // directHit(bar 박스 내부 클릭/hover) 시리즈가 발견되었는지 추적.
921
+ // 한 번이라도 directHit가 있으면 line의 근접 포인트 히트는 hitId 후보에서 배제된다.
922
+ let hasDirectHit = false;
920
923
 
921
924
  // 1. 먼저 공통으로 사용할 데이터 인덱스 결정
922
925
  const targetDataIndex = this.findClosestDataIndex(offset, sIds);
@@ -986,12 +989,23 @@ const modules = {
986
989
  maxSID = sId;
987
990
  }
988
991
 
989
- // 마우스 위치와의 거리 계산하여 가장 가까운 시리즈 선택
992
+ // 마우스 위치와의 거리 계산하여 가장 가까운 시리즈 선택.
993
+ // directHit(bar 박스 내부)가 하나라도 있으면 그중에서만 선택하고,
994
+ // 라인의 근접 포인트 히트(item.hit=true, directHit=false)는 hitId 후보에서 배제한다.
995
+ // bar + line combo 차트에서 작은 bar 클릭 시 큰 값의 line이 잡히던 버그 방지.
990
996
  if (item.hit && item.data.xp !== undefined && item.data.yp !== undefined) {
991
997
  const distance = (item.data.xp - offset[0]) ** 2
992
998
  + (item.data.yp - offset[1]) ** 2;
993
999
 
994
- if (distance < minDistance) {
1000
+ if (item.directHit) {
1001
+ // directHit는 최우선. 여러 directHit 중에서는 가장 가까운 것 선택.
1002
+ if (!hasDirectHit || distance < minDistance) {
1003
+ minDistance = distance;
1004
+ hitId = sId;
1005
+ }
1006
+ hasDirectHit = true;
1007
+ } else if (!hasDirectHit && distance < minDistance) {
1008
+ // directHit가 없을 때만 일반 hit 거리 비교
995
1009
  minDistance = distance;
996
1010
  hitId = sId;
997
1011
  }
@@ -1622,9 +1636,9 @@ const modules = {
1622
1636
  */
1623
1637
  isDeselectItem(hitInfo) {
1624
1638
  return this.options.selectItem.useDeselectItem
1625
- && hitInfo?.maxIndex === this.defaultSelectItemInfo?.dataIndex
1639
+ && hitInfo?.dataIndex === this.defaultSelectItemInfo?.dataIndex
1626
1640
  && hitInfo?.sId === this.defaultSelectItemInfo?.seriesID
1627
- && !isNaN(hitInfo?.maxIndex);
1641
+ && !isNaN(hitInfo?.dataIndex);
1628
1642
  },
1629
1643
 
1630
1644
  /**
@@ -29,11 +29,15 @@ const module = {
29
29
  scrollbarOpt[key] = merged[key];
30
30
  });
31
31
 
32
- delete scrollbarOpt.savedPosition;
32
+ if (scrollbarOpt.resetPosition) {
33
+ scrollbarOpt.range = axisOpt?.[0]?.range?.length ? [...axisOpt?.[0]?.range] : null;
34
+ this.resetScrollbarSavedPositions(dir);
35
+ }
33
36
 
34
37
  if (!scrollbarOpt.isInit) {
35
38
  scrollbarOpt.type = axisOpt?.[0]?.type;
36
39
  scrollbarOpt.range = axisOpt?.[0]?.range?.length ? [...axisOpt?.[0]?.range] : null;
40
+ this.resetScrollbarSavedPositions(dir);
37
41
 
38
42
  this.initScrollbarRange(dir);
39
43
  this.createScrollbarLayout(dir);
@@ -104,16 +108,18 @@ const module = {
104
108
  const axisOpt = dir === 'x' ? this.axesX : this.axesY;
105
109
  const isUpdateAxesRange = !isEqual(newOpt?.[0]?.range, axisOpt?.[0]?.range);
106
110
  if (isUpdateAxesRange || updateData) {
107
- const isResetPosition = dir === 'x' ? this.options.axesX?.[0]?.scrollbar?.resetPosition : this.options.axesY?.[0]?.scrollbar?.resetPosition;
108
- if (isUpdateAxesRange || isResetPosition) {
111
+ const isResetPosition = dir === 'x'
112
+ ? this.options.axesX?.[0]?.scrollbar?.resetPosition
113
+ : this.options.axesY?.[0]?.scrollbar?.resetPosition;
114
+
115
+ if (isUpdateAxesRange) {
109
116
  this.scrollbar[dir].range = newOpt?.[0]?.range?.length ? [...newOpt?.[0]?.range] : null;
110
- // range가 업데이트되면 저장된 스크롤 위치를 초기화
111
- delete this.scrollbar[dir].savedPosition;
112
- } else if (updateData) {
113
- // 데이터가 업데이트되면 저장된 픽셀 위치는 더 이상 유효하지 않으므로 삭제하여
114
- // 논리적 범위에 따라 다시 계산하도록 합니다.
115
- delete this.scrollbar[dir].savedPosition;
116
117
  }
118
+
119
+ if (isResetPosition || updateData) {
120
+ this.resetScrollbarSavedPositions(dir);
121
+ }
122
+
117
123
  this.initScrollbarRange(dir);
118
124
  }
119
125
  this.scrollbar[dir].use = !!newOpt?.[0].scrollbar?.use;
@@ -244,16 +250,29 @@ const module = {
244
250
  const buttonSize = scrollbarOpt.showButton ? scrollHeight : 0;
245
251
  const trackSize = fullSize - (buttonSize * 2);
246
252
 
247
- // 현재 위치를 보존해야 하는 경우 기존 위치를 저장
248
- let savedThumbPosition = null;
249
- if (preservePosition && scrollbarOpt.savedPosition !== undefined) {
250
- savedThumbPosition = scrollbarOpt.savedPosition;
251
- }
253
+ const thumbSize = this.getScrollbarThumbSize(dir, trackSize);
252
254
 
253
- const thumbSize = this.getScrollbarThumbSize(dir, trackSize, savedThumbPosition);
255
+ // 비율로 저장된 위치가 있으면 새 track 크기에 맞게 복원
256
+ if (preservePosition && scrollbarOpt.savedPositionRatio !== undefined) {
257
+ const maxPosition = Math.max(0, trackSize - thumbSize.size);
258
+ if (scrollbarOpt.savedAtStart) {
259
+ thumbSize.position = 0;
260
+ } else if (scrollbarOpt.savedAtEnd) {
261
+ thumbSize.position = maxPosition;
262
+ } else {
263
+ thumbSize.position = Math.min(scrollbarOpt.savedPositionRatio * trackSize, maxPosition);
264
+ }
265
+ }
254
266
 
255
- // 새로 계산된 위치를 저장
256
- scrollbarOpt.savedPosition = thumbSize.position;
267
+ // 위치를 비율 처음/끝 고정 여부로 저장
268
+ // currentMaxPosition === 0 (thumbSize >= trackSize) 인 경우 저장하지 않음
269
+ // → trackSize가 다시 커졌을 때 이전 savedAtEnd/savedAtStart/savedPositionRatio 상태를 유지
270
+ const currentMaxPosition = Math.max(0, trackSize - thumbSize.size);
271
+ if (currentMaxPosition > 0) {
272
+ scrollbarOpt.savedPositionRatio = thumbSize.position / trackSize;
273
+ scrollbarOpt.savedAtStart = thumbSize.position <= 0;
274
+ scrollbarOpt.savedAtEnd = thumbSize.position >= currentMaxPosition;
275
+ }
257
276
 
258
277
  let scrollbarStyle = 'display: block;';
259
278
  let scrollbarTrackStyle;
@@ -328,9 +347,8 @@ const module = {
328
347
  * get scrollbar thumb size
329
348
  * @param dir axis direction ('x' | 'y')
330
349
  * @param trackSize scrollbar track size
331
- * @param savedThumbPosition 기존 위치를 보존해야 하는 경우 저장된 위치
332
350
  */
333
- getScrollbarThumbSize(dir, trackSize, savedThumbPosition) {
351
+ getScrollbarThumbSize(dir, trackSize) {
334
352
  const scrollbarOpt = this.scrollbar[dir];
335
353
  const [min, max] = scrollbarOpt.range;
336
354
  const axesType = scrollbarOpt.type;
@@ -374,11 +392,6 @@ const module = {
374
392
  scrollbarOpt.steps = steps;
375
393
  scrollbarOpt.interval = interval;
376
394
 
377
- // 기존 위치를 보존해야 하는 경우 저장된 위치를 사용
378
- if (savedThumbPosition !== null) {
379
- thumbPosition = savedThumbPosition;
380
- }
381
-
382
395
  return {
383
396
  size: thumbSize,
384
397
  position: thumbPosition,
@@ -438,16 +451,32 @@ const module = {
438
451
  scrollbarOpt.range = [minValue, maxValue];
439
452
 
440
453
  // 사용자가 스크롤할 때는 저장된 위치를 초기화
441
- delete scrollbarOpt.savedPosition;
454
+ this.resetScrollbarSavedPositions(dir);
442
455
 
443
456
  this.update({
444
457
  updateSeries: false,
445
458
  updateSelTip: { update: false, keepDomain: false },
446
459
  lightUpdate: minValue > 1,
460
+ updateByScrollbar: true,
447
461
  });
448
462
  }
449
463
  },
450
464
 
465
+ /**
466
+ * reset scrollbar saved positions
467
+ * @param dir axis direction ('x' | 'y')
468
+ */
469
+ resetScrollbarSavedPositions(dir) {
470
+ const scrollbarOpt = this.scrollbar[dir];
471
+ if (!scrollbarOpt) {
472
+ return;
473
+ }
474
+
475
+ delete scrollbarOpt.savedPositionRatio;
476
+ delete scrollbarOpt.savedAtStart;
477
+ delete scrollbarOpt.savedAtEnd;
478
+ },
479
+
451
480
  /**
452
481
  * create scroll event
453
482
  */
@@ -662,11 +691,13 @@ const module = {
662
691
  this.scrollbar[dir].range = [movedMin, movedMax];
663
692
 
664
693
  // 사용자가 드래그로 스크롤할 때는 저장된 위치를 초기화
665
- delete this.scrollbar[dir].savedPosition;
694
+ this.resetScrollbarSavedPositions(dir);
666
695
 
667
696
  this.update({
668
697
  updateSeries: false,
669
698
  updateSelTip: { update: false, keepDomain: false },
699
+ lightUpdate: movedMin > 1,
700
+ updateByScrollbar: true,
670
701
  });
671
702
  },
672
703