@dcg-overseas/number-line 0.1.7 → 0.1.9

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.
Files changed (3) hide show
  1. package/dist/index.cjs +263 -115
  2. package/dist/index.js +263 -115
  3. package/package.json +8 -8
package/dist/index.cjs CHANGED
@@ -489,6 +489,47 @@ function computeLabelStep(containerWidth, range) {
489
489
  const candidates = [1, 2, 5, 10, 20, 25, 50, 100, 200, 500];
490
490
  return candidates.find((c) => c >= raw) ?? candidates[candidates.length - 1];
491
491
  }
492
+ function computeMinorTickStep(majorStep, containerWidth, range) {
493
+ if (majorStep <= 1 || range <= 0 || containerWidth <= 0) return 0;
494
+ const pxPerMajor = containerWidth / range * majorStep;
495
+ const MIN_MINOR_PX = 8;
496
+ for (const divisions of [10, 5, 4, 2]) {
497
+ if (majorStep % divisions !== 0) continue;
498
+ const minorStep = majorStep / divisions;
499
+ if (minorStep < 1) continue;
500
+ if (pxPerMajor / divisions >= MIN_MINOR_PX) return minorStep;
501
+ }
502
+ if (majorStep >= 2 && pxPerMajor / Math.min(majorStep - 1, 5) >= MIN_MINOR_PX) {
503
+ return 1;
504
+ }
505
+ return 0;
506
+ }
507
+ function computeHorizontalLayout(minVal, maxVal) {
508
+ const gutter = 16;
509
+ const charWidth = 8;
510
+ const leftLabel = String(Math.round(minVal));
511
+ const rightLabel = String(Math.round(maxVal));
512
+ return {
513
+ padLeft: gutter,
514
+ padRight: gutter,
515
+ labelInsetLeft: Math.max(8, Math.ceil(leftLabel.length * charWidth / 2) + 2),
516
+ labelInsetRight: Math.max(12, Math.ceil(rightLabel.length * charWidth / 2) + 4)
517
+ };
518
+ }
519
+ function createValueToX(viewMin, viewMax, viewWidth, layout) {
520
+ const range = Math.max(viewMax - viewMin, 1e-6);
521
+ const usable = Math.max(viewWidth - layout.padLeft - layout.padRight, 1);
522
+ const innerUsable = Math.max(
523
+ usable - layout.labelInsetLeft - layout.labelInsetRight,
524
+ 1
525
+ );
526
+ return (value) => layout.padLeft + layout.labelInsetLeft + (value - viewMin) / range * innerUsable;
527
+ }
528
+ function edgeTextAnchor(value, domainMin, domainMax) {
529
+ if (Math.abs(value - domainMin) < 1e-9) return "start";
530
+ if (Math.abs(value - domainMax) < 1e-9) return "end";
531
+ return "middle";
532
+ }
492
533
  const ARC_COLORS = [
493
534
  "#7c3aed",
494
535
  "#0891b2",
@@ -496,6 +537,9 @@ const ARC_COLORS = [
496
537
  "#be185d",
497
538
  "#16a34a"
498
539
  ];
540
+ const ARC_RY_RATIO = 0.9;
541
+ const ARC_ANIMATION_STAGGER_MS = 400;
542
+ const ARC_ANIMATION_DRAW_MS = 500;
499
543
  function gcd(a, b) {
500
544
  let x = Math.max(1, Math.abs(Math.round(a)));
501
545
  let y = Math.max(1, Math.abs(Math.round(b)));
@@ -504,11 +548,11 @@ function gcd(a, b) {
504
548
  }
505
549
  const AxisLayer = React.memo(function AxisLayer2({
506
550
  min,
551
+ max,
507
552
  viewMin,
508
553
  viewMax,
509
554
  axisY,
510
- padLeft,
511
- padRight,
555
+ horizontalLayout,
512
556
  viewWidth,
513
557
  containerWidth,
514
558
  groupSize,
@@ -520,15 +564,17 @@ const AxisLayer = React.memo(function AxisLayer2({
520
564
  }) {
521
565
  const range = viewMax - viewMin;
522
566
  if (range <= 0) return null;
523
- const usable = viewWidth - padLeft - padRight;
524
- const toX = (v) => padLeft + (v - viewMin) / range * usable;
567
+ const toX = createValueToX(viewMin, viewMax, viewWidth, horizontalLayout);
568
+ const axisStartX = toX(viewMin);
569
+ const axisEndX = toX(viewMax);
525
570
  const step = Math.max(1, Math.round(tickStep));
526
571
  const rawLabelStep = computeLabelStep(containerWidth, range);
527
572
  const labelStep = Math.max(step, Math.ceil(rawLabelStep / step) * step);
573
+ const minorStep = showMinorTicks ? computeMinorTickStep(step, containerWidth, range) : 0;
528
574
  const MAX_TICKS = 500;
529
575
  const capStep = Math.max(1, Math.ceil(range / MAX_TICKS));
530
576
  const visibleStep = groupSize > 0 ? gcd(step, groupSize) : step;
531
- const iterStep = showMinorTicks ? capStep : Math.max(capStep, visibleStep);
577
+ const iterStep = showMinorTicks && minorStep > 0 ? Math.max(capStep, gcd(minorStep, capStep)) : Math.max(capStep, visibleStep);
532
578
  const startVal = Math.floor(viewMin / iterStep) * iterStep;
533
579
  const allTicks = [];
534
580
  for (let v = startVal; v <= viewMax; v += iterStep) {
@@ -538,9 +584,9 @@ const AxisLayer = React.memo(function AxisLayer2({
538
584
  /* @__PURE__ */ jsxRuntime.jsx(
539
585
  "line",
540
586
  {
541
- x1: padLeft - 4,
587
+ x1: axisStartX - 4,
542
588
  y1: axisY,
543
- x2: viewWidth - padRight,
589
+ x2: axisEndX,
544
590
  y2: axisY,
545
591
  stroke: "#374151",
546
592
  strokeWidth: 2
@@ -550,16 +596,18 @@ const AxisLayer = React.memo(function AxisLayer2({
550
596
  const x = toX(v);
551
597
  const offset = v - min;
552
598
  const isMajor = offset % step === 0;
599
+ const isOnMinorGrid = minorStep > 0 && Math.abs(offset % minorStep) < 1e-9;
553
600
  const maxGroupBoundary = min + groupCount * groupSize;
554
601
  const isGroupBoundary = groupSize > 0 && offset % groupSize === 0 && v <= maxGroupBoundary;
555
- const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || !isGroupBoundary && !isMajor && showMinorTicks;
602
+ const isMinorTick = isOnMinorGrid && !isMajor && !isGroupBoundary;
603
+ const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || isMinorTick && showMinorTicks;
556
604
  if (!visible) return null;
557
605
  const renderAsGroup = isGroupBoundary && showGroupTicks;
558
606
  const renderAsMajor = !renderAsGroup && isMajor && showMajorTicks;
559
607
  const showLabel = (renderAsGroup || renderAsMajor) && offset % labelStep === 0;
560
608
  const tickH = renderAsGroup ? 7 : renderAsMajor ? 10 : 4;
561
- const strokeW = renderAsGroup ? 1.5 : renderAsMajor ? 1 : 0.5;
562
- const color = renderAsGroup || renderAsMajor ? "#374151" : "#9ca3af";
609
+ const strokeW = renderAsGroup ? 1.5 : renderAsMajor ? 1 : 0.75;
610
+ const color = renderAsGroup || renderAsMajor ? "#374151" : "#6b7280";
563
611
  return /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
564
612
  /* @__PURE__ */ jsxRuntime.jsx(
565
613
  "line",
@@ -577,7 +625,7 @@ const AxisLayer = React.memo(function AxisLayer2({
577
625
  {
578
626
  x,
579
627
  y: axisY + 22,
580
- textAnchor: "middle",
628
+ textAnchor: edgeTextAnchor(v, min, max),
581
629
  fontSize: 11,
582
630
  fill: "#374151",
583
631
  pointerEvents: "none",
@@ -589,6 +637,173 @@ const AxisLayer = React.memo(function AxisLayer2({
589
637
  })
590
638
  ] });
591
639
  });
640
+ function AnimatedArc({
641
+ groupIndex,
642
+ arcPath,
643
+ color,
644
+ isSelected,
645
+ x2,
646
+ textY,
647
+ axisY,
648
+ end,
649
+ min,
650
+ max,
651
+ hitCursor,
652
+ shouldAnimate,
653
+ onArcClick
654
+ }) {
655
+ const measureRef = React.useRef(null);
656
+ const animatedRef = React.useRef(false);
657
+ const [pathLen, setPathLen] = React.useState(null);
658
+ const [dashOffset, setDashOffset] = React.useState(null);
659
+ const [isDrawing, setIsDrawing] = React.useState(false);
660
+ const [showLabels, setShowLabels] = React.useState(!shouldAnimate);
661
+ const [showHitArea, setShowHitArea] = React.useState(!shouldAnimate);
662
+ const strokeColor = isSelected ? "#1d4ed8" : color;
663
+ const arrowHeadH = 7;
664
+ const arrowHeadHW = 4;
665
+ React.useLayoutEffect(() => {
666
+ var _a;
667
+ const len = ((_a = measureRef.current) == null ? void 0 : _a.getTotalLength()) ?? 0;
668
+ if (len <= 0) return;
669
+ setPathLen(len);
670
+ if (!shouldAnimate || animatedRef.current) {
671
+ setDashOffset(0);
672
+ setShowLabels(true);
673
+ setShowHitArea(true);
674
+ setIsDrawing(false);
675
+ return;
676
+ }
677
+ setDashOffset(len);
678
+ setShowLabels(false);
679
+ setShowHitArea(false);
680
+ setIsDrawing(false);
681
+ const delay = groupIndex * ARC_ANIMATION_STAGGER_MS;
682
+ let drawTimer = 0;
683
+ let labelTimer = 0;
684
+ let completeTimer = 0;
685
+ const rafId = requestAnimationFrame(() => {
686
+ requestAnimationFrame(() => {
687
+ drawTimer = window.setTimeout(() => {
688
+ setIsDrawing(true);
689
+ setDashOffset(0);
690
+ }, delay);
691
+ labelTimer = window.setTimeout(
692
+ () => setShowLabels(true),
693
+ delay + ARC_ANIMATION_DRAW_MS
694
+ );
695
+ completeTimer = window.setTimeout(() => {
696
+ animatedRef.current = true;
697
+ setIsDrawing(false);
698
+ setShowHitArea(true);
699
+ }, delay + ARC_ANIMATION_DRAW_MS);
700
+ });
701
+ });
702
+ return () => {
703
+ cancelAnimationFrame(rafId);
704
+ window.clearTimeout(drawTimer);
705
+ window.clearTimeout(labelTimer);
706
+ window.clearTimeout(completeTimer);
707
+ };
708
+ }, [arcPath, groupIndex, shouldAnimate]);
709
+ const strokeTransition = isDrawing ? `stroke-dashoffset ${ARC_ANIMATION_DRAW_MS}ms ease-out` : "none";
710
+ const arcVisible = pathLen !== null && dashOffset !== null;
711
+ return /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
712
+ /* @__PURE__ */ jsxRuntime.jsx(
713
+ "path",
714
+ {
715
+ ref: measureRef,
716
+ d: arcPath,
717
+ fill: "none",
718
+ stroke: "none",
719
+ visibility: "hidden",
720
+ pointerEvents: "none"
721
+ }
722
+ ),
723
+ arcVisible && isSelected && /* @__PURE__ */ jsxRuntime.jsx(
724
+ "path",
725
+ {
726
+ d: arcPath,
727
+ fill: "none",
728
+ stroke: "rgba(0,0,0,0.15)",
729
+ strokeWidth: 14,
730
+ strokeLinecap: "round",
731
+ pointerEvents: "none"
732
+ }
733
+ ),
734
+ arcVisible && /* @__PURE__ */ jsxRuntime.jsx(
735
+ "path",
736
+ {
737
+ d: arcPath,
738
+ fill: "none",
739
+ stroke: strokeColor,
740
+ strokeWidth: isSelected ? 3 : 2,
741
+ strokeDasharray: pathLen,
742
+ strokeDashoffset: dashOffset,
743
+ style: { transition: strokeTransition },
744
+ pointerEvents: "none"
745
+ }
746
+ ),
747
+ arcVisible && showHitArea && /* @__PURE__ */ jsxRuntime.jsx(
748
+ "path",
749
+ {
750
+ d: arcPath,
751
+ fill: "none",
752
+ stroke: "transparent",
753
+ strokeWidth: 20,
754
+ strokeLinecap: "round",
755
+ pointerEvents: "stroke",
756
+ style: { cursor: hitCursor },
757
+ onClick: (e) => {
758
+ onArcClick(groupIndex, e);
759
+ }
760
+ }
761
+ ),
762
+ arcVisible && /* @__PURE__ */ jsxRuntime.jsxs(
763
+ "g",
764
+ {
765
+ opacity: showLabels ? 1 : 0,
766
+ style: { transition: "opacity 220ms ease-out" },
767
+ children: [
768
+ /* @__PURE__ */ jsxRuntime.jsx(
769
+ "text",
770
+ {
771
+ x: x2,
772
+ y: textY,
773
+ textAnchor: edgeTextAnchor(end, min, max),
774
+ fontSize: 10,
775
+ fill: strokeColor,
776
+ fontWeight: "600",
777
+ pointerEvents: "none",
778
+ style: { userSelect: "none", WebkitUserSelect: "none" },
779
+ children: end
780
+ }
781
+ ),
782
+ /* @__PURE__ */ jsxRuntime.jsx(
783
+ "line",
784
+ {
785
+ x1: x2,
786
+ y1: textY + 4,
787
+ x2,
788
+ y2: axisY - arrowHeadH,
789
+ stroke: strokeColor,
790
+ strokeWidth: 1.5,
791
+ pointerEvents: "none"
792
+ }
793
+ ),
794
+ /* @__PURE__ */ jsxRuntime.jsx(
795
+ "polygon",
796
+ {
797
+ points: `${x2},${axisY} ${x2 - arrowHeadHW},${axisY - arrowHeadH} ${x2 + arrowHeadHW},${axisY - arrowHeadH}`,
798
+ fill: strokeColor,
799
+ pointerEvents: "none"
800
+ }
801
+ )
802
+ ]
803
+ }
804
+ )
805
+ ] });
806
+ }
592
807
  const GroupArcsLayer = React.memo(function GroupArcsLayer2({
593
808
  min,
594
809
  max,
@@ -597,8 +812,7 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
597
812
  groupSize,
598
813
  groupCount,
599
814
  axisY,
600
- padLeft,
601
- padRight,
815
+ horizontalLayout,
602
816
  viewWidth,
603
817
  arcMaxHeight,
604
818
  tool,
@@ -608,10 +822,8 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
608
822
  arcColors = ARC_COLORS
609
823
  }) {
610
824
  if (groupSize <= 0 || groupCount <= 0) return null;
611
- const range = viewMax - viewMin;
612
- const usable = viewWidth - padLeft - padRight;
613
- const toX = (v) => padLeft + (v - viewMin) / range * usable;
614
- const clipId = React.useId().replace(/:/g, "_") + "-arcs-clip";
825
+ const shouldAnimate = groupSize > 0 && groupCount > 0;
826
+ const toX = createValueToX(viewMin, viewMax, viewWidth, horizontalLayout);
615
827
  const arcs = [];
616
828
  for (let g = 0; g < groupCount; g++) {
617
829
  if (deletedArcIndices.has(g)) continue;
@@ -622,105 +834,41 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
622
834
  const x1 = toX(start);
623
835
  const x2 = toX(end);
624
836
  const rx = (x2 - x1) / 2;
625
- const ry = Math.min(rx * 0.7, arcMaxHeight);
837
+ const ry = Math.min(rx * ARC_RY_RATIO, arcMaxHeight);
626
838
  const color = arcColors[g % arcColors.length];
627
- const arrowHeadH = 7;
628
- const arrowHeadHW = 4;
629
839
  const MIN_ARROW_SPAN = 18;
630
840
  const textY = Math.min(axisY - ry - 6, axisY - MIN_ARROW_SPAN);
631
841
  const arcPath = buildArcPath({ x1, x2, y: axisY, rx, ry });
632
842
  const isSelected = selectedArcIndices.has(g);
633
843
  const hitCursor = tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default";
634
844
  arcs.push(
635
- /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
636
- isSelected && /* @__PURE__ */ jsxRuntime.jsx(
637
- "path",
638
- {
639
- d: arcPath,
640
- fill: "none",
641
- stroke: "rgba(0,0,0,0.15)",
642
- strokeWidth: 14,
643
- strokeLinecap: "round",
644
- pointerEvents: "none"
645
- }
646
- ),
647
- /* @__PURE__ */ jsxRuntime.jsx(
648
- "path",
649
- {
650
- d: arcPath,
651
- fill: "none",
652
- stroke: isSelected ? "#1d4ed8" : color,
653
- strokeWidth: isSelected ? 3 : 2,
654
- pointerEvents: "none"
655
- }
656
- ),
657
- /* @__PURE__ */ jsxRuntime.jsx(
658
- "path",
659
- {
660
- d: arcPath,
661
- fill: "none",
662
- stroke: "transparent",
663
- strokeWidth: 20,
664
- strokeLinecap: "round",
665
- pointerEvents: "stroke",
666
- style: { cursor: hitCursor },
667
- onClick: (e) => {
668
- if (tool === "eraser" || tool === "none") {
669
- e.stopPropagation();
670
- onArcClick(g, e);
671
- }
845
+ /* @__PURE__ */ jsxRuntime.jsx(
846
+ AnimatedArc,
847
+ {
848
+ groupIndex: g,
849
+ arcPath,
850
+ color,
851
+ isSelected,
852
+ x2,
853
+ textY,
854
+ axisY,
855
+ end,
856
+ min,
857
+ max,
858
+ hitCursor,
859
+ shouldAnimate,
860
+ onArcClick: (index, e) => {
861
+ if (tool === "eraser" || tool === "none") {
862
+ e.stopPropagation();
863
+ onArcClick(index, e);
672
864
  }
673
865
  }
674
- ),
675
- /* @__PURE__ */ jsxRuntime.jsx(
676
- "text",
677
- {
678
- x: x2,
679
- y: textY,
680
- textAnchor: "middle",
681
- fontSize: 10,
682
- fill: isSelected ? "#1d4ed8" : color,
683
- fontWeight: "600",
684
- pointerEvents: "none",
685
- style: { userSelect: "none", WebkitUserSelect: "none" },
686
- children: end
687
- }
688
- ),
689
- /* @__PURE__ */ jsxRuntime.jsx(
690
- "line",
691
- {
692
- x1: x2,
693
- y1: textY + 4,
694
- x2,
695
- y2: axisY - arrowHeadH,
696
- stroke: isSelected ? "#1d4ed8" : color,
697
- strokeWidth: 1.5,
698
- pointerEvents: "none"
699
- }
700
- ),
701
- /* @__PURE__ */ jsxRuntime.jsx(
702
- "polygon",
703
- {
704
- points: `${x2},${axisY} ${x2 - arrowHeadHW},${axisY - arrowHeadH} ${x2 + arrowHeadHW},${axisY - arrowHeadH}`,
705
- fill: isSelected ? "#1d4ed8" : color,
706
- pointerEvents: "none"
707
- }
708
- )
709
- ] }, g)
866
+ },
867
+ g
868
+ )
710
869
  );
711
870
  }
712
- return /* @__PURE__ */ jsxRuntime.jsxs("g", { className: "nl-arcs-layer", children: [
713
- /* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsx("clipPath", { id: clipId, children: /* @__PURE__ */ jsxRuntime.jsx(
714
- "rect",
715
- {
716
- x: padLeft,
717
- y: 0,
718
- width: usable,
719
- height: axisY + 1
720
- }
721
- ) }) }),
722
- /* @__PURE__ */ jsxRuntime.jsx("g", { clipPath: `url(#${clipId})`, children: arcs })
723
- ] });
871
+ return /* @__PURE__ */ jsxRuntime.jsx("g", { className: "nl-arcs-layer", children: arcs });
724
872
  });
725
873
  function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
726
874
  return /* @__PURE__ */ jsxRuntime.jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
@@ -788,9 +936,6 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
788
936
  )
789
937
  ] });
790
938
  }
791
- const PAD_LEFT_LEGACY = 30;
792
- const PAD_RIGHT_LEGACY = 40;
793
- const PAD_X = (PAD_LEFT_LEGACY + PAD_RIGHT_LEGACY) / 2;
794
939
  const LABEL_SPACE_BELOW_AXIS = 42;
795
940
  const LABEL_ABOVE_AXIS_SLOP = 32;
796
941
  const ARC_TOP_PADDING = 16;
@@ -829,12 +974,16 @@ function NumberLine({ className }) {
829
974
  } = useNumberLineContext();
830
975
  const w = containerWidth;
831
976
  const h = containerHeight;
977
+ const horizontalLayout = computeHorizontalLayout(min, max);
832
978
  const range = Math.max(viewMax - viewMin, 1e-6);
833
- const usable = Math.max(w - 2 * PAD_X, 1);
979
+ const usable = Math.max(
980
+ w - horizontalLayout.padLeft - horizontalLayout.padRight - horizontalLayout.labelInsetLeft - horizontalLayout.labelInsetRight,
981
+ 1
982
+ );
834
983
  let ryTypical = MIN_ARC_HEIGHT * 0.5;
835
984
  if (groupSize > 0 && groupCount > 0) {
836
985
  const rx = usable * groupSize / range / 2;
837
- ryTypical = Math.min(rx * 0.7, h * 0.48);
986
+ ryTypical = Math.min(rx * ARC_RY_RATIO, h * 0.48);
838
987
  }
839
988
  const minAxisY = MIN_ARC_HEIGHT + ARC_TOP_PADDING;
840
989
  const maxAxisY = h - LABEL_SPACE_BELOW_AXIS;
@@ -986,7 +1135,8 @@ function NumberLine({ className }) {
986
1135
  display: "block",
987
1136
  outline: enableZoom && isZoomActive ? "2px solid rgba(59, 130, 246, 0.5)" : "none",
988
1137
  cursor: svgCursor,
989
- touchAction: "none"
1138
+ touchAction: "none",
1139
+ overflow: "visible"
990
1140
  },
991
1141
  onPointerDown: handlePointerDown,
992
1142
  onPointerMove: handlePointerMove,
@@ -1006,8 +1156,7 @@ function NumberLine({ className }) {
1006
1156
  viewMin,
1007
1157
  viewMax,
1008
1158
  axisY,
1009
- padLeft: PAD_X,
1010
- padRight: PAD_X,
1159
+ horizontalLayout,
1011
1160
  viewWidth: w,
1012
1161
  containerWidth: w,
1013
1162
  groupSize,
@@ -1028,8 +1177,7 @@ function NumberLine({ className }) {
1028
1177
  groupSize,
1029
1178
  groupCount,
1030
1179
  axisY,
1031
- padLeft: PAD_X,
1032
- padRight: PAD_X,
1180
+ horizontalLayout,
1033
1181
  viewWidth: w,
1034
1182
  arcMaxHeight,
1035
1183
  tool,
package/dist/index.js CHANGED
@@ -487,6 +487,47 @@ function computeLabelStep(containerWidth, range) {
487
487
  const candidates = [1, 2, 5, 10, 20, 25, 50, 100, 200, 500];
488
488
  return candidates.find((c) => c >= raw) ?? candidates[candidates.length - 1];
489
489
  }
490
+ function computeMinorTickStep(majorStep, containerWidth, range) {
491
+ if (majorStep <= 1 || range <= 0 || containerWidth <= 0) return 0;
492
+ const pxPerMajor = containerWidth / range * majorStep;
493
+ const MIN_MINOR_PX = 8;
494
+ for (const divisions of [10, 5, 4, 2]) {
495
+ if (majorStep % divisions !== 0) continue;
496
+ const minorStep = majorStep / divisions;
497
+ if (minorStep < 1) continue;
498
+ if (pxPerMajor / divisions >= MIN_MINOR_PX) return minorStep;
499
+ }
500
+ if (majorStep >= 2 && pxPerMajor / Math.min(majorStep - 1, 5) >= MIN_MINOR_PX) {
501
+ return 1;
502
+ }
503
+ return 0;
504
+ }
505
+ function computeHorizontalLayout(minVal, maxVal) {
506
+ const gutter = 16;
507
+ const charWidth = 8;
508
+ const leftLabel = String(Math.round(minVal));
509
+ const rightLabel = String(Math.round(maxVal));
510
+ return {
511
+ padLeft: gutter,
512
+ padRight: gutter,
513
+ labelInsetLeft: Math.max(8, Math.ceil(leftLabel.length * charWidth / 2) + 2),
514
+ labelInsetRight: Math.max(12, Math.ceil(rightLabel.length * charWidth / 2) + 4)
515
+ };
516
+ }
517
+ function createValueToX(viewMin, viewMax, viewWidth, layout) {
518
+ const range = Math.max(viewMax - viewMin, 1e-6);
519
+ const usable = Math.max(viewWidth - layout.padLeft - layout.padRight, 1);
520
+ const innerUsable = Math.max(
521
+ usable - layout.labelInsetLeft - layout.labelInsetRight,
522
+ 1
523
+ );
524
+ return (value) => layout.padLeft + layout.labelInsetLeft + (value - viewMin) / range * innerUsable;
525
+ }
526
+ function edgeTextAnchor(value, domainMin, domainMax) {
527
+ if (Math.abs(value - domainMin) < 1e-9) return "start";
528
+ if (Math.abs(value - domainMax) < 1e-9) return "end";
529
+ return "middle";
530
+ }
490
531
  const ARC_COLORS = [
491
532
  "#7c3aed",
492
533
  "#0891b2",
@@ -494,6 +535,9 @@ const ARC_COLORS = [
494
535
  "#be185d",
495
536
  "#16a34a"
496
537
  ];
538
+ const ARC_RY_RATIO = 0.9;
539
+ const ARC_ANIMATION_STAGGER_MS = 400;
540
+ const ARC_ANIMATION_DRAW_MS = 500;
497
541
  function gcd(a, b) {
498
542
  let x = Math.max(1, Math.abs(Math.round(a)));
499
543
  let y = Math.max(1, Math.abs(Math.round(b)));
@@ -502,11 +546,11 @@ function gcd(a, b) {
502
546
  }
503
547
  const AxisLayer = React.memo(function AxisLayer2({
504
548
  min,
549
+ max,
505
550
  viewMin,
506
551
  viewMax,
507
552
  axisY,
508
- padLeft,
509
- padRight,
553
+ horizontalLayout,
510
554
  viewWidth,
511
555
  containerWidth,
512
556
  groupSize,
@@ -518,15 +562,17 @@ const AxisLayer = React.memo(function AxisLayer2({
518
562
  }) {
519
563
  const range = viewMax - viewMin;
520
564
  if (range <= 0) return null;
521
- const usable = viewWidth - padLeft - padRight;
522
- const toX = (v) => padLeft + (v - viewMin) / range * usable;
565
+ const toX = createValueToX(viewMin, viewMax, viewWidth, horizontalLayout);
566
+ const axisStartX = toX(viewMin);
567
+ const axisEndX = toX(viewMax);
523
568
  const step = Math.max(1, Math.round(tickStep));
524
569
  const rawLabelStep = computeLabelStep(containerWidth, range);
525
570
  const labelStep = Math.max(step, Math.ceil(rawLabelStep / step) * step);
571
+ const minorStep = showMinorTicks ? computeMinorTickStep(step, containerWidth, range) : 0;
526
572
  const MAX_TICKS = 500;
527
573
  const capStep = Math.max(1, Math.ceil(range / MAX_TICKS));
528
574
  const visibleStep = groupSize > 0 ? gcd(step, groupSize) : step;
529
- const iterStep = showMinorTicks ? capStep : Math.max(capStep, visibleStep);
575
+ const iterStep = showMinorTicks && minorStep > 0 ? Math.max(capStep, gcd(minorStep, capStep)) : Math.max(capStep, visibleStep);
530
576
  const startVal = Math.floor(viewMin / iterStep) * iterStep;
531
577
  const allTicks = [];
532
578
  for (let v = startVal; v <= viewMax; v += iterStep) {
@@ -536,9 +582,9 @@ const AxisLayer = React.memo(function AxisLayer2({
536
582
  /* @__PURE__ */ jsx(
537
583
  "line",
538
584
  {
539
- x1: padLeft - 4,
585
+ x1: axisStartX - 4,
540
586
  y1: axisY,
541
- x2: viewWidth - padRight,
587
+ x2: axisEndX,
542
588
  y2: axisY,
543
589
  stroke: "#374151",
544
590
  strokeWidth: 2
@@ -548,16 +594,18 @@ const AxisLayer = React.memo(function AxisLayer2({
548
594
  const x = toX(v);
549
595
  const offset = v - min;
550
596
  const isMajor = offset % step === 0;
597
+ const isOnMinorGrid = minorStep > 0 && Math.abs(offset % minorStep) < 1e-9;
551
598
  const maxGroupBoundary = min + groupCount * groupSize;
552
599
  const isGroupBoundary = groupSize > 0 && offset % groupSize === 0 && v <= maxGroupBoundary;
553
- const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || !isGroupBoundary && !isMajor && showMinorTicks;
600
+ const isMinorTick = isOnMinorGrid && !isMajor && !isGroupBoundary;
601
+ const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || isMinorTick && showMinorTicks;
554
602
  if (!visible) return null;
555
603
  const renderAsGroup = isGroupBoundary && showGroupTicks;
556
604
  const renderAsMajor = !renderAsGroup && isMajor && showMajorTicks;
557
605
  const showLabel = (renderAsGroup || renderAsMajor) && offset % labelStep === 0;
558
606
  const tickH = renderAsGroup ? 7 : renderAsMajor ? 10 : 4;
559
- const strokeW = renderAsGroup ? 1.5 : renderAsMajor ? 1 : 0.5;
560
- const color = renderAsGroup || renderAsMajor ? "#374151" : "#9ca3af";
607
+ const strokeW = renderAsGroup ? 1.5 : renderAsMajor ? 1 : 0.75;
608
+ const color = renderAsGroup || renderAsMajor ? "#374151" : "#6b7280";
561
609
  return /* @__PURE__ */ jsxs("g", { children: [
562
610
  /* @__PURE__ */ jsx(
563
611
  "line",
@@ -575,7 +623,7 @@ const AxisLayer = React.memo(function AxisLayer2({
575
623
  {
576
624
  x,
577
625
  y: axisY + 22,
578
- textAnchor: "middle",
626
+ textAnchor: edgeTextAnchor(v, min, max),
579
627
  fontSize: 11,
580
628
  fill: "#374151",
581
629
  pointerEvents: "none",
@@ -587,6 +635,173 @@ const AxisLayer = React.memo(function AxisLayer2({
587
635
  })
588
636
  ] });
589
637
  });
638
+ function AnimatedArc({
639
+ groupIndex,
640
+ arcPath,
641
+ color,
642
+ isSelected,
643
+ x2,
644
+ textY,
645
+ axisY,
646
+ end,
647
+ min,
648
+ max,
649
+ hitCursor,
650
+ shouldAnimate,
651
+ onArcClick
652
+ }) {
653
+ const measureRef = useRef(null);
654
+ const animatedRef = useRef(false);
655
+ const [pathLen, setPathLen] = useState(null);
656
+ const [dashOffset, setDashOffset] = useState(null);
657
+ const [isDrawing, setIsDrawing] = useState(false);
658
+ const [showLabels, setShowLabels] = useState(!shouldAnimate);
659
+ const [showHitArea, setShowHitArea] = useState(!shouldAnimate);
660
+ const strokeColor = isSelected ? "#1d4ed8" : color;
661
+ const arrowHeadH = 7;
662
+ const arrowHeadHW = 4;
663
+ useLayoutEffect(() => {
664
+ var _a;
665
+ const len = ((_a = measureRef.current) == null ? void 0 : _a.getTotalLength()) ?? 0;
666
+ if (len <= 0) return;
667
+ setPathLen(len);
668
+ if (!shouldAnimate || animatedRef.current) {
669
+ setDashOffset(0);
670
+ setShowLabels(true);
671
+ setShowHitArea(true);
672
+ setIsDrawing(false);
673
+ return;
674
+ }
675
+ setDashOffset(len);
676
+ setShowLabels(false);
677
+ setShowHitArea(false);
678
+ setIsDrawing(false);
679
+ const delay = groupIndex * ARC_ANIMATION_STAGGER_MS;
680
+ let drawTimer = 0;
681
+ let labelTimer = 0;
682
+ let completeTimer = 0;
683
+ const rafId = requestAnimationFrame(() => {
684
+ requestAnimationFrame(() => {
685
+ drawTimer = window.setTimeout(() => {
686
+ setIsDrawing(true);
687
+ setDashOffset(0);
688
+ }, delay);
689
+ labelTimer = window.setTimeout(
690
+ () => setShowLabels(true),
691
+ delay + ARC_ANIMATION_DRAW_MS
692
+ );
693
+ completeTimer = window.setTimeout(() => {
694
+ animatedRef.current = true;
695
+ setIsDrawing(false);
696
+ setShowHitArea(true);
697
+ }, delay + ARC_ANIMATION_DRAW_MS);
698
+ });
699
+ });
700
+ return () => {
701
+ cancelAnimationFrame(rafId);
702
+ window.clearTimeout(drawTimer);
703
+ window.clearTimeout(labelTimer);
704
+ window.clearTimeout(completeTimer);
705
+ };
706
+ }, [arcPath, groupIndex, shouldAnimate]);
707
+ const strokeTransition = isDrawing ? `stroke-dashoffset ${ARC_ANIMATION_DRAW_MS}ms ease-out` : "none";
708
+ const arcVisible = pathLen !== null && dashOffset !== null;
709
+ return /* @__PURE__ */ jsxs("g", { children: [
710
+ /* @__PURE__ */ jsx(
711
+ "path",
712
+ {
713
+ ref: measureRef,
714
+ d: arcPath,
715
+ fill: "none",
716
+ stroke: "none",
717
+ visibility: "hidden",
718
+ pointerEvents: "none"
719
+ }
720
+ ),
721
+ arcVisible && isSelected && /* @__PURE__ */ jsx(
722
+ "path",
723
+ {
724
+ d: arcPath,
725
+ fill: "none",
726
+ stroke: "rgba(0,0,0,0.15)",
727
+ strokeWidth: 14,
728
+ strokeLinecap: "round",
729
+ pointerEvents: "none"
730
+ }
731
+ ),
732
+ arcVisible && /* @__PURE__ */ jsx(
733
+ "path",
734
+ {
735
+ d: arcPath,
736
+ fill: "none",
737
+ stroke: strokeColor,
738
+ strokeWidth: isSelected ? 3 : 2,
739
+ strokeDasharray: pathLen,
740
+ strokeDashoffset: dashOffset,
741
+ style: { transition: strokeTransition },
742
+ pointerEvents: "none"
743
+ }
744
+ ),
745
+ arcVisible && showHitArea && /* @__PURE__ */ jsx(
746
+ "path",
747
+ {
748
+ d: arcPath,
749
+ fill: "none",
750
+ stroke: "transparent",
751
+ strokeWidth: 20,
752
+ strokeLinecap: "round",
753
+ pointerEvents: "stroke",
754
+ style: { cursor: hitCursor },
755
+ onClick: (e) => {
756
+ onArcClick(groupIndex, e);
757
+ }
758
+ }
759
+ ),
760
+ arcVisible && /* @__PURE__ */ jsxs(
761
+ "g",
762
+ {
763
+ opacity: showLabels ? 1 : 0,
764
+ style: { transition: "opacity 220ms ease-out" },
765
+ children: [
766
+ /* @__PURE__ */ jsx(
767
+ "text",
768
+ {
769
+ x: x2,
770
+ y: textY,
771
+ textAnchor: edgeTextAnchor(end, min, max),
772
+ fontSize: 10,
773
+ fill: strokeColor,
774
+ fontWeight: "600",
775
+ pointerEvents: "none",
776
+ style: { userSelect: "none", WebkitUserSelect: "none" },
777
+ children: end
778
+ }
779
+ ),
780
+ /* @__PURE__ */ jsx(
781
+ "line",
782
+ {
783
+ x1: x2,
784
+ y1: textY + 4,
785
+ x2,
786
+ y2: axisY - arrowHeadH,
787
+ stroke: strokeColor,
788
+ strokeWidth: 1.5,
789
+ pointerEvents: "none"
790
+ }
791
+ ),
792
+ /* @__PURE__ */ jsx(
793
+ "polygon",
794
+ {
795
+ points: `${x2},${axisY} ${x2 - arrowHeadHW},${axisY - arrowHeadH} ${x2 + arrowHeadHW},${axisY - arrowHeadH}`,
796
+ fill: strokeColor,
797
+ pointerEvents: "none"
798
+ }
799
+ )
800
+ ]
801
+ }
802
+ )
803
+ ] });
804
+ }
590
805
  const GroupArcsLayer = memo(function GroupArcsLayer2({
591
806
  min,
592
807
  max,
@@ -595,8 +810,7 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
595
810
  groupSize,
596
811
  groupCount,
597
812
  axisY,
598
- padLeft,
599
- padRight,
813
+ horizontalLayout,
600
814
  viewWidth,
601
815
  arcMaxHeight,
602
816
  tool,
@@ -606,10 +820,8 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
606
820
  arcColors = ARC_COLORS
607
821
  }) {
608
822
  if (groupSize <= 0 || groupCount <= 0) return null;
609
- const range = viewMax - viewMin;
610
- const usable = viewWidth - padLeft - padRight;
611
- const toX = (v) => padLeft + (v - viewMin) / range * usable;
612
- const clipId = React.useId().replace(/:/g, "_") + "-arcs-clip";
823
+ const shouldAnimate = groupSize > 0 && groupCount > 0;
824
+ const toX = createValueToX(viewMin, viewMax, viewWidth, horizontalLayout);
613
825
  const arcs = [];
614
826
  for (let g = 0; g < groupCount; g++) {
615
827
  if (deletedArcIndices.has(g)) continue;
@@ -620,105 +832,41 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
620
832
  const x1 = toX(start);
621
833
  const x2 = toX(end);
622
834
  const rx = (x2 - x1) / 2;
623
- const ry = Math.min(rx * 0.7, arcMaxHeight);
835
+ const ry = Math.min(rx * ARC_RY_RATIO, arcMaxHeight);
624
836
  const color = arcColors[g % arcColors.length];
625
- const arrowHeadH = 7;
626
- const arrowHeadHW = 4;
627
837
  const MIN_ARROW_SPAN = 18;
628
838
  const textY = Math.min(axisY - ry - 6, axisY - MIN_ARROW_SPAN);
629
839
  const arcPath = buildArcPath({ x1, x2, y: axisY, rx, ry });
630
840
  const isSelected = selectedArcIndices.has(g);
631
841
  const hitCursor = tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default";
632
842
  arcs.push(
633
- /* @__PURE__ */ jsxs("g", { children: [
634
- isSelected && /* @__PURE__ */ jsx(
635
- "path",
636
- {
637
- d: arcPath,
638
- fill: "none",
639
- stroke: "rgba(0,0,0,0.15)",
640
- strokeWidth: 14,
641
- strokeLinecap: "round",
642
- pointerEvents: "none"
643
- }
644
- ),
645
- /* @__PURE__ */ jsx(
646
- "path",
647
- {
648
- d: arcPath,
649
- fill: "none",
650
- stroke: isSelected ? "#1d4ed8" : color,
651
- strokeWidth: isSelected ? 3 : 2,
652
- pointerEvents: "none"
653
- }
654
- ),
655
- /* @__PURE__ */ jsx(
656
- "path",
657
- {
658
- d: arcPath,
659
- fill: "none",
660
- stroke: "transparent",
661
- strokeWidth: 20,
662
- strokeLinecap: "round",
663
- pointerEvents: "stroke",
664
- style: { cursor: hitCursor },
665
- onClick: (e) => {
666
- if (tool === "eraser" || tool === "none") {
667
- e.stopPropagation();
668
- onArcClick(g, e);
669
- }
843
+ /* @__PURE__ */ jsx(
844
+ AnimatedArc,
845
+ {
846
+ groupIndex: g,
847
+ arcPath,
848
+ color,
849
+ isSelected,
850
+ x2,
851
+ textY,
852
+ axisY,
853
+ end,
854
+ min,
855
+ max,
856
+ hitCursor,
857
+ shouldAnimate,
858
+ onArcClick: (index, e) => {
859
+ if (tool === "eraser" || tool === "none") {
860
+ e.stopPropagation();
861
+ onArcClick(index, e);
670
862
  }
671
863
  }
672
- ),
673
- /* @__PURE__ */ jsx(
674
- "text",
675
- {
676
- x: x2,
677
- y: textY,
678
- textAnchor: "middle",
679
- fontSize: 10,
680
- fill: isSelected ? "#1d4ed8" : color,
681
- fontWeight: "600",
682
- pointerEvents: "none",
683
- style: { userSelect: "none", WebkitUserSelect: "none" },
684
- children: end
685
- }
686
- ),
687
- /* @__PURE__ */ jsx(
688
- "line",
689
- {
690
- x1: x2,
691
- y1: textY + 4,
692
- x2,
693
- y2: axisY - arrowHeadH,
694
- stroke: isSelected ? "#1d4ed8" : color,
695
- strokeWidth: 1.5,
696
- pointerEvents: "none"
697
- }
698
- ),
699
- /* @__PURE__ */ jsx(
700
- "polygon",
701
- {
702
- points: `${x2},${axisY} ${x2 - arrowHeadHW},${axisY - arrowHeadH} ${x2 + arrowHeadHW},${axisY - arrowHeadH}`,
703
- fill: isSelected ? "#1d4ed8" : color,
704
- pointerEvents: "none"
705
- }
706
- )
707
- ] }, g)
864
+ },
865
+ g
866
+ )
708
867
  );
709
868
  }
710
- return /* @__PURE__ */ jsxs("g", { className: "nl-arcs-layer", children: [
711
- /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("clipPath", { id: clipId, children: /* @__PURE__ */ jsx(
712
- "rect",
713
- {
714
- x: padLeft,
715
- y: 0,
716
- width: usable,
717
- height: axisY + 1
718
- }
719
- ) }) }),
720
- /* @__PURE__ */ jsx("g", { clipPath: `url(#${clipId})`, children: arcs })
721
- ] });
869
+ return /* @__PURE__ */ jsx("g", { className: "nl-arcs-layer", children: arcs });
722
870
  });
723
871
  function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
724
872
  return /* @__PURE__ */ jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
@@ -786,9 +934,6 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
786
934
  )
787
935
  ] });
788
936
  }
789
- const PAD_LEFT_LEGACY = 30;
790
- const PAD_RIGHT_LEGACY = 40;
791
- const PAD_X = (PAD_LEFT_LEGACY + PAD_RIGHT_LEGACY) / 2;
792
937
  const LABEL_SPACE_BELOW_AXIS = 42;
793
938
  const LABEL_ABOVE_AXIS_SLOP = 32;
794
939
  const ARC_TOP_PADDING = 16;
@@ -827,12 +972,16 @@ function NumberLine({ className }) {
827
972
  } = useNumberLineContext();
828
973
  const w = containerWidth;
829
974
  const h = containerHeight;
975
+ const horizontalLayout = computeHorizontalLayout(min, max);
830
976
  const range = Math.max(viewMax - viewMin, 1e-6);
831
- const usable = Math.max(w - 2 * PAD_X, 1);
977
+ const usable = Math.max(
978
+ w - horizontalLayout.padLeft - horizontalLayout.padRight - horizontalLayout.labelInsetLeft - horizontalLayout.labelInsetRight,
979
+ 1
980
+ );
832
981
  let ryTypical = MIN_ARC_HEIGHT * 0.5;
833
982
  if (groupSize > 0 && groupCount > 0) {
834
983
  const rx = usable * groupSize / range / 2;
835
- ryTypical = Math.min(rx * 0.7, h * 0.48);
984
+ ryTypical = Math.min(rx * ARC_RY_RATIO, h * 0.48);
836
985
  }
837
986
  const minAxisY = MIN_ARC_HEIGHT + ARC_TOP_PADDING;
838
987
  const maxAxisY = h - LABEL_SPACE_BELOW_AXIS;
@@ -984,7 +1133,8 @@ function NumberLine({ className }) {
984
1133
  display: "block",
985
1134
  outline: enableZoom && isZoomActive ? "2px solid rgba(59, 130, 246, 0.5)" : "none",
986
1135
  cursor: svgCursor,
987
- touchAction: "none"
1136
+ touchAction: "none",
1137
+ overflow: "visible"
988
1138
  },
989
1139
  onPointerDown: handlePointerDown,
990
1140
  onPointerMove: handlePointerMove,
@@ -1004,8 +1154,7 @@ function NumberLine({ className }) {
1004
1154
  viewMin,
1005
1155
  viewMax,
1006
1156
  axisY,
1007
- padLeft: PAD_X,
1008
- padRight: PAD_X,
1157
+ horizontalLayout,
1009
1158
  viewWidth: w,
1010
1159
  containerWidth: w,
1011
1160
  groupSize,
@@ -1026,8 +1175,7 @@ function NumberLine({ className }) {
1026
1175
  groupSize,
1027
1176
  groupCount,
1028
1177
  axisY,
1029
- padLeft: PAD_X,
1030
- padRight: PAD_X,
1178
+ horizontalLayout,
1031
1179
  viewWidth: w,
1032
1180
  arcMaxHeight,
1033
1181
  tool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcg-overseas/number-line",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Interactive number line component with group arcs and freehand drawing",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,12 +23,6 @@
23
23
  "dist"
24
24
  ],
25
25
  "sideEffects": false,
26
- "scripts": {
27
- "build": "vite build",
28
- "build:watch": "vite build --watch",
29
- "typecheck": "tsc --noEmit",
30
- "clean": "rm -rf dist *.tsbuildinfo"
31
- },
32
26
  "peerDependencies": {
33
27
  "react": "^18.0.0",
34
28
  "react-dom": "^18.0.0"
@@ -43,5 +37,11 @@
43
37
  "publishConfig": {
44
38
  "access": "public",
45
39
  "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "scripts": {
42
+ "build": "vite build",
43
+ "build:watch": "vite build --watch",
44
+ "typecheck": "tsc --noEmit",
45
+ "clean": "rm -rf dist *.tsbuildinfo"
46
46
  }
47
- }
47
+ }