@dcg-overseas/number-line 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -61,6 +61,12 @@ function Demo() {
61
61
  | `showGroupTicks` | `boolean` | `true` | 是否显示分组边界刻度(最长,10px) |
62
62
  | `showMajorTicks` | `boolean` | `true` | 是否显示主刻度(中等,7px) |
63
63
  | `showMinorTicks` | `boolean` | `false` | 是否显示次刻度(最短,4px)。默认隐藏 |
64
+ | `enableZoom` | `boolean` | `false` | 启用滚轮/双指缩放 + 拖拽平移。默认禁用 |
65
+ | `minZoom` | `number` | `1` | 最小缩放级别(1 = 完整 `[min, max]` 范围) |
66
+ | `maxZoom` | `number` | `50` | 最大缩放级别(50 = 放大 50 倍) |
67
+ | `viewMin` | `number?` | — | 受控模式:当前可视范围起点。需与 `viewMax` 和 `onViewChange` 配合 |
68
+ | `viewMax` | `number?` | — | 受控模式:当前可视范围终点 |
69
+ | `onViewChange` | `(viewMin, viewMax) => void` | — | 非受控模式:viewport 变化时触发(滚轮/拖拽/双击重置) |
64
70
  | `children` | `ReactNode` | — | 子节点(必须包含 `<NumberLine />` 或自定义渲染器) |
65
71
 
66
72
  #### 弧线生成规则
@@ -86,6 +92,54 @@ g ∈ [0, groupCount)
86
92
  <NumberLineProvider ... showMajorTicks={false} />
87
93
  ```
88
94
 
95
+ #### 缩放与平移(`enableZoom`)
96
+
97
+ 启用后支持:
98
+ - **滚轮缩放**:以鼠标位置为中心放大/缩小
99
+ - **拖拽平移**:`tool='none'` 时,左键拖拽 >5px 触发平移(避免与点击选中冲突)
100
+ - **双指缩放**(触摸屏):pinch 手势
101
+ - **双击重置**:双击空白处恢复到完整 `[min, max]` 范围
102
+
103
+ ```tsx
104
+ // 非受控模式(内部维护 viewport)
105
+ <NumberLineProvider
106
+ min={0}
107
+ max={100}
108
+ enableZoom
109
+ minZoom={1}
110
+ maxZoom={50}
111
+ onViewChange={(viewMin, viewMax) => console.log('viewport:', viewMin, viewMax)}
112
+ ...
113
+ />
114
+
115
+ // 受控模式(父组件控制 viewport)
116
+ function App() {
117
+ const [view, setView] = useState([0, 100])
118
+ return (
119
+ <NumberLineProvider
120
+ min={0}
121
+ max={100}
122
+ enableZoom
123
+ viewMin={view[0]}
124
+ viewMax={view[1]}
125
+ onViewChange={(a, b) => setView([a, b])}
126
+ ...
127
+ />
128
+ )
129
+ }
130
+ ```
131
+
132
+ **注意事项**:
133
+ - 笔迹坐标是归一化 `[0,1]`(容器像素比例),**不会跟随数值缩放移动**。适合「批注」语义,不适合「标记 v=50 处」语义。
134
+ - viewport 始终夹紧在 `[min, max]` 内,不会平移到数据范围外。
135
+ - 缩放时刻度密度自动调整(`labelStep` 按可视范围重算)。
136
+ - **桌面端**:滚轮和 pinch 需要鼠标悬停或 SVG 获得焦点(视觉上有蓝色边框提示)。
137
+ - **移动端**:双指 pinch 直接生效(触摸即激活)。**SVG 外的双指仍会触发浏览器整页缩放**——如果不需要整页缩放,可在宿主 HTML 添加:
138
+ ```html
139
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
140
+ ```
141
+ 但出于无障碍考虑,禁用整页缩放需谨慎评估。
142
+
89
143
  ---
90
144
 
91
145
  ### `<NumberLine>`
@@ -161,6 +215,14 @@ g ∈ [0, groupCount)
161
215
  | `onReset` | `() => void` | 清空所有笔迹和弧线删除记录 |
162
216
  | `onUndo` | `() => void` | 撤销上一步操作 |
163
217
 
218
+ #### 缩放 API(仅 `enableZoom=true` 时有效)
219
+ | 字段 | 类型 | 说明 |
220
+ |------|------|------|
221
+ | `viewMin` `viewMax` | `number` | 当前可视范围(等于 `[min, max]` 当 zoom 禁用) |
222
+ | `resetView` | `() => void` | 重置到完整数据范围 |
223
+ | `zoomAt` | `(factor: number, fraction: number) => void` | 以 viewport 的 `fraction` 位置(0..1)为中心缩放 `factor` 倍 |
224
+ | `panByFraction` | `(df: number) => void` | 平移 viewport 宽度的 `df` 倍(正数右移,负数左移) |
225
+
164
226
  ---
165
227
 
166
228
  ## 自定义工具栏示例
package/dist/index.cjs CHANGED
@@ -145,6 +145,89 @@ function useContainerSize() {
145
145
  }, []);
146
146
  return { ref, width: size.width, height: size.height };
147
147
  }
148
+ function useZoom({
149
+ dataMin,
150
+ dataMax,
151
+ minZoom,
152
+ maxZoom,
153
+ controlledMin,
154
+ controlledMax,
155
+ onChange
156
+ }) {
157
+ const isControlled = controlledMin !== void 0 && controlledMax !== void 0;
158
+ const [internal, setInternal] = React.useState([dataMin, dataMax]);
159
+ const lastRange = React.useRef([dataMin, dataMax]);
160
+ React.useEffect(() => {
161
+ const [lm, lM] = lastRange.current;
162
+ if (lm !== dataMin || lM !== dataMax) {
163
+ lastRange.current = [dataMin, dataMax];
164
+ if (!isControlled) setInternal([dataMin, dataMax]);
165
+ }
166
+ }, [dataMin, dataMax, isControlled]);
167
+ const viewMin = isControlled ? controlledMin : internal[0];
168
+ const viewMax = isControlled ? controlledMax : internal[1];
169
+ const viewRef = React.useRef({ viewMin, viewMax });
170
+ viewRef.current = { viewMin, viewMax };
171
+ const dataRange = dataMax - dataMin;
172
+ const minView = dataRange / Math.max(1, maxZoom);
173
+ const maxView = dataRange / Math.max(1, Math.min(minZoom, maxZoom));
174
+ const commit = React.useCallback(
175
+ (next) => {
176
+ const { viewMin: curMin, viewMax: curMax } = viewRef.current;
177
+ let [a, b] = next;
178
+ let width = b - a;
179
+ if (width < minView) {
180
+ const c = (a + b) / 2;
181
+ a = c - minView / 2;
182
+ b = c + minView / 2;
183
+ width = minView;
184
+ }
185
+ if (width > maxView) {
186
+ const c = (a + b) / 2;
187
+ a = c - maxView / 2;
188
+ b = c + maxView / 2;
189
+ width = maxView;
190
+ }
191
+ if (a < dataMin) {
192
+ b += dataMin - a;
193
+ a = dataMin;
194
+ }
195
+ if (b > dataMax) {
196
+ a -= b - dataMax;
197
+ b = dataMax;
198
+ }
199
+ if (a < dataMin) a = dataMin;
200
+ if (b > dataMax) b = dataMax;
201
+ if (a === curMin && b === curMax) return;
202
+ if (!isControlled) setInternal([a, b]);
203
+ onChange == null ? void 0 : onChange(a, b);
204
+ },
205
+ [minView, maxView, dataMin, dataMax, isControlled, onChange]
206
+ );
207
+ const zoomAt = React.useCallback(
208
+ (factor, f) => {
209
+ const width = viewMax - viewMin;
210
+ const anchorVal = viewMin + width * f;
211
+ const newWidth = width / factor;
212
+ const a = anchorVal - newWidth * f;
213
+ const b = anchorVal + newWidth * (1 - f);
214
+ commit([a, b]);
215
+ },
216
+ [viewMin, viewMax, commit]
217
+ );
218
+ const panByFraction = React.useCallback(
219
+ (df) => {
220
+ const width = viewMax - viewMin;
221
+ const dv = width * df;
222
+ commit([viewMin + dv, viewMax + dv]);
223
+ },
224
+ [viewMin, viewMax, commit]
225
+ );
226
+ const resetView = React.useCallback(() => {
227
+ commit([dataMin, dataMax]);
228
+ }, [commit, dataMin, dataMax]);
229
+ return { viewMin, viewMax, zoomAt, panByFraction, resetView };
230
+ }
148
231
  const NumberLineContext = React.createContext(null);
149
232
  function useNumberLineContext() {
150
233
  const ctx = React.useContext(NumberLineContext);
@@ -161,6 +244,12 @@ function NumberLineProvider({
161
244
  showGroupTicks = true,
162
245
  showMajorTicks = true,
163
246
  showMinorTicks = false,
247
+ enableZoom = false,
248
+ minZoom = 1,
249
+ maxZoom = 50,
250
+ viewMin: controlledViewMin,
251
+ viewMax: controlledViewMax,
252
+ onViewChange,
164
253
  children
165
254
  }) {
166
255
  const [selectedArcIndices, setSelectedArcIndices] = React.useState(/* @__PURE__ */ new Set());
@@ -168,6 +257,15 @@ function NumberLineProvider({
168
257
  const { ref: svgRef, width: containerWidth, height: containerHeight } = useContainerSize();
169
258
  const history = useHistory();
170
259
  const drawing = useDrawing();
260
+ const { viewMin, viewMax, zoomAt, panByFraction, resetView } = useZoom({
261
+ dataMin: min,
262
+ dataMax: max,
263
+ minZoom,
264
+ maxZoom,
265
+ controlledMin: controlledViewMin,
266
+ controlledMax: controlledViewMax,
267
+ onChange: onViewChange
268
+ });
171
269
  const [liveStroke, setLiveStroke] = React.useState(null);
172
270
  const svgPoint = React.useRef(null);
173
271
  React.useEffect(() => {
@@ -339,6 +437,12 @@ function NumberLineProvider({
339
437
  showGroupTicks,
340
438
  showMajorTicks,
341
439
  showMinorTicks,
440
+ viewMin,
441
+ viewMax,
442
+ enableZoom,
443
+ resetView,
444
+ zoomAt,
445
+ panByFraction,
342
446
  tool: drawing.tool,
343
447
  strokes: drawing.strokes,
344
448
  liveStroke,
@@ -386,22 +490,24 @@ function gcd(a, b) {
386
490
  }
387
491
  const AxisLayer = React.memo(function AxisLayer2({
388
492
  min,
389
- max,
493
+ viewMin,
494
+ viewMax,
390
495
  axisY,
391
496
  padLeft,
392
497
  padRight,
393
498
  viewWidth,
394
499
  containerWidth,
395
500
  groupSize,
501
+ groupCount,
396
502
  tickStep,
397
503
  showGroupTicks,
398
504
  showMajorTicks,
399
505
  showMinorTicks
400
506
  }) {
401
- const range = max - min;
507
+ const range = viewMax - viewMin;
402
508
  if (range <= 0) return null;
403
509
  const usable = viewWidth - padLeft - padRight;
404
- const toX = (v) => padLeft + (v - min) / range * usable;
510
+ const toX = (v) => padLeft + (v - viewMin) / range * usable;
405
511
  const step = Math.max(1, Math.round(tickStep));
406
512
  const rawLabelStep = computeLabelStep(containerWidth, range);
407
513
  const labelStep = Math.max(step, Math.ceil(rawLabelStep / step) * step);
@@ -409,8 +515,11 @@ const AxisLayer = React.memo(function AxisLayer2({
409
515
  const capStep = Math.max(1, Math.ceil(range / MAX_TICKS));
410
516
  const visibleStep = groupSize > 0 ? gcd(step, groupSize) : step;
411
517
  const iterStep = showMinorTicks ? capStep : Math.max(capStep, visibleStep);
518
+ const startVal = Math.floor(viewMin / iterStep) * iterStep;
412
519
  const allTicks = [];
413
- for (let v = min; v <= max; v += iterStep) allTicks.push(v);
520
+ for (let v = startVal; v <= viewMax; v += iterStep) {
521
+ if (v >= viewMin && v <= viewMax) allTicks.push(v);
522
+ }
414
523
  return /* @__PURE__ */ jsxRuntime.jsxs("g", { className: "nl-axis-layer", children: [
415
524
  /* @__PURE__ */ jsxRuntime.jsx(
416
525
  "line",
@@ -427,7 +536,8 @@ const AxisLayer = React.memo(function AxisLayer2({
427
536
  const x = toX(v);
428
537
  const offset = v - min;
429
538
  const isMajor = offset % step === 0;
430
- const isGroupBoundary = groupSize > 0 && offset % groupSize === 0;
539
+ const maxGroupBoundary = min + groupCount * groupSize;
540
+ const isGroupBoundary = groupSize > 0 && offset % groupSize === 0 && v <= maxGroupBoundary;
431
541
  const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || !isGroupBoundary && !isMajor && showMinorTicks;
432
542
  if (!visible) return null;
433
543
  const renderAsGroup = isGroupBoundary && showGroupTicks;
@@ -468,6 +578,8 @@ const AxisLayer = React.memo(function AxisLayer2({
468
578
  const GroupArcsLayer = React.memo(function GroupArcsLayer2({
469
579
  min,
470
580
  max,
581
+ viewMin,
582
+ viewMax,
471
583
  groupSize,
472
584
  groupCount,
473
585
  axisY,
@@ -482,15 +594,17 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
482
594
  arcColors = ARC_COLORS
483
595
  }) {
484
596
  if (groupSize <= 0 || groupCount <= 0) return null;
485
- const range = max - min;
597
+ const range = viewMax - viewMin;
486
598
  const usable = viewWidth - padLeft - padRight;
487
- const toX = (v) => padLeft + (v - min) / range * usable;
599
+ const toX = (v) => padLeft + (v - viewMin) / range * usable;
600
+ const clipId = React.useId().replace(/:/g, "_") + "-arcs-clip";
488
601
  const arcs = [];
489
602
  for (let g = 0; g < groupCount; g++) {
490
603
  if (deletedArcIndices.has(g)) continue;
491
604
  const start = min + g * groupSize;
492
605
  const end = start + groupSize;
493
606
  if (end > max) break;
607
+ if (end < viewMin || start > viewMax) continue;
494
608
  const x1 = toX(start);
495
609
  const x2 = toX(end);
496
610
  const rx = (x2 - x1) / 2;
@@ -536,7 +650,7 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
536
650
  strokeLinecap: "round",
537
651
  pointerEvents: "stroke",
538
652
  style: { cursor: hitCursor },
539
- onPointerDown: (e) => {
653
+ onClick: (e) => {
540
654
  if (tool === "eraser" || tool === "none") {
541
655
  e.stopPropagation();
542
656
  onArcClick(g, e);
@@ -581,7 +695,18 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
581
695
  ] }, g)
582
696
  );
583
697
  }
584
- return /* @__PURE__ */ jsxRuntime.jsx("g", { className: "nl-arcs-layer", children: arcs });
698
+ return /* @__PURE__ */ jsxRuntime.jsxs("g", { className: "nl-arcs-layer", children: [
699
+ /* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsx("clipPath", { id: clipId, children: /* @__PURE__ */ jsxRuntime.jsx(
700
+ "rect",
701
+ {
702
+ x: padLeft,
703
+ y: 0,
704
+ width: usable,
705
+ height: axisY + 1
706
+ }
707
+ ) }) }),
708
+ /* @__PURE__ */ jsxRuntime.jsx("g", { clipPath: `url(#${clipId})`, children: arcs })
709
+ ] });
585
710
  });
586
711
  function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
587
712
  return /* @__PURE__ */ jsxRuntime.jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
@@ -611,7 +736,7 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
611
736
  vectorEffect: "non-scaling-stroke",
612
737
  pointerEvents: "stroke",
613
738
  style: { cursor: tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default" },
614
- onPointerDown: (e) => {
739
+ onClick: (e) => {
615
740
  if (tool === "eraser" || tool === "none") {
616
741
  e.stopPropagation();
617
742
  onStrokeClick(s.id, e);
@@ -669,6 +794,12 @@ function NumberLine({ className }) {
669
794
  showGroupTicks,
670
795
  showMajorTicks,
671
796
  showMinorTicks,
797
+ viewMin,
798
+ viewMax,
799
+ enableZoom,
800
+ resetView,
801
+ zoomAt,
802
+ panByFraction,
672
803
  tool,
673
804
  strokes,
674
805
  liveStroke,
@@ -684,6 +815,137 @@ function NumberLine({ className }) {
684
815
  const h = containerHeight;
685
816
  const axisY = Math.max(MIN_ARC_HEIGHT + ARC_TOP_PADDING, h - AXIS_BOTTOM_OFFSET);
686
817
  const arcMaxHeight = Math.max(MIN_ARC_HEIGHT, axisY - ARC_TOP_PADDING);
818
+ const [isZoomActive, setIsZoomActive] = React.useState(false);
819
+ const [isPanning, setIsPanning] = React.useState(false);
820
+ const panStart = React.useRef(null);
821
+ const activePointers = React.useRef(/* @__PURE__ */ new Map());
822
+ const pinchState = React.useRef(null);
823
+ const handleWheel = React.useCallback(
824
+ (e) => {
825
+ if (!enableZoom || !isZoomActive) return;
826
+ e.preventDefault();
827
+ e.stopPropagation();
828
+ const svg = svgRef.current;
829
+ if (!svg) return;
830
+ const rect = svg.getBoundingClientRect();
831
+ const mouseX = e.clientX - rect.left;
832
+ const fraction = mouseX / w;
833
+ const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
834
+ zoomAt(factor, fraction);
835
+ },
836
+ [enableZoom, isZoomActive, svgRef, w, zoomAt]
837
+ );
838
+ const handlePanStart = React.useCallback(
839
+ (e) => {
840
+ if (!enableZoom || tool !== "none") return;
841
+ if (e.button !== 0 && e.button !== 1) return;
842
+ const svg = svgRef.current;
843
+ if (!svg) return;
844
+ const rect = svg.getBoundingClientRect();
845
+ panStart.current = { x: e.clientX - rect.left, pointerId: e.pointerId };
846
+ },
847
+ [enableZoom, tool, svgRef]
848
+ );
849
+ const handlePanMove = React.useCallback(
850
+ (e) => {
851
+ if (!enableZoom || !panStart.current) return;
852
+ if (e.pointerId !== panStart.current.pointerId) return;
853
+ const svg = svgRef.current;
854
+ if (!svg) return;
855
+ const rect = svg.getBoundingClientRect();
856
+ const x = e.clientX - rect.left;
857
+ const dx = x - panStart.current.x;
858
+ if (!isPanning && Math.abs(dx) < 5) return;
859
+ if (!isPanning) {
860
+ setIsPanning(true);
861
+ svg.setPointerCapture(e.pointerId);
862
+ }
863
+ const df = -dx / w;
864
+ panByFraction(df);
865
+ panStart.current.x = x;
866
+ },
867
+ [enableZoom, isPanning, svgRef, w, panByFraction]
868
+ );
869
+ const handlePanEnd = React.useCallback(() => {
870
+ panStart.current = null;
871
+ setIsPanning(false);
872
+ }, []);
873
+ const updatePinch = React.useCallback(() => {
874
+ if (!enableZoom || activePointers.current.size !== 2) return;
875
+ const [p1, p2] = Array.from(activePointers.current.values());
876
+ const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y);
877
+ const midX = (p1.x + p2.x) / 2;
878
+ if (!pinchState.current) {
879
+ pinchState.current = { dist, midX };
880
+ } else {
881
+ const factor = dist / pinchState.current.dist;
882
+ const fraction = midX / w;
883
+ zoomAt(factor, fraction);
884
+ pinchState.current = { dist, midX };
885
+ }
886
+ }, [enableZoom, w, zoomAt]);
887
+ const handlePointerDown = React.useCallback(
888
+ (e) => {
889
+ if (e.pointerType === "touch" && enableZoom) {
890
+ const svg = svgRef.current;
891
+ if (!svg) return;
892
+ const rect = svg.getBoundingClientRect();
893
+ activePointers.current.set(e.pointerId, {
894
+ x: e.clientX - rect.left,
895
+ y: e.clientY - rect.top
896
+ });
897
+ setIsZoomActive(true);
898
+ updatePinch();
899
+ }
900
+ if (tool === "pen") {
901
+ onPointerDown(e);
902
+ } else {
903
+ handlePanStart(e);
904
+ onPointerDown(e);
905
+ }
906
+ },
907
+ [tool, enableZoom, svgRef, onPointerDown, handlePanStart, updatePinch]
908
+ );
909
+ const handlePointerMove = React.useCallback(
910
+ (e) => {
911
+ if (e.pointerType === "touch" && enableZoom && activePointers.current.has(e.pointerId)) {
912
+ const svg = svgRef.current;
913
+ if (!svg) return;
914
+ const rect = svg.getBoundingClientRect();
915
+ activePointers.current.set(e.pointerId, {
916
+ x: e.clientX - rect.left,
917
+ y: e.clientY - rect.top
918
+ });
919
+ updatePinch();
920
+ }
921
+ onPointerMove(e);
922
+ handlePanMove(e);
923
+ },
924
+ [enableZoom, svgRef, onPointerMove, handlePanMove, updatePinch]
925
+ );
926
+ const handlePointerUp = React.useCallback(
927
+ (e) => {
928
+ if (activePointers.current.has(e.pointerId)) {
929
+ activePointers.current.delete(e.pointerId);
930
+ if (activePointers.current.size < 2) {
931
+ pinchState.current = null;
932
+ }
933
+ if (activePointers.current.size === 0 && e.pointerType === "touch") {
934
+ setIsZoomActive(false);
935
+ }
936
+ }
937
+ onPointerUp();
938
+ handlePanEnd();
939
+ },
940
+ [onPointerUp, handlePanEnd]
941
+ );
942
+ const handleDoubleClick = React.useCallback(
943
+ () => {
944
+ if (!enableZoom) return;
945
+ resetView();
946
+ },
947
+ [enableZoom, resetView]
948
+ );
687
949
  return /* @__PURE__ */ jsxRuntime.jsxs(
688
950
  "svg",
689
951
  {
@@ -692,22 +954,38 @@ function NumberLine({ className }) {
692
954
  className: className ? `nl-svg ${className}` : "nl-svg",
693
955
  viewBox: `0 0 ${w} ${h}`,
694
956
  preserveAspectRatio: "none",
695
- style: { width: "100%", height: "100%", display: "block", outline: "none", cursor: svgCursor, touchAction: "none" },
696
- onPointerDown,
697
- onPointerMove,
698
- onPointerUp,
957
+ style: {
958
+ width: "100%",
959
+ height: "100%",
960
+ display: "block",
961
+ outline: enableZoom && isZoomActive ? "2px solid rgba(59, 130, 246, 0.5)" : "none",
962
+ cursor: svgCursor,
963
+ touchAction: "none"
964
+ },
965
+ onPointerDown: handlePointerDown,
966
+ onPointerMove: handlePointerMove,
967
+ onPointerUp: handlePointerUp,
968
+ onWheel: handleWheel,
969
+ onDoubleClick: handleDoubleClick,
970
+ onMouseEnter: () => enableZoom && setIsZoomActive(true),
971
+ onMouseLeave: () => enableZoom && setIsZoomActive(false),
972
+ onFocus: () => enableZoom && setIsZoomActive(true),
973
+ onBlur: () => enableZoom && setIsZoomActive(false),
699
974
  children: [
700
975
  /* @__PURE__ */ jsxRuntime.jsx(
701
976
  AxisLayer,
702
977
  {
703
978
  min,
704
979
  max,
980
+ viewMin,
981
+ viewMax,
705
982
  axisY,
706
983
  padLeft: PAD_LEFT,
707
984
  padRight: PAD_RIGHT,
708
985
  viewWidth: w,
709
986
  containerWidth: w,
710
987
  groupSize,
988
+ groupCount,
711
989
  tickStep,
712
990
  showGroupTicks,
713
991
  showMajorTicks,
@@ -719,6 +997,8 @@ function NumberLine({ className }) {
719
997
  {
720
998
  min,
721
999
  max,
1000
+ viewMin,
1001
+ viewMax,
722
1002
  groupSize,
723
1003
  groupCount,
724
1004
  axisY,
package/dist/index.d.ts CHANGED
@@ -19,6 +19,12 @@ export declare interface NumberLineContextValue {
19
19
  showGroupTicks: boolean;
20
20
  showMajorTicks: boolean;
21
21
  showMinorTicks: boolean;
22
+ viewMin: number;
23
+ viewMax: number;
24
+ enableZoom: boolean;
25
+ resetView: () => void;
26
+ zoomAt: (factor: number, fraction: number) => void;
27
+ panByFraction: (df: number) => void;
22
28
  tool: Tool;
23
29
  strokes: Stroke[];
24
30
  liveStroke: Stroke | null;
@@ -51,9 +57,20 @@ export declare interface NumberLineProps {
51
57
  showMajorTicks?: boolean;
52
58
  /** show minor ticks (in-between). Default: false */
53
59
  showMinorTicks?: boolean;
60
+ /** enable mouse-wheel / pinch zoom + drag-to-pan. Default: false */
61
+ enableZoom?: boolean;
62
+ /** minimum zoom level. 1 = full [min, max] range. Default: 1 */
63
+ minZoom?: number;
64
+ /** maximum zoom level. Default: 50 */
65
+ maxZoom?: number;
66
+ /** controlled view: if both provided, viewport is driven by parent */
67
+ viewMin?: number;
68
+ viewMax?: number;
69
+ /** fired whenever internal viewport changes (uncontrolled mode) */
70
+ onViewChange?: (viewMin: number, viewMax: number) => void;
54
71
  }
55
72
 
56
- export declare function NumberLineProvider({ min, max, groupSize, groupCount, tickStep, arcColors, showGroupTicks, showMajorTicks, showMinorTicks, children, }: NumberLineProviderProps): JSX_2.Element;
73
+ export declare function NumberLineProvider({ min, max, groupSize, groupCount, tickStep, arcColors, showGroupTicks, showMajorTicks, showMinorTicks, enableZoom, minZoom, maxZoom, viewMin: controlledViewMin, viewMax: controlledViewMax, onViewChange, children, }: NumberLineProviderProps): JSX_2.Element;
57
74
 
58
75
  declare interface NumberLineProviderProps extends NumberLineProps {
59
76
  children: default_2.ReactNode;
package/dist/index.js CHANGED
@@ -143,6 +143,89 @@ function useContainerSize() {
143
143
  }, []);
144
144
  return { ref, width: size.width, height: size.height };
145
145
  }
146
+ function useZoom({
147
+ dataMin,
148
+ dataMax,
149
+ minZoom,
150
+ maxZoom,
151
+ controlledMin,
152
+ controlledMax,
153
+ onChange
154
+ }) {
155
+ const isControlled = controlledMin !== void 0 && controlledMax !== void 0;
156
+ const [internal, setInternal] = useState([dataMin, dataMax]);
157
+ const lastRange = useRef([dataMin, dataMax]);
158
+ useEffect(() => {
159
+ const [lm, lM] = lastRange.current;
160
+ if (lm !== dataMin || lM !== dataMax) {
161
+ lastRange.current = [dataMin, dataMax];
162
+ if (!isControlled) setInternal([dataMin, dataMax]);
163
+ }
164
+ }, [dataMin, dataMax, isControlled]);
165
+ const viewMin = isControlled ? controlledMin : internal[0];
166
+ const viewMax = isControlled ? controlledMax : internal[1];
167
+ const viewRef = useRef({ viewMin, viewMax });
168
+ viewRef.current = { viewMin, viewMax };
169
+ const dataRange = dataMax - dataMin;
170
+ const minView = dataRange / Math.max(1, maxZoom);
171
+ const maxView = dataRange / Math.max(1, Math.min(minZoom, maxZoom));
172
+ const commit = useCallback(
173
+ (next) => {
174
+ const { viewMin: curMin, viewMax: curMax } = viewRef.current;
175
+ let [a, b] = next;
176
+ let width = b - a;
177
+ if (width < minView) {
178
+ const c = (a + b) / 2;
179
+ a = c - minView / 2;
180
+ b = c + minView / 2;
181
+ width = minView;
182
+ }
183
+ if (width > maxView) {
184
+ const c = (a + b) / 2;
185
+ a = c - maxView / 2;
186
+ b = c + maxView / 2;
187
+ width = maxView;
188
+ }
189
+ if (a < dataMin) {
190
+ b += dataMin - a;
191
+ a = dataMin;
192
+ }
193
+ if (b > dataMax) {
194
+ a -= b - dataMax;
195
+ b = dataMax;
196
+ }
197
+ if (a < dataMin) a = dataMin;
198
+ if (b > dataMax) b = dataMax;
199
+ if (a === curMin && b === curMax) return;
200
+ if (!isControlled) setInternal([a, b]);
201
+ onChange == null ? void 0 : onChange(a, b);
202
+ },
203
+ [minView, maxView, dataMin, dataMax, isControlled, onChange]
204
+ );
205
+ const zoomAt = useCallback(
206
+ (factor, f) => {
207
+ const width = viewMax - viewMin;
208
+ const anchorVal = viewMin + width * f;
209
+ const newWidth = width / factor;
210
+ const a = anchorVal - newWidth * f;
211
+ const b = anchorVal + newWidth * (1 - f);
212
+ commit([a, b]);
213
+ },
214
+ [viewMin, viewMax, commit]
215
+ );
216
+ const panByFraction = useCallback(
217
+ (df) => {
218
+ const width = viewMax - viewMin;
219
+ const dv = width * df;
220
+ commit([viewMin + dv, viewMax + dv]);
221
+ },
222
+ [viewMin, viewMax, commit]
223
+ );
224
+ const resetView = useCallback(() => {
225
+ commit([dataMin, dataMax]);
226
+ }, [commit, dataMin, dataMax]);
227
+ return { viewMin, viewMax, zoomAt, panByFraction, resetView };
228
+ }
146
229
  const NumberLineContext = createContext(null);
147
230
  function useNumberLineContext() {
148
231
  const ctx = useContext(NumberLineContext);
@@ -159,6 +242,12 @@ function NumberLineProvider({
159
242
  showGroupTicks = true,
160
243
  showMajorTicks = true,
161
244
  showMinorTicks = false,
245
+ enableZoom = false,
246
+ minZoom = 1,
247
+ maxZoom = 50,
248
+ viewMin: controlledViewMin,
249
+ viewMax: controlledViewMax,
250
+ onViewChange,
162
251
  children
163
252
  }) {
164
253
  const [selectedArcIndices, setSelectedArcIndices] = useState(/* @__PURE__ */ new Set());
@@ -166,6 +255,15 @@ function NumberLineProvider({
166
255
  const { ref: svgRef, width: containerWidth, height: containerHeight } = useContainerSize();
167
256
  const history = useHistory();
168
257
  const drawing = useDrawing();
258
+ const { viewMin, viewMax, zoomAt, panByFraction, resetView } = useZoom({
259
+ dataMin: min,
260
+ dataMax: max,
261
+ minZoom,
262
+ maxZoom,
263
+ controlledMin: controlledViewMin,
264
+ controlledMax: controlledViewMax,
265
+ onChange: onViewChange
266
+ });
169
267
  const [liveStroke, setLiveStroke] = useState(null);
170
268
  const svgPoint = useRef(null);
171
269
  useEffect(() => {
@@ -337,6 +435,12 @@ function NumberLineProvider({
337
435
  showGroupTicks,
338
436
  showMajorTicks,
339
437
  showMinorTicks,
438
+ viewMin,
439
+ viewMax,
440
+ enableZoom,
441
+ resetView,
442
+ zoomAt,
443
+ panByFraction,
340
444
  tool: drawing.tool,
341
445
  strokes: drawing.strokes,
342
446
  liveStroke,
@@ -384,22 +488,24 @@ function gcd(a, b) {
384
488
  }
385
489
  const AxisLayer = React.memo(function AxisLayer2({
386
490
  min,
387
- max,
491
+ viewMin,
492
+ viewMax,
388
493
  axisY,
389
494
  padLeft,
390
495
  padRight,
391
496
  viewWidth,
392
497
  containerWidth,
393
498
  groupSize,
499
+ groupCount,
394
500
  tickStep,
395
501
  showGroupTicks,
396
502
  showMajorTicks,
397
503
  showMinorTicks
398
504
  }) {
399
- const range = max - min;
505
+ const range = viewMax - viewMin;
400
506
  if (range <= 0) return null;
401
507
  const usable = viewWidth - padLeft - padRight;
402
- const toX = (v) => padLeft + (v - min) / range * usable;
508
+ const toX = (v) => padLeft + (v - viewMin) / range * usable;
403
509
  const step = Math.max(1, Math.round(tickStep));
404
510
  const rawLabelStep = computeLabelStep(containerWidth, range);
405
511
  const labelStep = Math.max(step, Math.ceil(rawLabelStep / step) * step);
@@ -407,8 +513,11 @@ const AxisLayer = React.memo(function AxisLayer2({
407
513
  const capStep = Math.max(1, Math.ceil(range / MAX_TICKS));
408
514
  const visibleStep = groupSize > 0 ? gcd(step, groupSize) : step;
409
515
  const iterStep = showMinorTicks ? capStep : Math.max(capStep, visibleStep);
516
+ const startVal = Math.floor(viewMin / iterStep) * iterStep;
410
517
  const allTicks = [];
411
- for (let v = min; v <= max; v += iterStep) allTicks.push(v);
518
+ for (let v = startVal; v <= viewMax; v += iterStep) {
519
+ if (v >= viewMin && v <= viewMax) allTicks.push(v);
520
+ }
412
521
  return /* @__PURE__ */ jsxs("g", { className: "nl-axis-layer", children: [
413
522
  /* @__PURE__ */ jsx(
414
523
  "line",
@@ -425,7 +534,8 @@ const AxisLayer = React.memo(function AxisLayer2({
425
534
  const x = toX(v);
426
535
  const offset = v - min;
427
536
  const isMajor = offset % step === 0;
428
- const isGroupBoundary = groupSize > 0 && offset % groupSize === 0;
537
+ const maxGroupBoundary = min + groupCount * groupSize;
538
+ const isGroupBoundary = groupSize > 0 && offset % groupSize === 0 && v <= maxGroupBoundary;
429
539
  const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || !isGroupBoundary && !isMajor && showMinorTicks;
430
540
  if (!visible) return null;
431
541
  const renderAsGroup = isGroupBoundary && showGroupTicks;
@@ -466,6 +576,8 @@ const AxisLayer = React.memo(function AxisLayer2({
466
576
  const GroupArcsLayer = memo(function GroupArcsLayer2({
467
577
  min,
468
578
  max,
579
+ viewMin,
580
+ viewMax,
469
581
  groupSize,
470
582
  groupCount,
471
583
  axisY,
@@ -480,15 +592,17 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
480
592
  arcColors = ARC_COLORS
481
593
  }) {
482
594
  if (groupSize <= 0 || groupCount <= 0) return null;
483
- const range = max - min;
595
+ const range = viewMax - viewMin;
484
596
  const usable = viewWidth - padLeft - padRight;
485
- const toX = (v) => padLeft + (v - min) / range * usable;
597
+ const toX = (v) => padLeft + (v - viewMin) / range * usable;
598
+ const clipId = React.useId().replace(/:/g, "_") + "-arcs-clip";
486
599
  const arcs = [];
487
600
  for (let g = 0; g < groupCount; g++) {
488
601
  if (deletedArcIndices.has(g)) continue;
489
602
  const start = min + g * groupSize;
490
603
  const end = start + groupSize;
491
604
  if (end > max) break;
605
+ if (end < viewMin || start > viewMax) continue;
492
606
  const x1 = toX(start);
493
607
  const x2 = toX(end);
494
608
  const rx = (x2 - x1) / 2;
@@ -534,7 +648,7 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
534
648
  strokeLinecap: "round",
535
649
  pointerEvents: "stroke",
536
650
  style: { cursor: hitCursor },
537
- onPointerDown: (e) => {
651
+ onClick: (e) => {
538
652
  if (tool === "eraser" || tool === "none") {
539
653
  e.stopPropagation();
540
654
  onArcClick(g, e);
@@ -579,7 +693,18 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
579
693
  ] }, g)
580
694
  );
581
695
  }
582
- return /* @__PURE__ */ jsx("g", { className: "nl-arcs-layer", children: arcs });
696
+ return /* @__PURE__ */ jsxs("g", { className: "nl-arcs-layer", children: [
697
+ /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("clipPath", { id: clipId, children: /* @__PURE__ */ jsx(
698
+ "rect",
699
+ {
700
+ x: padLeft,
701
+ y: 0,
702
+ width: usable,
703
+ height: axisY + 1
704
+ }
705
+ ) }) }),
706
+ /* @__PURE__ */ jsx("g", { clipPath: `url(#${clipId})`, children: arcs })
707
+ ] });
583
708
  });
584
709
  function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
585
710
  return /* @__PURE__ */ jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
@@ -609,7 +734,7 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
609
734
  vectorEffect: "non-scaling-stroke",
610
735
  pointerEvents: "stroke",
611
736
  style: { cursor: tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default" },
612
- onPointerDown: (e) => {
737
+ onClick: (e) => {
613
738
  if (tool === "eraser" || tool === "none") {
614
739
  e.stopPropagation();
615
740
  onStrokeClick(s.id, e);
@@ -667,6 +792,12 @@ function NumberLine({ className }) {
667
792
  showGroupTicks,
668
793
  showMajorTicks,
669
794
  showMinorTicks,
795
+ viewMin,
796
+ viewMax,
797
+ enableZoom,
798
+ resetView,
799
+ zoomAt,
800
+ panByFraction,
670
801
  tool,
671
802
  strokes,
672
803
  liveStroke,
@@ -682,6 +813,137 @@ function NumberLine({ className }) {
682
813
  const h = containerHeight;
683
814
  const axisY = Math.max(MIN_ARC_HEIGHT + ARC_TOP_PADDING, h - AXIS_BOTTOM_OFFSET);
684
815
  const arcMaxHeight = Math.max(MIN_ARC_HEIGHT, axisY - ARC_TOP_PADDING);
816
+ const [isZoomActive, setIsZoomActive] = useState(false);
817
+ const [isPanning, setIsPanning] = useState(false);
818
+ const panStart = useRef(null);
819
+ const activePointers = useRef(/* @__PURE__ */ new Map());
820
+ const pinchState = useRef(null);
821
+ const handleWheel = useCallback(
822
+ (e) => {
823
+ if (!enableZoom || !isZoomActive) return;
824
+ e.preventDefault();
825
+ e.stopPropagation();
826
+ const svg = svgRef.current;
827
+ if (!svg) return;
828
+ const rect = svg.getBoundingClientRect();
829
+ const mouseX = e.clientX - rect.left;
830
+ const fraction = mouseX / w;
831
+ const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
832
+ zoomAt(factor, fraction);
833
+ },
834
+ [enableZoom, isZoomActive, svgRef, w, zoomAt]
835
+ );
836
+ const handlePanStart = useCallback(
837
+ (e) => {
838
+ if (!enableZoom || tool !== "none") return;
839
+ if (e.button !== 0 && e.button !== 1) return;
840
+ const svg = svgRef.current;
841
+ if (!svg) return;
842
+ const rect = svg.getBoundingClientRect();
843
+ panStart.current = { x: e.clientX - rect.left, pointerId: e.pointerId };
844
+ },
845
+ [enableZoom, tool, svgRef]
846
+ );
847
+ const handlePanMove = useCallback(
848
+ (e) => {
849
+ if (!enableZoom || !panStart.current) return;
850
+ if (e.pointerId !== panStart.current.pointerId) return;
851
+ const svg = svgRef.current;
852
+ if (!svg) return;
853
+ const rect = svg.getBoundingClientRect();
854
+ const x = e.clientX - rect.left;
855
+ const dx = x - panStart.current.x;
856
+ if (!isPanning && Math.abs(dx) < 5) return;
857
+ if (!isPanning) {
858
+ setIsPanning(true);
859
+ svg.setPointerCapture(e.pointerId);
860
+ }
861
+ const df = -dx / w;
862
+ panByFraction(df);
863
+ panStart.current.x = x;
864
+ },
865
+ [enableZoom, isPanning, svgRef, w, panByFraction]
866
+ );
867
+ const handlePanEnd = useCallback(() => {
868
+ panStart.current = null;
869
+ setIsPanning(false);
870
+ }, []);
871
+ const updatePinch = useCallback(() => {
872
+ if (!enableZoom || activePointers.current.size !== 2) return;
873
+ const [p1, p2] = Array.from(activePointers.current.values());
874
+ const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y);
875
+ const midX = (p1.x + p2.x) / 2;
876
+ if (!pinchState.current) {
877
+ pinchState.current = { dist, midX };
878
+ } else {
879
+ const factor = dist / pinchState.current.dist;
880
+ const fraction = midX / w;
881
+ zoomAt(factor, fraction);
882
+ pinchState.current = { dist, midX };
883
+ }
884
+ }, [enableZoom, w, zoomAt]);
885
+ const handlePointerDown = useCallback(
886
+ (e) => {
887
+ if (e.pointerType === "touch" && enableZoom) {
888
+ const svg = svgRef.current;
889
+ if (!svg) return;
890
+ const rect = svg.getBoundingClientRect();
891
+ activePointers.current.set(e.pointerId, {
892
+ x: e.clientX - rect.left,
893
+ y: e.clientY - rect.top
894
+ });
895
+ setIsZoomActive(true);
896
+ updatePinch();
897
+ }
898
+ if (tool === "pen") {
899
+ onPointerDown(e);
900
+ } else {
901
+ handlePanStart(e);
902
+ onPointerDown(e);
903
+ }
904
+ },
905
+ [tool, enableZoom, svgRef, onPointerDown, handlePanStart, updatePinch]
906
+ );
907
+ const handlePointerMove = useCallback(
908
+ (e) => {
909
+ if (e.pointerType === "touch" && enableZoom && activePointers.current.has(e.pointerId)) {
910
+ const svg = svgRef.current;
911
+ if (!svg) return;
912
+ const rect = svg.getBoundingClientRect();
913
+ activePointers.current.set(e.pointerId, {
914
+ x: e.clientX - rect.left,
915
+ y: e.clientY - rect.top
916
+ });
917
+ updatePinch();
918
+ }
919
+ onPointerMove(e);
920
+ handlePanMove(e);
921
+ },
922
+ [enableZoom, svgRef, onPointerMove, handlePanMove, updatePinch]
923
+ );
924
+ const handlePointerUp = useCallback(
925
+ (e) => {
926
+ if (activePointers.current.has(e.pointerId)) {
927
+ activePointers.current.delete(e.pointerId);
928
+ if (activePointers.current.size < 2) {
929
+ pinchState.current = null;
930
+ }
931
+ if (activePointers.current.size === 0 && e.pointerType === "touch") {
932
+ setIsZoomActive(false);
933
+ }
934
+ }
935
+ onPointerUp();
936
+ handlePanEnd();
937
+ },
938
+ [onPointerUp, handlePanEnd]
939
+ );
940
+ const handleDoubleClick = useCallback(
941
+ () => {
942
+ if (!enableZoom) return;
943
+ resetView();
944
+ },
945
+ [enableZoom, resetView]
946
+ );
685
947
  return /* @__PURE__ */ jsxs(
686
948
  "svg",
687
949
  {
@@ -690,22 +952,38 @@ function NumberLine({ className }) {
690
952
  className: className ? `nl-svg ${className}` : "nl-svg",
691
953
  viewBox: `0 0 ${w} ${h}`,
692
954
  preserveAspectRatio: "none",
693
- style: { width: "100%", height: "100%", display: "block", outline: "none", cursor: svgCursor, touchAction: "none" },
694
- onPointerDown,
695
- onPointerMove,
696
- onPointerUp,
955
+ style: {
956
+ width: "100%",
957
+ height: "100%",
958
+ display: "block",
959
+ outline: enableZoom && isZoomActive ? "2px solid rgba(59, 130, 246, 0.5)" : "none",
960
+ cursor: svgCursor,
961
+ touchAction: "none"
962
+ },
963
+ onPointerDown: handlePointerDown,
964
+ onPointerMove: handlePointerMove,
965
+ onPointerUp: handlePointerUp,
966
+ onWheel: handleWheel,
967
+ onDoubleClick: handleDoubleClick,
968
+ onMouseEnter: () => enableZoom && setIsZoomActive(true),
969
+ onMouseLeave: () => enableZoom && setIsZoomActive(false),
970
+ onFocus: () => enableZoom && setIsZoomActive(true),
971
+ onBlur: () => enableZoom && setIsZoomActive(false),
697
972
  children: [
698
973
  /* @__PURE__ */ jsx(
699
974
  AxisLayer,
700
975
  {
701
976
  min,
702
977
  max,
978
+ viewMin,
979
+ viewMax,
703
980
  axisY,
704
981
  padLeft: PAD_LEFT,
705
982
  padRight: PAD_RIGHT,
706
983
  viewWidth: w,
707
984
  containerWidth: w,
708
985
  groupSize,
986
+ groupCount,
709
987
  tickStep,
710
988
  showGroupTicks,
711
989
  showMajorTicks,
@@ -717,6 +995,8 @@ function NumberLine({ className }) {
717
995
  {
718
996
  min,
719
997
  max,
998
+ viewMin,
999
+ viewMax,
720
1000
  groupSize,
721
1001
  groupCount,
722
1002
  axisY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcg-overseas/number-line",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Interactive number line component with group arcs and freehand drawing",
5
5
  "type": "module",
6
6
  "license": "MIT",