@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 +62 -0
- package/dist/index.cjs +294 -14
- package/dist/index.d.ts +18 -1
- package/dist/index.js +294 -14
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
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 -
|
|
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 =
|
|
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
|
|
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 =
|
|
597
|
+
const range = viewMax - viewMin;
|
|
486
598
|
const usable = viewWidth - padLeft - padRight;
|
|
487
|
-
const toX = (v) => padLeft + (v -
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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: {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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 =
|
|
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 -
|
|
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 =
|
|
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
|
|
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 =
|
|
595
|
+
const range = viewMax - viewMin;
|
|
484
596
|
const usable = viewWidth - padLeft - padRight;
|
|
485
|
-
const toX = (v) => padLeft + (v -
|
|
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
|
-
|
|
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__ */
|
|
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
|
-
|
|
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: {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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,
|