evui 3.4.203 → 3.4.204

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.203",
3
+ "version": "3.4.204",
4
4
  "description": "A EXEM Library project",
5
5
  "author": "exem <dev_client@ex-em.com>",
6
6
  "license": "MIT",
@@ -61,13 +61,9 @@ class Bar {
61
61
  const minmaxY = axesSteps.y[this.yAxisIndex];
62
62
 
63
63
  let totalCount = this.data.length;
64
- let minIndex;
65
- let maxIndex;
66
- if (isHorizontal) {
67
- [minIndex, maxIndex] = [minmaxY.minIndex, minmaxY.maxIndex];
68
- } else {
69
- [minIndex, maxIndex] = [minmaxX.minIndex, minmaxX.maxIndex];
70
- }
64
+ const [minIndex, maxIndex] = isHorizontal
65
+ ? [minmaxY.minIndex, minmaxY.maxIndex]
66
+ : [minmaxX.minIndex, minmaxX.maxIndex];
71
67
 
72
68
  // minIndex, maxIndex가 유효하면 실제 그릴 데이터 개수로 보정
73
69
  if (truthyNumber(minIndex) && truthyNumber(maxIndex)) {
@@ -103,17 +99,7 @@ class Bar {
103
99
  bArea = cArea > (cPad * 2) ? (cArea - (cPad * 2)) : cArea;
104
100
  bArea = this.isExistGrp ? bArea : bArea / showSeriesCount;
105
101
 
106
- const getSize = () => {
107
- if (typeof thickness === 'string' && /[0-9]+px/.test(thickness)) {
108
- return Math.min(bArea, Number(thickness.replace('px', '')));
109
- }
110
- if (typeof thickness === 'number' && thickness <= 1 && thickness >= 0) {
111
- return Math.ceil(bArea * thickness);
112
- }
113
- return bArea;
114
- };
115
- const size = getSize();
116
-
102
+ const size = this.calculateBarSize(thickness, bArea);
117
103
  w = isHorizontal ? null : size;
118
104
  h = isHorizontal ? size : null;
119
105
 
@@ -141,12 +127,10 @@ class Bar {
141
127
  const item = this.data[i]; // 실제 데이터 인덱스에 해당하는 항목
142
128
  if (item) {
143
129
  // 스크롤 offset(minIndex)만큼 보정해서 그리기
144
- let categoryPoint;
145
- if (isHorizontal) {
146
- categoryPoint = ysp - (cArea * (screenIndex)) - cPad;
147
- } else {
148
- categoryPoint = xsp + (cArea * (screenIndex)) + cPad;
149
- }
130
+
131
+ const categoryPoint = isHorizontal
132
+ ? ysp - (cArea * screenIndex) - cPad
133
+ : xsp + (cArea * screenIndex) + cPad;
150
134
 
151
135
  // 기본 위치 설정
152
136
  if (isHorizontal) {
@@ -328,10 +312,25 @@ class Bar {
328
312
  * Find graph item
329
313
  * @param {array} offset mouse position
330
314
  * @param {boolean} isHorizontal determines if a horizontal option's value
315
+ * @param {number} dataIndex selected label data index
316
+ * @param {boolean} useIndicatorOnLabel
331
317
  *
332
318
  * @returns {object} graph item
333
319
  */
334
- findGraphData(offset, isHorizontal) {
320
+ findGraphData(offset, isHorizontal, dataIndex, useIndicatorOnLabel) {
321
+ if (typeof dataIndex === 'number' && this.show && useIndicatorOnLabel) {
322
+ const gdata = this.data;
323
+ const item = { data: null, hit: false, color: this.color };
324
+
325
+ if (gdata[dataIndex]) {
326
+ item.data = gdata[dataIndex];
327
+ item.index = dataIndex;
328
+ item.hit = this.isPointInBar(offset, gdata[dataIndex]);
329
+ }
330
+
331
+ return item;
332
+ }
333
+
335
334
  return isHorizontal ? this.findGraphRangeCount(offset) : this.findGraphRange(offset);
336
335
  }
337
336
 
@@ -341,12 +340,17 @@ class Bar {
341
340
  *
342
341
  * @returns {object} graph item
343
342
  */
344
- findGraphRange(offset) {
345
- const xp = offset[0];
346
- const yp = offset[1];
343
+ /**
344
+ * Binary search for finding graph item
345
+ * @private
346
+ * @param {array} offset - mouse position
347
+ * @param {boolean} isHorizontal - search orientation
348
+ * @returns {object} graph item
349
+ */
350
+ binarySearchBar(offset, isHorizontal) {
351
+ const [xp, yp] = offset;
347
352
  const item = { data: null, hit: false, color: this.color };
348
353
  const gdata = this.data;
349
-
350
354
  const totalCount = this.filteredCount ?? gdata.length;
351
355
 
352
356
  let s = 0;
@@ -354,20 +358,27 @@ class Bar {
354
358
 
355
359
  while (s <= e) {
356
360
  const m = Math.floor((s + e) / 2);
357
- const sx = gdata[m].xp;
358
- const sy = gdata[m].yp;
359
- const ex = sx + gdata[m].w;
360
- const ey = sy + gdata[m].h;
361
+ const barData = gdata[m];
362
+ const { xp: sx, yp: sy, w, h } = barData;
363
+ const ex = sx + w;
364
+ const ey = sy + h;
365
+
366
+ const inRange = isHorizontal
367
+ ? ((ey <= yp) && (yp <= sy))
368
+ : ((sx <= xp) && (xp <= ex));
369
+
370
+ if (inRange) {
371
+ item.data = barData;
372
+ item.index = barData.index;
373
+ item.hit = this.isPointInBar(offset, barData);
374
+ return item;
375
+ }
361
376
 
362
- if ((sx <= xp) && (xp <= ex)) {
363
- item.data = gdata[m];
364
- item.index = gdata[m].index; // 원본 데이터 인덱스 사용
377
+ const shouldGoRight = isHorizontal
378
+ ? (!(ey < yp))
379
+ : (sx + 4 < xp);
365
380
 
366
- if ((ey <= yp) && (yp <= sy)) {
367
- item.hit = true;
368
- }
369
- return item;
370
- } else if (sx + 4 < xp) {
381
+ if (shouldGoRight) {
371
382
  s = m + 1;
372
383
  } else {
373
384
  e = m - 1;
@@ -377,6 +388,10 @@ class Bar {
377
388
  return item;
378
389
  }
379
390
 
391
+ findGraphRange(offset) {
392
+ return this.binarySearchBar(offset, false);
393
+ }
394
+
380
395
  /**
381
396
  * Find graph item (horizontal)
382
397
  * @param {array} offset mouse position
@@ -384,39 +399,7 @@ class Bar {
384
399
  * @returns {object} graph item
385
400
  */
386
401
  findGraphRangeCount(offset) {
387
- const xp = offset[0];
388
- const yp = offset[1];
389
- const item = { data: null, hit: false, color: this.color };
390
- const gdata = this.data;
391
-
392
- const totalCount = this.filteredCount ?? gdata.length;
393
-
394
- let s = 0;
395
- let e = totalCount - 1;
396
-
397
- while (s <= e) {
398
- const m = Math.floor((s + e) / 2);
399
- const sx = gdata[m].xp;
400
- const sy = gdata[m].yp;
401
- const ex = sx + gdata[m].w;
402
- const ey = sy + gdata[m].h;
403
-
404
- if ((ey <= yp) && (yp <= sy)) {
405
- item.data = gdata[m];
406
- item.index = gdata[m].index; // 원본 데이터 인덱스 사용
407
-
408
- if ((sx <= xp) && (xp <= ex)) {
409
- item.hit = true;
410
- }
411
- return item;
412
- } else if (ey < yp) {
413
- e = m - 1;
414
- } else {
415
- s = m + 1;
416
- }
417
- }
418
-
419
- return item;
402
+ return this.binarySearchBar(offset, true);
420
403
  }
421
404
 
422
405
  /**
@@ -560,10 +543,27 @@ class Bar {
560
543
  ctx.restore();
561
544
  }
562
545
 
546
+ /**
547
+ * Calculate bar size based on thickness
548
+ * @private
549
+ * @param {string|number} thickness - thickness value
550
+ * @param {number} bArea - available bar area
551
+ * @returns {number} calculated size
552
+ */
553
+ calculateBarSize(thickness, bArea) {
554
+ if (typeof thickness === 'string' && /[0-9]+px/.test(thickness)) {
555
+ return Math.min(bArea, Number(thickness.replace('px', '')));
556
+ }
557
+ if (typeof thickness === 'number' && thickness <= 1 && thickness >= 0) {
558
+ return Math.ceil(bArea * thickness);
559
+ }
560
+ return bArea;
561
+ }
562
+
563
563
  drawBar({ ctx, positions }) {
564
- const isHorizontal = this.isHorizontal;
564
+ const { isHorizontal, borderRadius } = this;
565
565
  const isStackBar = 'stackIndex' in this;
566
- const isBorderRadius = this.borderRadius && this.borderRadius > 0;
566
+ const isBorderRadius = borderRadius && borderRadius > 0;
567
567
  const { x, y, w } = positions;
568
568
  const h = isHorizontal ? -positions.h : positions.h;
569
569
 
@@ -587,13 +587,26 @@ class Bar {
587
587
  ctx.restore();
588
588
  }
589
589
 
590
+ /**
591
+ * Check if point is within bar boundaries
592
+ * @param {array} offset - [x, y] mouse position
593
+ * @param {object} barData - bar data object with xp, yp, w, h properties
594
+ * @returns {boolean} true if point is within bar
595
+ */
596
+ isPointInBar(offset, barData) {
597
+ const [xp, yp] = offset;
598
+ const { xp: sx, yp: sy, w, h } = barData;
599
+ const ex = sx + w;
600
+ const ey = sy + h;
601
+
602
+ return (sx <= xp) && (xp <= ex) && (ey <= yp) && (yp <= sy);
603
+ }
604
+
590
605
  drawRoundedRect(ctx, positions) {
591
- const chartRect = this.chartRect;
592
- const labelOffset = this.labelOffset;
593
- const isHorizontal = this.isHorizontal;
606
+ const { chartRect, labelOffset, isHorizontal, borderRadius } = this;
594
607
  const { x, y } = positions;
595
608
  let { w, h } = positions;
596
- let r = this.borderRadius;
609
+ let r = borderRadius;
597
610
 
598
611
  const squarePath = new Path2D();
599
612
  squarePath.rect(
@@ -327,76 +327,149 @@ class Line {
327
327
  const yp = offset[1];
328
328
  const item = { data: null, hit: false, color: this.color };
329
329
  const gdata = this.data.filter(data => !Util.isNullOrUndefined(data.x));
330
- const SPARE_XP = 0.5;
331
330
  const isLinearInterpolation = this.useLinearInterpolation();
332
331
 
333
332
  if (gdata?.length) {
334
333
  if (typeof dataIndex === 'number' && this.show) {
335
334
  item.data = gdata[dataIndex];
336
335
  item.index = dataIndex;
336
+ if (item.data) {
337
+ const point = gdata[dataIndex];
338
+ const yDist = Math.abs(yp - point.yp);
339
+ const directHitThreshold = 15; // 직접 히트 임계값
340
+
341
+ if (yDist <= directHitThreshold) {
342
+ item.hit = true;
343
+ }
344
+ }
337
345
  } else if (typeof this.beforeFindItemIndex === 'number' && this.show && useSelectLabelOrItem) {
338
346
  item.data = gdata[this.beforeFindItemIndex];
339
347
  item.index = this.beforeFindItemIndex;
340
348
  } else {
341
- let s = 0;
342
- let e = gdata.length - 1;
343
- const xpInterval = gdata[1]?.xp - gdata[0].xp < 6 ? 1.5 : 6;
344
-
345
- while (s <= e) {
346
- const m = Math.floor((s + e) / 2);
347
- const x = gdata[m].xp;
348
- const y = gdata[m].yp;
349
-
350
- if (x - xpInterval < xp && xp < x + xpInterval) {
351
- const curXpInterval = gdata[m]?.xp - (gdata[m - 1]?.xp ?? 0);
352
-
353
- if (gdata[m - 1]?.xp && gdata[m + 1]?.xp && curXpInterval > 0) {
354
- const leftXp = xp - gdata[m - 1].xp;
355
- const midXp = Math.abs(xp - gdata[m].xp);
356
- const rightXp = gdata[m + 1].xp - xp;
357
-
358
- if (
359
- Math.abs(this.beforeMouseXp - xp) >= curXpInterval - SPARE_XP
360
- && (this.beforeFindItemIndex === m || midXp === rightXp || midXp === leftXp)
361
- ) {
362
- if (this.beforeMouseXp - xp > 0) {
363
- item.data = gdata[this.beforeFindItemIndex - 1];
364
- item.index = this.beforeFindItemIndex - 1;
365
- } else if (this.beforeMouseXp - xp < 0) {
366
- item.data = gdata[this.beforeFindItemIndex + 1];
367
- item.index = this.beforeFindItemIndex + 1;
368
- } else if (this.beforeMouseYp !== yp) {
369
- item.data = gdata[this.beforeFindItemIndex];
370
- item.index = this.beforeFindItemIndex;
371
- }
372
- } else {
373
- const closeXp = Math.min(leftXp, midXp, rightXp);
374
-
375
- if (closeXp === leftXp) {
376
- item.data = gdata[m - 1];
377
- item.index = m - 1;
378
- } else if (closeXp === rightXp) {
379
- item.data = gdata[m + 1];
380
- item.index = m + 1;
381
- } else {
382
- item.data = gdata[m];
383
- item.index = m;
384
- }
349
+ // Axis 트리거 방식: X축 위치에서 가장 가까운 데이터 포인트 찾기
350
+ let closestXDistance = Infinity;
351
+ let closestIndex = -1;
352
+
353
+ // null이 아닌 유효한 데이터만 필터링
354
+ const validData = [];
355
+ gdata.forEach((point, idx) => {
356
+ if (point.xp !== null && point.yp !== null && point.o !== null) {
357
+ validData.push({ ...point, originalIndex: idx });
358
+ }
359
+ });
360
+
361
+ if (validData.length === 0) {
362
+ return item;
363
+ }
364
+
365
+ // 이진 탐색으로 가장 가까운 포인트 찾기
366
+ let left = 0;
367
+ let right = validData.length - 1;
368
+
369
+ while (left <= right) {
370
+ const mid = Math.floor((left + right) / 2);
371
+ const point = validData[mid];
372
+ const xDistance = Math.abs(xp - point.xp);
373
+
374
+ if (xDistance < closestXDistance) {
375
+ closestXDistance = xDistance;
376
+ closestIndex = point.originalIndex;
377
+ }
378
+
379
+ if (point.xp < xp) {
380
+ left = mid + 1;
381
+ // 다음 포인트도 확인
382
+ if (left < validData.length) {
383
+ const nextDistance = Math.abs(xp - validData[left].xp);
384
+ if (nextDistance < closestXDistance) {
385
+ closestXDistance = nextDistance;
386
+ closestIndex = validData[left].originalIndex;
385
387
  }
386
- } else {
387
- item.data = gdata[m];
388
- item.index = m;
389
388
  }
389
+ } else if (point.xp > xp) {
390
+ right = mid - 1;
391
+ // 이전 포인트도 확인
392
+ if (right >= 0) {
393
+ const prevDistance = Math.abs(xp - validData[right].xp);
394
+ if (prevDistance < closestXDistance) {
395
+ closestXDistance = prevDistance;
396
+ closestIndex = validData[right].originalIndex;
397
+ }
398
+ }
399
+ } else {
400
+ // 정확히 일치하는 경우
401
+ break;
402
+ }
403
+ }
390
404
 
391
- if ((y - 6 <= yp) && (yp <= y + 6)) {
392
- item.hit = true;
405
+ // 이진 탐색 주변 포인트 추가 확인 (정확도 향상)
406
+ const foundIdx = validData.findIndex(p => p.originalIndex === closestIndex);
407
+ if (foundIdx !== -1) {
408
+ // 앞뒤 2개씩 추가 확인
409
+ for (let i = Math.max(0, foundIdx - 2);
410
+ i <= Math.min(validData.length - 1, foundIdx + 2);
411
+ i++) {
412
+ const point = validData[i];
413
+ const xDistance = Math.abs(xp - point.xp);
414
+ if (xDistance < closestXDistance) {
415
+ closestXDistance = xDistance;
416
+ closestIndex = point.originalIndex;
393
417
  }
418
+ }
419
+ }
394
420
 
395
- break;
396
- } else if (x + xpInterval > xp) {
397
- e = m - 1;
421
+ // 가장 가까운 포인트 설정
422
+ if (closestIndex !== -1) {
423
+ // 데이터 간격 계산 - 모든 데이터(null 포함)의 평균 간격 사용
424
+ let avgInterval = 50;
425
+ if (gdata.length > 1) {
426
+ const intervals = [];
427
+ for (let i = 1; i < gdata.length; i++) {
428
+ if (gdata[i].xp !== null && gdata[i - 1].xp !== null) {
429
+ intervals.push(Math.abs(gdata[i].xp - gdata[i - 1].xp));
430
+ }
431
+ }
432
+ if (intervals.length > 0) {
433
+ avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
434
+ }
435
+ }
436
+
437
+ // 두 가지 임계값 설정
438
+ const strictThreshold = avgInterval * 0.3; // 엄격한 임계값: 데이터 간격의 30%
439
+ const relaxedThreshold = avgInterval; // 느슨한 임계값: 데이터 간격 전체
440
+
441
+ // 1. 먼저 엄격한 임계값으로 정확한 매치 확인
442
+ if (closestXDistance <= strictThreshold) {
443
+ // 정확히 일치하거나 매우 가까운 데이터가 있음
444
+ item.data = gdata[closestIndex];
445
+ item.index = closestIndex;
398
446
  } else {
399
- s = m + 1;
447
+ // 2. 정확한 매치가 없을 때, 현재 X 위치 근처에 다른 유효 데이터가 있는지 확인
448
+ let hasNearbyValidData = false;
449
+ for (let i = 0; i < validData.length; i++) {
450
+ const xDist = Math.abs(xp - validData[i].xp);
451
+ if (xDist <= strictThreshold) {
452
+ hasNearbyValidData = true;
453
+ break;
454
+ }
455
+ }
456
+
457
+ // 3. 근처에 다른 유효 데이터가 없을 때만 느슨한 임계값 적용
458
+ if (!hasNearbyValidData && closestXDistance <= relaxedThreshold) {
459
+ item.data = gdata[closestIndex];
460
+ item.index = closestIndex;
461
+ }
462
+ }
463
+
464
+ // Y축 거리를 확인하여 직접 히트 판정
465
+ if (item.data) {
466
+ const point = gdata[closestIndex];
467
+ const yDist = Math.abs(yp - point.yp);
468
+ const directHitThreshold = 15; // 직접 히트 임계값
469
+
470
+ if (yDist <= directHitThreshold) {
471
+ item.hit = true;
472
+ }
400
473
  }
401
474
  }
402
475
  }
@@ -430,40 +503,90 @@ class Line {
430
503
  const item = { data: null, hit: false, color: this.color };
431
504
  const gdata = this.data.filter(data => !Util.isNullOrUndefined(data.x));
432
505
 
506
+ if (!gdata.length) {
507
+ return item;
508
+ }
509
+
510
+ // 동적 감지 범위 계산
511
+ const gap = gdata.length > 1 ? Math.abs(gdata[1]?.xp - gdata[0]?.xp) : 50;
512
+ const xpInterval = Math.max(gap * 0.4, 10); // 데이터 간격의 40% 또는 최소 10px
513
+
433
514
  let s = 0;
434
515
  let e = gdata.length - 1;
516
+ let closestIndex = -1;
517
+ let closestDistance = Infinity;
435
518
 
519
+ // 이진 탐색으로 근처 데이터 찾기
436
520
  while (s <= e) {
437
521
  const m = Math.floor((s + e) / 2);
438
522
  const x = gdata[m].xp;
439
- const y = gdata[m].yp;
440
523
 
441
- if ((x - 2 <= xp) && (xp <= x + 2)) {
442
- item.data = gdata[m];
443
- item.index = m;
524
+ // X 좌표가 감지 범위 내에 있는 경우
525
+ if ((x - xpInterval <= xp) && (xp <= x + xpInterval)) {
526
+ // 중간점 주변 데이터들과 거리 비교
527
+ const checkStart = Math.max(0, m - 2);
528
+ const checkEnd = Math.min(gdata.length - 1, m + 2);
529
+
530
+ for (let i = checkStart; i <= checkEnd; i++) {
531
+ if (gdata[i].xp !== null && gdata[i].yp !== null) {
532
+ const distance = Math.sqrt(
533
+ ((xp - gdata[i].xp) ** 2)
534
+ + ((yp - gdata[i].yp) ** 2),
535
+ );
536
+
537
+ if (distance < closestDistance) {
538
+ closestDistance = distance;
539
+ closestIndex = i;
540
+ }
541
+ }
542
+ }
543
+
544
+ if (closestIndex !== -1) {
545
+ item.data = gdata[closestIndex];
546
+ item.index = closestIndex;
444
547
 
445
- if ((y - 2 <= yp) && (yp <= y + 2)) {
446
- item.hit = true;
548
+ // 매우 가까운 경우 hit으로 표시
549
+ if (closestDistance < 5) {
550
+ item.hit = true;
551
+ }
447
552
  }
448
553
 
449
554
  return item;
450
- } else if (x + 2 < xp) {
555
+ } else if (x + xpInterval < xp) {
556
+ // 마우스가 오른쪽에 있는 경우
451
557
  if (m < e && xp < gdata[m + 1].xp) {
452
558
  const curr = Math.abs(gdata[m].xp - xp);
453
559
  const next = Math.abs(gdata[m + 1].xp - xp);
454
560
 
455
561
  item.data = curr > next ? gdata[m + 1] : gdata[m];
456
562
  item.index = curr > next ? m + 1 : m;
563
+
564
+ // Y 거리도 확인하여 hit 판정
565
+ const selectedPoint = item.data;
566
+ const yDist = Math.abs(yp - selectedPoint.yp);
567
+ if (yDist < 10) {
568
+ item.hit = true;
569
+ }
570
+
457
571
  return item;
458
572
  }
459
573
  s = m + 1;
460
574
  } else {
575
+ // 마우스가 왼쪽에 있는 경우
461
576
  if (m > 0 && xp > gdata[m - 1].xp) {
462
577
  const prev = Math.abs(gdata[m - 1].xp - xp);
463
578
  const curr = Math.abs(gdata[m].xp - xp);
464
579
 
465
580
  item.data = prev > curr ? gdata[m] : gdata[m - 1];
466
581
  item.index = prev > curr ? m : m - 1;
582
+
583
+ // Y 거리도 확인하여 hit 판정
584
+ const selectedPoint = item.data;
585
+ const yDist = Math.abs(yp - selectedPoint.yp);
586
+ if (yDist < 10) {
587
+ item.hit = true;
588
+ }
589
+
467
590
  return item;
468
591
  }
469
592
  e = m - 1;