@dcg-overseas/number-line 0.1.3 → 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 +317 -0
- package/dist/index.cjs +301 -19
- package/dist/index.d.ts +18 -1
- package/dist/index.js +301 -19
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# @dcg-overseas/number-line
|
|
2
|
+
|
|
3
|
+
交互式数轴组件,支持分组弧线(带箭头终点标签)、自由画笔、橡皮擦、撤销与键盘快捷键。
|
|
4
|
+
|
|
5
|
+
- 容器自适应:`viewBox` 跟随容器实际像素,文字和线条始终保持设计尺寸
|
|
6
|
+
- 笔迹归一化存储(`[0,1]` 坐标 + `vectorEffect="non-scaling-stroke"`),容器 resize 后笔迹位置稳定不漂移
|
|
7
|
+
- Provider/Component 解耦:状态在 `NumberLineProvider` 中管理,工具栏与数轴 SVG 各自独立组装
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @dcg-overseas/number-line
|
|
15
|
+
# peer deps
|
|
16
|
+
pnpm add react react-dom
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 快速开始
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { NumberLineProvider, NumberLine } from '@dcg-overseas/number-line'
|
|
25
|
+
|
|
26
|
+
function Demo() {
|
|
27
|
+
return (
|
|
28
|
+
<NumberLineProvider
|
|
29
|
+
min={0}
|
|
30
|
+
max={100}
|
|
31
|
+
groupSize={10}
|
|
32
|
+
groupCount={10}
|
|
33
|
+
tickStep={5}
|
|
34
|
+
>
|
|
35
|
+
<div style={{ width: '100%', height: 200 }}>
|
|
36
|
+
<NumberLine />
|
|
37
|
+
</div>
|
|
38
|
+
</NumberLineProvider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> ⚠️ **必须给 `<NumberLine />` 的父容器明确的宽高**(CSS 尺寸或 flex 布局),SVG 默认 `width:100% height:100%`,没有外部尺寸约束会塌陷为 0。
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
### `<NumberLineProvider>`
|
|
50
|
+
|
|
51
|
+
数轴的状态根。包裹任何需要访问数轴上下文的子组件(`<NumberLine />` 和你自己的工具栏按钮)。
|
|
52
|
+
|
|
53
|
+
| Prop | 类型 | 默认值 | 说明 |
|
|
54
|
+
|------|------|-------|------|
|
|
55
|
+
| `min` | `number` | — | 数轴起点值(含) |
|
|
56
|
+
| `max` | `number` | — | 数轴终点值(含) |
|
|
57
|
+
| `groupSize` | `number` | — | 每组弧线跨度(数轴单位)。例:`groupSize=8` 表示每段弧从 `n` 跳到 `n+8` |
|
|
58
|
+
| `groupCount` | `number` | — | 弧线组数。从 `min` 起依次绘制 `groupCount` 段弧线 |
|
|
59
|
+
| `tickStep` | `number` | — | 主刻度步长(数轴单位)。例:`tickStep=5` 表示 0、5、10、15… 为主刻度 |
|
|
60
|
+
| `arcColors` | `string[]` | 5 色循环 | 弧线颜色数组,按 `groupIndex % length` 轮换。默认值见 [`utils/geometry.ts` ARC_COLORS](src/utils/geometry.ts) |
|
|
61
|
+
| `showGroupTicks` | `boolean` | `true` | 是否显示分组边界刻度(最长,10px) |
|
|
62
|
+
| `showMajorTicks` | `boolean` | `true` | 是否显示主刻度(中等,7px) |
|
|
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 变化时触发(滚轮/拖拽/双击重置) |
|
|
70
|
+
| `children` | `ReactNode` | — | 子节点(必须包含 `<NumberLine />` 或自定义渲染器) |
|
|
71
|
+
|
|
72
|
+
#### 弧线生成规则
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
弧线 g 的范围:[min + g*groupSize, min + (g+1)*groupSize]
|
|
76
|
+
g ∈ [0, groupCount)
|
|
77
|
+
若 (min + (g+1)*groupSize) > max,弧线提前停止
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### 三档刻度优先级
|
|
81
|
+
|
|
82
|
+
一个位置可能同时是多档刻度(例如 `tickStep=5, groupSize=10` 时,10 既是主刻度也是分组边界)。视觉与可见性按 **分组边界 > 主刻度 > 次刻度** 归类,仅看其最高档对应的 `show*Ticks`。
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
// 默认:只显示主刻度 + 分组边界
|
|
86
|
+
<NumberLineProvider min={0} max={100} groupSize={10} groupCount={10} tickStep={5} />
|
|
87
|
+
|
|
88
|
+
// 显示全部三档刻度
|
|
89
|
+
<NumberLineProvider ... showMinorTicks />
|
|
90
|
+
|
|
91
|
+
// 极简模式:仅分组边界
|
|
92
|
+
<NumberLineProvider ... showMajorTicks={false} />
|
|
93
|
+
```
|
|
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
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### `<NumberLine>`
|
|
146
|
+
|
|
147
|
+
数轴 SVG 视图。必须放在 `<NumberLineProvider>` 内,自动从 context 读取所有配置和事件处理。
|
|
148
|
+
|
|
149
|
+
| Prop | 类型 | 默认值 | 说明 |
|
|
150
|
+
|------|------|-------|------|
|
|
151
|
+
| `className` | `string?` | — | 追加到 SVG 根节点的 className(基础类名 `nl-svg`) |
|
|
152
|
+
|
|
153
|
+
**渲染层级(从下到上):**
|
|
154
|
+
1. `AxisLayer` — 数轴线 + 刻度 + 标签
|
|
155
|
+
2. `GroupArcsLayer` — 分组弧线 + 弧顶箭头标签 + 选中高亮
|
|
156
|
+
3. `DrawingLayer` — 用户笔迹(normalized `[0,1]` 坐标,scale 还原)
|
|
157
|
+
|
|
158
|
+
**布局参数(内部常量,无需配置):**
|
|
159
|
+
|
|
160
|
+
| 名称 | 值 | 含义 |
|
|
161
|
+
|------|---|------|
|
|
162
|
+
| `PAD_LEFT` | 30 | 数轴左 padding |
|
|
163
|
+
| `PAD_RIGHT` | 40 | 数轴右 padding |
|
|
164
|
+
| `AXIS_BOTTOM_OFFSET` | 50 | 数轴线距容器底部像素(给刻度数字留空间) |
|
|
165
|
+
| `ARC_TOP_PADDING` | 16 | 弧线顶部距容器顶部像素 |
|
|
166
|
+
| `MIN_ARC_HEIGHT` | 24 | 弧线最小高度(容器极小时兜底) |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### `useNumberLineContext()`
|
|
171
|
+
|
|
172
|
+
读取 / 操作数轴状态的 hook。**必须在 `<NumberLineProvider>` 子树内调用**,否则抛错。
|
|
173
|
+
|
|
174
|
+
返回 `NumberLineContextValue`:
|
|
175
|
+
|
|
176
|
+
#### 布局
|
|
177
|
+
| 字段 | 类型 | 说明 |
|
|
178
|
+
|------|------|------|
|
|
179
|
+
| `svgRef` | `RefObject<SVGSVGElement>` | SVG 元素 ref |
|
|
180
|
+
| `containerWidth` | `number` | 容器实际像素宽 |
|
|
181
|
+
| `containerHeight` | `number` | 容器实际像素高 |
|
|
182
|
+
| `svgCursor` | `string` | 当前 SVG cursor(自动随 tool 切换) |
|
|
183
|
+
|
|
184
|
+
#### 配置(透传自 Provider props)
|
|
185
|
+
| 字段 | 类型 |
|
|
186
|
+
|------|------|
|
|
187
|
+
| `min` `max` `groupSize` `groupCount` `tickStep` | `number` |
|
|
188
|
+
| `arcColors` | `string[] \| undefined` |
|
|
189
|
+
| `showGroupTicks` `showMajorTicks` `showMinorTicks` | `boolean` |
|
|
190
|
+
|
|
191
|
+
#### 绘图状态
|
|
192
|
+
| 字段 | 类型 | 说明 |
|
|
193
|
+
|------|------|------|
|
|
194
|
+
| `tool` | `'none' \| 'pen' \| 'eraser'` | 当前工具 |
|
|
195
|
+
| `strokes` | `Stroke[]` | 已落笔的所有笔迹 |
|
|
196
|
+
| `liveStroke` | `Stroke \| null` | 正在绘制中的笔迹(半透明预览) |
|
|
197
|
+
| `selectedArcIndices` | `Set<number>` | 当前选中的弧线 group index |
|
|
198
|
+
| `deletedArcIndices` | `Set<number>` | 已被擦除/删除的弧线 group index |
|
|
199
|
+
|
|
200
|
+
#### 事件回调(已绑定到 `<NumberLine>`,自定义渲染时使用)
|
|
201
|
+
| 字段 | 类型 |
|
|
202
|
+
|------|------|
|
|
203
|
+
| `onPointerDown` `onPointerMove` `onPointerUp` | `(e: PointerEvent) => void` |
|
|
204
|
+
| `onStrokeClick` | `(id: string, e: PointerEvent) => void` |
|
|
205
|
+
| `onArcClick` | `(groupIndex: number, e: PointerEvent) => void` |
|
|
206
|
+
|
|
207
|
+
#### 工具栏 API
|
|
208
|
+
| 字段 | 类型 | 说明 |
|
|
209
|
+
|------|------|------|
|
|
210
|
+
| `canDelete` | `boolean` | 是否有可删除的选中项(笔迹或弧线) |
|
|
211
|
+
| `canUndo` | `boolean` | 是否有可撤销的操作 |
|
|
212
|
+
| `onTogglePen` | `() => void` | 切换画笔工具 |
|
|
213
|
+
| `onToggleEraser` | `() => void` | 切换橡皮擦 |
|
|
214
|
+
| `onDelete` | `() => void` | 删除当前选中 |
|
|
215
|
+
| `onReset` | `() => void` | 清空所有笔迹和弧线删除记录 |
|
|
216
|
+
| `onUndo` | `() => void` | 撤销上一步操作 |
|
|
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
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 自定义工具栏示例
|
|
229
|
+
|
|
230
|
+
`<NumberLine>` 不附带工具栏。把按钮和数轴一起放在 `<NumberLineProvider>` 下,通过 `useNumberLineContext` 接入:
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
import { NumberLineProvider, NumberLine, useNumberLineContext } from '@dcg-overseas/number-line'
|
|
234
|
+
|
|
235
|
+
function Toolbar() {
|
|
236
|
+
const { tool, canDelete, canUndo, onTogglePen, onToggleEraser, onDelete, onReset, onUndo } =
|
|
237
|
+
useNumberLineContext()
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="flex gap-2">
|
|
241
|
+
<button data-active={tool === 'pen'} onClick={onTogglePen}>✏️ 画笔</button>
|
|
242
|
+
<button data-active={tool === 'eraser'} onClick={onToggleEraser}>🩹 擦除</button>
|
|
243
|
+
<button disabled={!canDelete} onClick={onDelete}>🗑 删除</button>
|
|
244
|
+
<button onClick={onReset}>♻️ 重置</button>
|
|
245
|
+
<button disabled={!canUndo} onClick={onUndo}>↶ 撤销</button>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function App() {
|
|
251
|
+
return (
|
|
252
|
+
<NumberLineProvider min={0} max={80} groupSize={8} groupCount={10} tickStep={1}>
|
|
253
|
+
<Toolbar />
|
|
254
|
+
<div style={{ height: 220 }}>
|
|
255
|
+
<NumberLine />
|
|
256
|
+
</div>
|
|
257
|
+
</NumberLineProvider>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 键盘快捷键
|
|
265
|
+
|
|
266
|
+
快捷键绑定在 SVG 元素上(`tabIndex={0}`,需先聚焦 SVG),多实例互不干扰,也不会拦截外部输入框。
|
|
267
|
+
|
|
268
|
+
| 按键 | 行为 |
|
|
269
|
+
|------|------|
|
|
270
|
+
| `Esc` | 切回 `'none'` 模式,清空弧线选中 |
|
|
271
|
+
| `Delete` / `Backspace` | 删除当前选中的笔迹和弧线 |
|
|
272
|
+
| `Ctrl+Z` / `Cmd+Z` | 撤销上一步 |
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## 类型导出
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import type {
|
|
280
|
+
NumberLineProps,
|
|
281
|
+
NumberLineContextValue,
|
|
282
|
+
} from '@dcg-overseas/number-line'
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
完整类型见 [src/types.ts](src/types.ts) 与 [src/context/NumberLineContext.ts](src/context/NumberLineContext.ts)。
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## 设计要点
|
|
290
|
+
|
|
291
|
+
### 自适应坐标系
|
|
292
|
+
|
|
293
|
+
`viewBox` 动态跟随容器实际像素(`viewBox={`0 0 ${w} ${h}`}` + `preserveAspectRatio="none"`),1 SVG 单位 = 1 CSS 像素。这样:
|
|
294
|
+
|
|
295
|
+
- 文字和线条始终是设计尺寸(fontSize=11 永远是 11px),不会因为容器纵横比异常被缩到看不见
|
|
296
|
+
- 数轴自动贴近容器底部,弧线撑满上方空间,无空白浪费
|
|
297
|
+
|
|
298
|
+
### 笔迹归一化(resize 稳定)
|
|
299
|
+
|
|
300
|
+
笔迹存储为 `[0, 1]` 归一化坐标(`M 0.234 0.456 L ...`)。渲染时用 `<g transform={`scale(${w} ${h})`}>` 还原到容器像素,并配合 `vectorEffect="non-scaling-stroke"` 锁定线宽。
|
|
301
|
+
|
|
302
|
+
效果:容器从 `400×200` 变成 `600×300`,所有笔迹的相对位置仍然贴合数轴,stroke 宽度保持 2.5px。
|
|
303
|
+
|
|
304
|
+
### 操作级撤销
|
|
305
|
+
|
|
306
|
+
撤销栈记录的是「操作」而非「状态」(`HistoryOp`),分两类:
|
|
307
|
+
|
|
308
|
+
- `add` — 新增笔迹,撤销 = 移除指定 id
|
|
309
|
+
- `delete` — 删除笔迹/弧线,撤销 = 恢复删除前完整快照 + 还原弧线 group index
|
|
310
|
+
|
|
311
|
+
这样即使一次 `delete` 同时删了多条笔迹和多条弧线,撤销也能精确还原。
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## License
|
|
316
|
+
|
|
317
|
+
MIT
|
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,13 +536,16 @@ 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
|
|
431
|
-
const
|
|
432
|
-
const visible = isGroupBoundary
|
|
539
|
+
const maxGroupBoundary = min + groupCount * groupSize;
|
|
540
|
+
const isGroupBoundary = groupSize > 0 && offset % groupSize === 0 && v <= maxGroupBoundary;
|
|
541
|
+
const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || !isGroupBoundary && !isMajor && showMinorTicks;
|
|
433
542
|
if (!visible) return null;
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
const
|
|
543
|
+
const renderAsGroup = isGroupBoundary && showGroupTicks;
|
|
544
|
+
const renderAsMajor = !renderAsGroup && isMajor && showMajorTicks;
|
|
545
|
+
const showLabel = (renderAsGroup || renderAsMajor) && offset % labelStep === 0;
|
|
546
|
+
const tickH = renderAsGroup ? 7 : renderAsMajor ? 10 : 4;
|
|
547
|
+
const strokeW = renderAsGroup ? 1.5 : renderAsMajor ? 1 : 0.5;
|
|
548
|
+
const color = renderAsGroup || renderAsMajor ? "#374151" : "#9ca3af";
|
|
437
549
|
return /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
|
|
438
550
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
439
551
|
"line",
|
|
@@ -466,6 +578,8 @@ const AxisLayer = React.memo(function AxisLayer2({
|
|
|
466
578
|
const GroupArcsLayer = React.memo(function GroupArcsLayer2({
|
|
467
579
|
min,
|
|
468
580
|
max,
|
|
581
|
+
viewMin,
|
|
582
|
+
viewMax,
|
|
469
583
|
groupSize,
|
|
470
584
|
groupCount,
|
|
471
585
|
axisY,
|
|
@@ -480,15 +594,17 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
|
|
|
480
594
|
arcColors = ARC_COLORS
|
|
481
595
|
}) {
|
|
482
596
|
if (groupSize <= 0 || groupCount <= 0) return null;
|
|
483
|
-
const range =
|
|
597
|
+
const range = viewMax - viewMin;
|
|
484
598
|
const usable = viewWidth - padLeft - padRight;
|
|
485
|
-
const toX = (v) => padLeft + (v -
|
|
599
|
+
const toX = (v) => padLeft + (v - viewMin) / range * usable;
|
|
600
|
+
const clipId = React.useId().replace(/:/g, "_") + "-arcs-clip";
|
|
486
601
|
const arcs = [];
|
|
487
602
|
for (let g = 0; g < groupCount; g++) {
|
|
488
603
|
if (deletedArcIndices.has(g)) continue;
|
|
489
604
|
const start = min + g * groupSize;
|
|
490
605
|
const end = start + groupSize;
|
|
491
606
|
if (end > max) break;
|
|
607
|
+
if (end < viewMin || start > viewMax) continue;
|
|
492
608
|
const x1 = toX(start);
|
|
493
609
|
const x2 = toX(end);
|
|
494
610
|
const rx = (x2 - x1) / 2;
|
|
@@ -534,7 +650,7 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
|
|
|
534
650
|
strokeLinecap: "round",
|
|
535
651
|
pointerEvents: "stroke",
|
|
536
652
|
style: { cursor: hitCursor },
|
|
537
|
-
|
|
653
|
+
onClick: (e) => {
|
|
538
654
|
if (tool === "eraser" || tool === "none") {
|
|
539
655
|
e.stopPropagation();
|
|
540
656
|
onArcClick(g, e);
|
|
@@ -579,7 +695,18 @@ const GroupArcsLayer = React.memo(function GroupArcsLayer2({
|
|
|
579
695
|
] }, g)
|
|
580
696
|
);
|
|
581
697
|
}
|
|
582
|
-
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
|
+
] });
|
|
583
710
|
});
|
|
584
711
|
function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
|
|
585
712
|
return /* @__PURE__ */ jsxRuntime.jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
|
|
@@ -609,7 +736,7 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
|
|
|
609
736
|
vectorEffect: "non-scaling-stroke",
|
|
610
737
|
pointerEvents: "stroke",
|
|
611
738
|
style: { cursor: tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default" },
|
|
612
|
-
|
|
739
|
+
onClick: (e) => {
|
|
613
740
|
if (tool === "eraser" || tool === "none") {
|
|
614
741
|
e.stopPropagation();
|
|
615
742
|
onStrokeClick(s.id, e);
|
|
@@ -667,6 +794,12 @@ function NumberLine({ className }) {
|
|
|
667
794
|
showGroupTicks,
|
|
668
795
|
showMajorTicks,
|
|
669
796
|
showMinorTicks,
|
|
797
|
+
viewMin,
|
|
798
|
+
viewMax,
|
|
799
|
+
enableZoom,
|
|
800
|
+
resetView,
|
|
801
|
+
zoomAt,
|
|
802
|
+
panByFraction,
|
|
670
803
|
tool,
|
|
671
804
|
strokes,
|
|
672
805
|
liveStroke,
|
|
@@ -682,6 +815,137 @@ function NumberLine({ className }) {
|
|
|
682
815
|
const h = containerHeight;
|
|
683
816
|
const axisY = Math.max(MIN_ARC_HEIGHT + ARC_TOP_PADDING, h - AXIS_BOTTOM_OFFSET);
|
|
684
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
|
+
);
|
|
685
949
|
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
686
950
|
"svg",
|
|
687
951
|
{
|
|
@@ -690,22 +954,38 @@ function NumberLine({ className }) {
|
|
|
690
954
|
className: className ? `nl-svg ${className}` : "nl-svg",
|
|
691
955
|
viewBox: `0 0 ${w} ${h}`,
|
|
692
956
|
preserveAspectRatio: "none",
|
|
693
|
-
style: {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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),
|
|
697
974
|
children: [
|
|
698
975
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
699
976
|
AxisLayer,
|
|
700
977
|
{
|
|
701
978
|
min,
|
|
702
979
|
max,
|
|
980
|
+
viewMin,
|
|
981
|
+
viewMax,
|
|
703
982
|
axisY,
|
|
704
983
|
padLeft: PAD_LEFT,
|
|
705
984
|
padRight: PAD_RIGHT,
|
|
706
985
|
viewWidth: w,
|
|
707
986
|
containerWidth: w,
|
|
708
987
|
groupSize,
|
|
988
|
+
groupCount,
|
|
709
989
|
tickStep,
|
|
710
990
|
showGroupTicks,
|
|
711
991
|
showMajorTicks,
|
|
@@ -717,6 +997,8 @@ function NumberLine({ className }) {
|
|
|
717
997
|
{
|
|
718
998
|
min,
|
|
719
999
|
max,
|
|
1000
|
+
viewMin,
|
|
1001
|
+
viewMax,
|
|
720
1002
|
groupSize,
|
|
721
1003
|
groupCount,
|
|
722
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,13 +534,16 @@ 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
|
|
429
|
-
const
|
|
430
|
-
const visible = isGroupBoundary
|
|
537
|
+
const maxGroupBoundary = min + groupCount * groupSize;
|
|
538
|
+
const isGroupBoundary = groupSize > 0 && offset % groupSize === 0 && v <= maxGroupBoundary;
|
|
539
|
+
const visible = isGroupBoundary && showGroupTicks || isMajor && showMajorTicks || !isGroupBoundary && !isMajor && showMinorTicks;
|
|
431
540
|
if (!visible) return null;
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
const
|
|
541
|
+
const renderAsGroup = isGroupBoundary && showGroupTicks;
|
|
542
|
+
const renderAsMajor = !renderAsGroup && isMajor && showMajorTicks;
|
|
543
|
+
const showLabel = (renderAsGroup || renderAsMajor) && offset % labelStep === 0;
|
|
544
|
+
const tickH = renderAsGroup ? 7 : renderAsMajor ? 10 : 4;
|
|
545
|
+
const strokeW = renderAsGroup ? 1.5 : renderAsMajor ? 1 : 0.5;
|
|
546
|
+
const color = renderAsGroup || renderAsMajor ? "#374151" : "#9ca3af";
|
|
435
547
|
return /* @__PURE__ */ jsxs("g", { children: [
|
|
436
548
|
/* @__PURE__ */ jsx(
|
|
437
549
|
"line",
|
|
@@ -464,6 +576,8 @@ const AxisLayer = React.memo(function AxisLayer2({
|
|
|
464
576
|
const GroupArcsLayer = memo(function GroupArcsLayer2({
|
|
465
577
|
min,
|
|
466
578
|
max,
|
|
579
|
+
viewMin,
|
|
580
|
+
viewMax,
|
|
467
581
|
groupSize,
|
|
468
582
|
groupCount,
|
|
469
583
|
axisY,
|
|
@@ -478,15 +592,17 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
|
|
|
478
592
|
arcColors = ARC_COLORS
|
|
479
593
|
}) {
|
|
480
594
|
if (groupSize <= 0 || groupCount <= 0) return null;
|
|
481
|
-
const range =
|
|
595
|
+
const range = viewMax - viewMin;
|
|
482
596
|
const usable = viewWidth - padLeft - padRight;
|
|
483
|
-
const toX = (v) => padLeft + (v -
|
|
597
|
+
const toX = (v) => padLeft + (v - viewMin) / range * usable;
|
|
598
|
+
const clipId = React.useId().replace(/:/g, "_") + "-arcs-clip";
|
|
484
599
|
const arcs = [];
|
|
485
600
|
for (let g = 0; g < groupCount; g++) {
|
|
486
601
|
if (deletedArcIndices.has(g)) continue;
|
|
487
602
|
const start = min + g * groupSize;
|
|
488
603
|
const end = start + groupSize;
|
|
489
604
|
if (end > max) break;
|
|
605
|
+
if (end < viewMin || start > viewMax) continue;
|
|
490
606
|
const x1 = toX(start);
|
|
491
607
|
const x2 = toX(end);
|
|
492
608
|
const rx = (x2 - x1) / 2;
|
|
@@ -532,7 +648,7 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
|
|
|
532
648
|
strokeLinecap: "round",
|
|
533
649
|
pointerEvents: "stroke",
|
|
534
650
|
style: { cursor: hitCursor },
|
|
535
|
-
|
|
651
|
+
onClick: (e) => {
|
|
536
652
|
if (tool === "eraser" || tool === "none") {
|
|
537
653
|
e.stopPropagation();
|
|
538
654
|
onArcClick(g, e);
|
|
@@ -577,7 +693,18 @@ const GroupArcsLayer = memo(function GroupArcsLayer2({
|
|
|
577
693
|
] }, g)
|
|
578
694
|
);
|
|
579
695
|
}
|
|
580
|
-
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
|
+
] });
|
|
581
708
|
});
|
|
582
709
|
function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick }) {
|
|
583
710
|
return /* @__PURE__ */ jsxs("g", { className: "nl-drawing-layer", transform: `scale(${width} ${height})`, children: [
|
|
@@ -607,7 +734,7 @@ function DrawingLayer({ strokes, liveStroke, tool, width, height, onStrokeClick
|
|
|
607
734
|
vectorEffect: "non-scaling-stroke",
|
|
608
735
|
pointerEvents: "stroke",
|
|
609
736
|
style: { cursor: tool === "eraser" ? "cell" : tool === "none" ? "pointer" : "default" },
|
|
610
|
-
|
|
737
|
+
onClick: (e) => {
|
|
611
738
|
if (tool === "eraser" || tool === "none") {
|
|
612
739
|
e.stopPropagation();
|
|
613
740
|
onStrokeClick(s.id, e);
|
|
@@ -665,6 +792,12 @@ function NumberLine({ className }) {
|
|
|
665
792
|
showGroupTicks,
|
|
666
793
|
showMajorTicks,
|
|
667
794
|
showMinorTicks,
|
|
795
|
+
viewMin,
|
|
796
|
+
viewMax,
|
|
797
|
+
enableZoom,
|
|
798
|
+
resetView,
|
|
799
|
+
zoomAt,
|
|
800
|
+
panByFraction,
|
|
668
801
|
tool,
|
|
669
802
|
strokes,
|
|
670
803
|
liveStroke,
|
|
@@ -680,6 +813,137 @@ function NumberLine({ className }) {
|
|
|
680
813
|
const h = containerHeight;
|
|
681
814
|
const axisY = Math.max(MIN_ARC_HEIGHT + ARC_TOP_PADDING, h - AXIS_BOTTOM_OFFSET);
|
|
682
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
|
+
);
|
|
683
947
|
return /* @__PURE__ */ jsxs(
|
|
684
948
|
"svg",
|
|
685
949
|
{
|
|
@@ -688,22 +952,38 @@ function NumberLine({ className }) {
|
|
|
688
952
|
className: className ? `nl-svg ${className}` : "nl-svg",
|
|
689
953
|
viewBox: `0 0 ${w} ${h}`,
|
|
690
954
|
preserveAspectRatio: "none",
|
|
691
|
-
style: {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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),
|
|
695
972
|
children: [
|
|
696
973
|
/* @__PURE__ */ jsx(
|
|
697
974
|
AxisLayer,
|
|
698
975
|
{
|
|
699
976
|
min,
|
|
700
977
|
max,
|
|
978
|
+
viewMin,
|
|
979
|
+
viewMax,
|
|
701
980
|
axisY,
|
|
702
981
|
padLeft: PAD_LEFT,
|
|
703
982
|
padRight: PAD_RIGHT,
|
|
704
983
|
viewWidth: w,
|
|
705
984
|
containerWidth: w,
|
|
706
985
|
groupSize,
|
|
986
|
+
groupCount,
|
|
707
987
|
tickStep,
|
|
708
988
|
showGroupTicks,
|
|
709
989
|
showMajorTicks,
|
|
@@ -715,6 +995,8 @@ function NumberLine({ className }) {
|
|
|
715
995
|
{
|
|
716
996
|
min,
|
|
717
997
|
max,
|
|
998
|
+
viewMin,
|
|
999
|
+
viewMax,
|
|
718
1000
|
groupSize,
|
|
719
1001
|
groupCount,
|
|
720
1002
|
axisY,
|