@dcg-overseas/number-line 1.0.0-beta.5 → 1.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,355 +1,316 @@
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
- | `coordinateScale` | `number` | `1` | SVG 内部逻辑坐标倍率。仅扩大 `viewBox` 与布局计算,不改变 SVG 的 CSS 宽高;整体缩放页面可传入反向倍率以保持设计稿的标签密度,避免 CSS `transform` 缩放。 |
61
- | `arcColors` | `string[]` | 5 色循环 | 弧线颜色数组,按 `groupIndex % length` 轮换。默认值见 [`utils/geometry.ts` ARC_COLORS](src/utils/geometry.ts) |
62
- | `showGroupTicks` | `boolean` | `true` | 是否显示分组边界刻度(最长,10px) |
63
- | `showMajorTicks` | `boolean` | `true` | 是否显示主刻度(中等,7px) |
64
- | `showMinorTicks` | `boolean` | `false` | 是否显示次刻度(最短,4px)。默认隐藏 |
65
- | `enableZoom` | `boolean` | `false` | 启用滚轮/双指缩放 + 拖拽平移。默认禁用 |
66
- | `minZoom` | `number` | `1` | 最小缩放级别(1 = 完整 `[min, max]` 范围) |
67
- | `maxZoom` | `number` | `50` | 最大缩放级别(50 = 放大 50 倍) |
68
- | `viewMin` | `number?` | — | 受控模式:当前可视范围起点。需与 `viewMax` 和 `onViewChange` 配合 |
69
- | `viewMax` | `number?` | — | 受控模式:当前可视范围终点 |
70
- | `onViewChange` | `(viewMin, viewMax) => void` | — | 非受控模式:viewport 变化时触发(滚轮/拖拽/双击重置) |
71
- | `children` | `ReactNode` | — | 子节点(必须包含 `<NumberLine />` 或自定义渲染器) |
72
-
73
- #### 弧线生成规则
74
-
75
- ```
76
- 弧线 g 的范围:[min + g*groupSize, min + (g+1)*groupSize]
77
- g ∈ [0, groupCount)
78
- 若 (min + (g+1)*groupSize) > max,弧线提前停止
79
- ```
80
-
81
- #### 三档刻度优先级
82
-
83
- 一个位置可能同时是多档刻度(例如 `tickStep=5, groupSize=10` 时,10 既是主刻度也是分组边界)。视觉与可见性按 **分组边界 > 主刻度 > 次刻度** 归类,仅看其最高档对应的 `show*Ticks`。
84
-
85
- ```tsx
86
- // 默认:只显示主刻度 + 分组边界
87
- <NumberLineProvider min={0} max={100} groupSize={10} groupCount={10} tickStep={5} />
88
-
89
- // 显示全部三档刻度
90
- <NumberLineProvider ... showMinorTicks />
91
-
92
- // 极简模式:仅分组边界
93
- <NumberLineProvider ... showMajorTicks={false} />
94
- ```
95
-
96
- #### 缩放与平移(`enableZoom`)
97
-
98
- 启用后支持:
99
-
100
- - **滚轮缩放**:以鼠标位置为中心放大/缩小
101
- - **拖拽平移**:`tool='none'` 时,左键拖拽 >5px 触发平移(避免与点击选中冲突)
102
- - **双指缩放**(触摸屏):pinch 手势
103
- - **双击重置**:双击空白处恢复到完整 `[min, max]` 范围
104
-
105
- ```tsx
106
- // 非受控模式(内部维护 viewport)
107
- <NumberLineProvider
108
- min={0}
109
- max={100}
110
- enableZoom
111
- minZoom={1}
112
- maxZoom={50}
113
- onViewChange={(viewMin, viewMax) => console.log('viewport:', viewMin, viewMax)}
114
- ...
115
- />
116
-
117
- // 受控模式(父组件控制 viewport)
118
- function App() {
119
- const [view, setView] = useState([0, 100])
120
- return (
121
- <NumberLineProvider
122
- min={0}
123
- max={100}
124
- enableZoom
125
- viewMin={view[0]}
126
- viewMax={view[1]}
127
- onViewChange={(a, b) => setView([a, b])}
128
- ...
129
- />
130
- )
131
- }
132
- ```
133
-
134
- **注意事项**:
135
-
136
- - 笔迹坐标是归一化 `[0,1]`(容器像素比例),**不会跟随数值缩放移动**。适合「批注」语义,不适合「标记 v=50 处」语义。
137
- - viewport 始终夹紧在 `[min, max]` 内,不会平移到数据范围外。
138
- - 缩放时刻度密度自动调整(`labelStep` 按可视范围重算)。
139
- - **桌面端**:滚轮和 pinch 需要鼠标悬停或 SVG 获得焦点(视觉上有蓝色边框提示)。
140
- - **移动端**:双指 pinch 直接生效(触摸即激活)。**SVG 外的双指仍会触发浏览器整页缩放**——如果不需要整页缩放,可在宿主 HTML 添加:
141
- ```html
142
- <meta
143
- name="viewport"
144
- content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
145
- />
146
- ```
147
- 但出于无障碍考虑,禁用整页缩放需谨慎评估。
148
-
149
- ---
150
-
151
- ### `<NumberLine>`
152
-
153
- 数轴 SVG 视图。必须放在 `<NumberLineProvider>` 内,自动从 context 读取所有配置和事件处理。
154
-
155
- | Prop | 类型 | 默认值 | 说明 |
156
- | ----------- | --------- | ------ | -------------------------------------------------- |
157
- | `className` | `string?` | — | 追加到 SVG 根节点的 className(基础类名 `nl-svg`) |
158
-
159
- **渲染层级(从下到上):**
160
-
161
- 1. `AxisLayer` — 数轴线 + 刻度 + 标签
162
- 2. `GroupArcsLayer` — 分组弧线 + 弧顶箭头标签 + 选中高亮
163
- 3. `DrawingLayer` — 用户笔迹(normalized `[0,1]` 坐标,scale 还原)
164
-
165
- **布局参数(内部常量,无需配置):**
166
-
167
- | 名称 | 值 | 含义 |
168
- | -------------------- | --- | ---------------------------------------- |
169
- | `PAD_LEFT` | 30 | 数轴左 padding |
170
- | `PAD_RIGHT` | 40 | 数轴右 padding |
171
- | `AXIS_BOTTOM_OFFSET` | 50 | 数轴线距容器底部像素(给刻度数字留空间) |
172
- | `ARC_TOP_PADDING` | 16 | 弧线顶部距容器顶部像素 |
173
- | `MIN_ARC_HEIGHT` | 24 | 弧线最小高度(容器极小时兜底) |
174
-
175
- ---
176
-
177
- ### `useNumberLineContext()`
178
-
179
- 读取 / 操作数轴状态的 hook。**必须在 `<NumberLineProvider>` 子树内调用**,否则抛错。
180
-
181
- 返回 `NumberLineContextValue`:
182
-
183
- #### 布局
184
-
185
- | 字段 | 类型 | 说明 |
186
- | ----------------- | -------------------------- | ----------------------------------- |
187
- | `svgRef` | `RefObject<SVGSVGElement>` | SVG 元素 ref |
188
- | `containerWidth` | `number` | 容器实际像素宽 |
189
- | `containerHeight` | `number` | 容器实际像素高 |
190
- | `svgCursor` | `string` | 当前 SVG cursor(自动随 tool 切换) |
191
-
192
- #### 配置(透传自 Provider props)
193
-
194
- | 字段 | 类型 |
195
- | -------------------------------------------------- | ----------------------- |
196
- | `min` `max` `groupSize` `groupCount` `tickStep` | `number` |
197
- | `arcColors` | `string[] \| undefined` |
198
- | `showGroupTicks` `showMajorTicks` `showMinorTicks` | `boolean` |
199
-
200
- #### 绘图状态
201
-
202
- | 字段 | 类型 | 说明 |
203
- | -------------------- | ----------------------------- | ------------------------------- |
204
- | `tool` | `'none' \| 'pen' \| 'eraser'` | 当前工具 |
205
- | `strokes` | `Stroke[]` | 已落笔的所有笔迹 |
206
- | `liveStroke` | `Stroke \| null` | 正在绘制中的笔迹(半透明预览) |
207
- | `selectedArcIndices` | `Set<number>` | 当前选中的弧线 group index |
208
- | `deletedArcIndices` | `Set<number>` | 已被擦除/删除的弧线 group index |
209
-
210
- #### 事件回调(已绑定到 `<NumberLine>`,自定义渲染时使用)
211
-
212
- | 字段 | 类型 |
213
- | --------------------------------------------- | ----------------------------------------------- |
214
- | `onPointerDown` `onPointerMove` `onPointerUp` | `(e: PointerEvent) => void` |
215
- | `onStrokeClick` | `(id: string, e: PointerEvent) => void` |
216
- | `onArcClick` | `(groupIndex: number, e: PointerEvent) => void` |
217
-
218
- #### 工具栏 API
219
-
220
- | 字段 | 类型 | 说明 |
221
- | ---------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
222
- | `canDelete` | `boolean` | 是否有可删除的选中项(笔迹或弧线) |
223
- | `canUndo` | `boolean` | 是否有可撤销的操作 |
224
- | `onTogglePen` | `() => void` | 切换画笔工具 |
225
- | `onToggleEraser` | `() => void` | 切换橡皮擦 |
226
- | `onDelete` | `() => void` | 删除当前选中 |
227
- | `clearAll` | `() => void` | 可撤销地清空全部笔迹、弧线、选择和分组边界粗刻度;连续清空不会增加空历史项 |
228
- | `onReset` | `() => void` | `clearAll` 的兼容别名,已弃用;新代码请使用 `clearAll` |
229
- | `onUndo` | `() => void` | 撤销上一步操作 |
230
- | `showAllArcs` | `() => void` | 重新显示全部跳数弧线**及分组边界粗刻度**(清空删除记录)。即使参数未变、或刚 `clearAll` 隐藏后,也能强制把跳数显示出来,且不影响笔迹 |
231
-
232
- #### 缩放 API(仅 `enableZoom=true` 时有效)
233
-
234
- | 字段 | 类型 | 说明 |
235
- | ------------------- | -------------------------------------------- | ------------------------------------------------------------ |
236
- | `viewMin` `viewMax` | `number` | 当前可视范围(等于 `[min, max]` 当 zoom 禁用) |
237
- | `resetView` | `() => void` | 重置到完整数据范围 |
238
- | `zoomAt` | `(factor: number, fraction: number) => void` | 以 viewport 的 `fraction` 位置(0..1)为中心缩放 `factor` 倍 |
239
- | `panByFraction` | `(df: number) => void` | 平移 viewport 宽度的 `df` 倍(正数右移,负数左移) |
240
-
241
- ---
242
-
243
- ## 自定义工具栏示例
244
-
245
- `<NumberLine>` 不附带工具栏。把按钮和数轴一起放在 `<NumberLineProvider>` 下,通过 `useNumberLineContext` 接入:
246
-
247
- ```tsx
248
- import {
249
- NumberLineProvider,
250
- NumberLine,
251
- useNumberLineContext,
252
- } from "@dcg-overseas/number-line";
253
-
254
- function Toolbar() {
255
- const {
256
- tool,
257
- canDelete,
258
- canUndo,
259
- onTogglePen,
260
- onToggleEraser,
261
- onDelete,
262
- clearAll,
263
- onUndo,
264
- } = useNumberLineContext();
265
-
266
- return (
267
- <div className="flex gap-2">
268
- <button data-active={tool === "pen"} onClick={onTogglePen}>
269
- ✏️ 画笔
270
- </button>
271
- <button data-active={tool === "eraser"} onClick={onToggleEraser}>
272
- 🩹 擦除
273
- </button>
274
- <button disabled={!canDelete} onClick={onDelete}>
275
- 🗑 删除
276
- </button>
277
- <button onClick={clearAll}>♻️ 全部清除</button>
278
- <button disabled={!canUndo} onClick={onUndo}>
279
- ↶ 撤销
280
- </button>
281
- </div>
282
- );
283
- }
284
-
285
- function App() {
286
- return (
287
- <NumberLineProvider
288
- min={0}
289
- max={80}
290
- groupSize={8}
291
- groupCount={10}
292
- tickStep={1}
293
- >
294
- <Toolbar />
295
- <div style={{ height: 220 }}>
296
- <NumberLine />
297
- </div>
298
- </NumberLineProvider>
299
- );
300
- }
301
- ```
302
-
303
- ---
304
-
305
- ## 键盘快捷键
306
-
307
- 快捷键绑定在 SVG 元素上(`tabIndex={0}`,需先聚焦 SVG),多实例互不干扰,也不会拦截外部输入框。
308
-
309
- | 按键 | 行为 |
310
- | ---------------------- | -------------------------------- |
311
- | `Esc` | 切回 `'none'` 模式,清空弧线选中 |
312
- | `Delete` / `Backspace` | 删除当前选中的笔迹和弧线 |
313
- | `Ctrl+Z` / `Cmd+Z` | 撤销上一步 |
314
-
315
- ---
316
-
317
- ## 类型导出
318
-
319
- ```ts
320
- import type {
321
- NumberLineProps,
322
- NumberLineContextValue,
323
- } from "@dcg-overseas/number-line";
324
- ```
325
-
326
- 完整类型见 [src/types.ts](src/types.ts) 与 [src/context/NumberLineContext.ts](src/context/NumberLineContext.ts)。
327
-
328
- ---
329
-
330
- ## 设计要点
331
-
332
- ### 自适应坐标系
333
-
334
- `viewBox` 动态跟随容器实际像素(`viewBox={`0 0 ${w} ${h}`}` + `preserveAspectRatio="none"`),1 SVG 单位 = 1 CSS 像素。这样:
335
-
336
- - 文字和线条始终是设计尺寸(fontSize=11 永远是 11px),不会因为容器纵横比异常被缩到看不见
337
- - 数轴自动贴近容器底部,弧线撑满上方空间,无空白浪费
338
-
339
- ### 笔迹归一化(resize 稳定)
340
-
341
- 笔迹存储为 `[0, 1]` 归一化坐标(`M 0.234 0.456 L ...`)。渲染时用 `<g transform={`scale(${w} ${h})`}>` 还原到容器像素,并配合 `vectorEffect="non-scaling-stroke"` 锁定线宽。
342
-
343
- 效果:容器从 `400×200` 变成 `600×300`,所有笔迹的相对位置仍然贴合数轴,stroke 宽度保持 2.5px。
344
-
345
- ### 操作级撤销
346
-
347
- 撤销栈记录每次操作发生前的完整状态快照,包括笔迹、已删除弧线、已选弧线和粗分组刻度状态。
348
- 画笔、橡皮擦、删除选中及 `clearAll` 都复用同一恢复流程,因此一次清空或批量删除可以完整还原。
349
- `min`、`max`、`groupSize`、`groupCount` 或 `tickStep` 改变时会执行不可撤销初始化并清空历史,防止把上一道题的数据恢复到新坐标范围。
350
-
351
- ---
352
-
353
- ## License
354
-
355
- MIT
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
+ | `clearAll` | `() => void` | 可撤销地清空全部笔迹、弧线、选择和分组边界粗刻度;连续清空不会增加空历史项 |
216
+ | `onReset` | `() => void` | `clearAll` 的兼容别名,已弃用;新代码请使用 `clearAll` |
217
+ | `onUndo` | `() => void` | 撤销上一步操作 |
218
+ | `showAllArcs` | `() => void` | 重新显示全部跳数弧线**及分组边界粗刻度**(清空删除记录)。即使参数未变、或刚 `clearAll` 隐藏后,也能强制把跳数显示出来,且不影响笔迹 |
219
+
220
+ #### 缩放 API(仅 `enableZoom=true` 时有效)
221
+ | 字段 | 类型 | 说明 |
222
+ |------|------|------|
223
+ | `viewMin` `viewMax` | `number` | 当前可视范围(等于 `[min, max]` 当 zoom 禁用) |
224
+ | `resetView` | `() => void` | 重置到完整数据范围 |
225
+ | `zoomAt` | `(factor: number, fraction: number) => void` | 以 viewport 的 `fraction` 位置(0..1)为中心缩放 `factor` 倍 |
226
+ | `panByFraction` | `(df: number) => void` | 平移 viewport 宽度的 `df` 倍(正数右移,负数左移) |
227
+
228
+ ---
229
+
230
+ ## 自定义工具栏示例
231
+
232
+ `<NumberLine>` 不附带工具栏。把按钮和数轴一起放在 `<NumberLineProvider>` 下,通过 `useNumberLineContext` 接入:
233
+
234
+ ```tsx
235
+ import { NumberLineProvider, NumberLine, useNumberLineContext } from '@dcg-overseas/number-line'
236
+
237
+ function Toolbar() {
238
+ const { tool, canDelete, canUndo, onTogglePen, onToggleEraser, onDelete, clearAll, onUndo } =
239
+ useNumberLineContext()
240
+
241
+ return (
242
+ <div className="flex gap-2">
243
+ <button data-active={tool === 'pen'} onClick={onTogglePen}>✏️ 画笔</button>
244
+ <button data-active={tool === 'eraser'} onClick={onToggleEraser}>🩹 擦除</button>
245
+ <button disabled={!canDelete} onClick={onDelete}>🗑 删除</button>
246
+ <button onClick={clearAll}>♻️ 全部清除</button>
247
+ <button disabled={!canUndo} onClick={onUndo}>↶ 撤销</button>
248
+ </div>
249
+ )
250
+ }
251
+
252
+ function App() {
253
+ return (
254
+ <NumberLineProvider min={0} max={80} groupSize={8} groupCount={10} tickStep={1}>
255
+ <Toolbar />
256
+ <div style={{ height: 220 }}>
257
+ <NumberLine />
258
+ </div>
259
+ </NumberLineProvider>
260
+ )
261
+ }
262
+ ```
263
+
264
+ ---
265
+
266
+ ## 键盘快捷键
267
+
268
+ 快捷键绑定在 SVG 元素上(`tabIndex={0}`,需先聚焦 SVG),多实例互不干扰,也不会拦截外部输入框。
269
+
270
+ | 按键 | 行为 |
271
+ |------|------|
272
+ | `Esc` | 切回 `'none'` 模式,清空弧线选中 |
273
+ | `Delete` / `Backspace` | 删除当前选中的笔迹和弧线 |
274
+ | `Ctrl+Z` / `Cmd+Z` | 撤销上一步 |
275
+
276
+ ---
277
+
278
+ ## 类型导出
279
+
280
+ ```ts
281
+ import type {
282
+ NumberLineProps,
283
+ NumberLineContextValue,
284
+ } from '@dcg-overseas/number-line'
285
+ ```
286
+
287
+ 完整类型见 [src/types.ts](src/types.ts) 与 [src/context/NumberLineContext.ts](src/context/NumberLineContext.ts)。
288
+
289
+ ---
290
+
291
+ ## 设计要点
292
+
293
+ ### 自适应坐标系
294
+
295
+ `viewBox` 动态跟随容器实际像素(`viewBox={`0 0 ${w} ${h}`}` + `preserveAspectRatio="none"`),1 SVG 单位 = 1 CSS 像素。这样:
296
+
297
+ - 文字和线条始终是设计尺寸(fontSize=11 永远是 11px),不会因为容器纵横比异常被缩到看不见
298
+ - 数轴自动贴近容器底部,弧线撑满上方空间,无空白浪费
299
+
300
+ ### 笔迹归一化(resize 稳定)
301
+
302
+ 笔迹存储为 `[0, 1]` 归一化坐标(`M 0.234 0.456 L ...`)。渲染时用 `<g transform={`scale(${w} ${h})`}>` 还原到容器像素,并配合 `vectorEffect="non-scaling-stroke"` 锁定线宽。
303
+
304
+ 效果:容器从 `400×200` 变成 `600×300`,所有笔迹的相对位置仍然贴合数轴,stroke 宽度保持 2.5px。
305
+
306
+ ### 操作级撤销
307
+
308
+ 撤销栈记录每次操作发生前的完整状态快照,包括笔迹、已删除弧线、已选弧线和粗分组刻度状态。
309
+ 画笔、橡皮擦、删除选中及 `clearAll` 都复用同一恢复流程,因此一次清空或批量删除可以完整还原。
310
+ `min`、`max`、`groupSize`、`groupCount` 或 `tickStep` 改变时会执行不可撤销初始化并清空历史,防止把上一道题的数据恢复到新坐标范围。
311
+
312
+ ---
313
+
314
+ ## License
315
+
316
+ MIT
package/dist/index.cjs CHANGED
@@ -258,6 +258,7 @@ function NumberLineProvider({
258
258
  tickStep,
259
259
  strokeWidth: configuredStrokeWidth,
260
260
  coordinateScale: configuredCoordinateScale = 1,
261
+ tickHeightScale: configuredTickHeightScale = 1,
261
262
  arcColors,
262
263
  showGroupTicks = true,
263
264
  showMajorTicks = true,
@@ -275,10 +276,15 @@ function NumberLineProvider({
275
276
  const { min: domainMin, max: domainMax } = normalizeDomain(min, max);
276
277
  const strokeWidth = typeof configuredStrokeWidth === "number" && Number.isFinite(configuredStrokeWidth) && configuredStrokeWidth > 0 ? configuredStrokeWidth : DEFAULT_DRAWING_STROKE_WIDTH;
277
278
  const coordinateScale = typeof configuredCoordinateScale === "number" && Number.isFinite(configuredCoordinateScale) && configuredCoordinateScale > 0 ? configuredCoordinateScale : 1;
279
+ const tickHeightScale = typeof configuredTickHeightScale === "number" && Number.isFinite(configuredTickHeightScale) && configuredTickHeightScale > 0 ? configuredTickHeightScale : 1;
278
280
  const [selectedArcIndices, setSelectedArcIndices] = React.useState(/* @__PURE__ */ new Set());
279
281
  const [deletedArcIndices, setDeletedArcIndices] = React.useState(/* @__PURE__ */ new Set());
280
282
  const [groupTicksHidden, setGroupTicksHidden] = React.useState(false);
281
- const { ref: svgRef, width: measuredWidth, height: measuredHeight } = useContainerSize();
283
+ const {
284
+ ref: svgRef,
285
+ width: measuredWidth,
286
+ height: measuredHeight
287
+ } = useContainerSize();
282
288
  const containerWidth = measuredWidth * coordinateScale;
283
289
  const containerHeight = measuredHeight * coordinateScale;
284
290
  const history = useHistory();
@@ -297,9 +303,7 @@ function NumberLineProvider({
297
303
  const eraseGesture = React.useRef(null);
298
304
  const createHistorySnapshot = React.useCallback(
299
305
  (strokesOverride) => ({
300
- strokes: (strokesOverride ?? drawing.strokes).map((stroke) => ({
301
- ...stroke
302
- })),
306
+ strokes: (strokesOverride ?? drawing.strokes).map((stroke) => ({ ...stroke })),
303
307
  deletedArcIndices: [...deletedArcIndices],
304
308
  selectedArcIndices: [...selectedArcIndices],
305
309
  groupTicksHidden
@@ -315,21 +319,26 @@ function NumberLineProvider({
315
319
  setDeletedArcIndices(/* @__PURE__ */ new Set());
316
320
  setSelectedArcIndices(/* @__PURE__ */ new Set());
317
321
  setGroupTicksHidden(false);
318
- }, [domainMin, domainMax, groupSize, groupCount, tickStep, drawing.resetStrokes, history.clear]);
319
- const getLocalCoords = React.useCallback(
320
- (event) => {
321
- const svg = svgRef.current;
322
- if (!svg) return null;
323
- const rect = svg.getBoundingClientRect();
324
- const viewBox = svg.viewBox.baseVal;
325
- if (rect.width <= 0 || rect.height <= 0 || viewBox.width <= 0 || viewBox.height <= 0) return null;
326
- return [
327
- viewBox.x + (event.clientX - rect.left) / rect.width * viewBox.width,
328
- viewBox.y + (event.clientY - rect.top) / rect.height * viewBox.height
329
- ];
330
- },
331
- [svgRef]
332
- );
322
+ }, [
323
+ domainMin,
324
+ domainMax,
325
+ groupSize,
326
+ groupCount,
327
+ tickStep,
328
+ drawing.resetStrokes,
329
+ history.clear
330
+ ]);
331
+ const getLocalCoords = React.useCallback((event) => {
332
+ const svg = svgRef.current;
333
+ if (!svg) return null;
334
+ const rect = svg.getBoundingClientRect();
335
+ const viewBox = svg.viewBox.baseVal;
336
+ if (rect.width <= 0 || rect.height <= 0 || viewBox.width <= 0 || viewBox.height <= 0) return null;
337
+ return [
338
+ viewBox.x + (event.clientX - rect.left) / rect.width * viewBox.width,
339
+ viewBox.y + (event.clientY - rect.top) / rect.height * viewBox.height
340
+ ];
341
+ }, [svgRef]);
333
342
  const strokeIdsNear = React.useCallback(
334
343
  (x, y, radius) => {
335
344
  const ids = [];
@@ -386,7 +395,8 @@ function NumberLineProvider({
386
395
  } else {
387
396
  const nextIds = strokeIdsNear(coords2[0], coords2[1], radius);
388
397
  setEraseHoverIds((prev) => {
389
- if (nextIds.length === prev.size && nextIds.every((id) => prev.has(id))) return prev;
398
+ if (nextIds.length === prev.size && nextIds.every((id) => prev.has(id)))
399
+ return prev;
390
400
  return new Set(nextIds);
391
401
  });
392
402
  }
@@ -458,7 +468,9 @@ function NumberLineProvider({
458
468
  }, [drawing, history, selectedArcIndices, createHistorySnapshot]);
459
469
  const clearAll = React.useCallback(() => {
460
470
  const arcCount = Math.max(0, Math.ceil(Number(groupCount) || 0));
461
- const allArcsDeleted = deletedArcIndices.size >= arcCount && Array.from({ length: arcCount }, (_, index) => index).every((index) => deletedArcIndices.has(index));
471
+ const allArcsDeleted = deletedArcIndices.size >= arcCount && Array.from({ length: arcCount }, (_, index) => index).every(
472
+ (index) => deletedArcIndices.has(index)
473
+ );
462
474
  const alreadyClear = drawing.strokes.length === 0 && selectedArcIndices.size === 0 && allArcsDeleted && groupTicksHidden;
463
475
  if (alreadyClear) return;
464
476
  history.push(createHistorySnapshot());
@@ -469,7 +481,15 @@ function NumberLineProvider({
469
481
  setDeletedArcIndices(new Set(Array.from({ length: arcCount }, (_, i) => i)));
470
482
  setSelectedArcIndices(/* @__PURE__ */ new Set());
471
483
  setGroupTicksHidden(true);
472
- }, [drawing, history, groupCount, deletedArcIndices, selectedArcIndices, groupTicksHidden, createHistorySnapshot]);
484
+ }, [
485
+ drawing,
486
+ history,
487
+ groupCount,
488
+ deletedArcIndices,
489
+ selectedArcIndices,
490
+ groupTicksHidden,
491
+ createHistorySnapshot
492
+ ]);
473
493
  const onReset = clearAll;
474
494
  const showAllArcs = React.useCallback(() => {
475
495
  setDeletedArcIndices((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
@@ -486,33 +506,27 @@ function NumberLineProvider({
486
506
  eraseGesture.current = null;
487
507
  setEraseHoverIds(/* @__PURE__ */ new Set());
488
508
  }, [history, drawing]);
489
- const onTogglePen = React.useCallback(() => {
490
- drawing.toggleTool("pen");
491
- clearArcSelection();
492
- }, [drawing, clearArcSelection]);
493
- const onToggleEraser = React.useCallback(() => {
494
- drawing.toggleTool("eraser");
495
- clearArcSelection();
496
- }, [drawing, clearArcSelection]);
509
+ const onTogglePen = React.useCallback(
510
+ () => {
511
+ drawing.toggleTool("pen");
512
+ clearArcSelection();
513
+ },
514
+ [drawing, clearArcSelection]
515
+ );
516
+ const onToggleEraser = React.useCallback(
517
+ () => {
518
+ drawing.toggleTool("eraser");
519
+ clearArcSelection();
520
+ },
521
+ [drawing, clearArcSelection]
522
+ );
497
523
  const canDelete = drawing.strokes.some((s) => s.selected) || selectedArcIndices.size > 0;
498
524
  React.useEffect(() => {
499
525
  setSelectedArcIndices(/* @__PURE__ */ new Set());
500
526
  setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
501
527
  }, [drawing.tool]);
502
- const keyHandlers = React.useRef({
503
- toggleTool: drawing.toggleTool,
504
- clearArcSelection,
505
- canDelete,
506
- onDelete,
507
- onUndo
508
- });
509
- keyHandlers.current = {
510
- toggleTool: drawing.toggleTool,
511
- clearArcSelection,
512
- canDelete,
513
- onDelete,
514
- onUndo
515
- };
528
+ const keyHandlers = React.useRef({ toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo });
529
+ keyHandlers.current = { toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo };
516
530
  React.useEffect(() => {
517
531
  const svg = svgRef.current;
518
532
  if (!svg) return;
@@ -548,6 +562,7 @@ function NumberLineProvider({
548
562
  tickStep,
549
563
  strokeWidth,
550
564
  arcColors,
565
+ tickHeightScale,
551
566
  showGroupTicks: showGroupTicks && !groupTicksHidden,
552
567
  showMajorTicks,
553
568
  showMinorTicks,
@@ -670,6 +685,7 @@ const AxisLayer = React.memo(function AxisLayer2({
670
685
  groupSize,
671
686
  groupCount,
672
687
  tickStep,
688
+ tickHeightScale,
673
689
  showGroupTicks,
674
690
  showMajorTicks,
675
691
  showMinorTicks,
@@ -735,7 +751,7 @@ const AxisLayer = React.memo(function AxisLayer2({
735
751
  v
736
752
  }) : void 0;
737
753
  const shouldShowLabel = currentShowLabelStep ?? (showLabel || showLabelStep);
738
- const tickH = renderAsGroup ? 7 : renderAsMajor ? 10 : renderAsHalf ? 8 : 6;
754
+ const tickH = (renderAsGroup ? 7 : renderAsMajor ? 10 : renderAsHalf ? 8 : 6) * tickHeightScale;
739
755
  const strokeW = renderAsGroup ? 4 : renderAsMajor ? 2 : renderAsHalf ? 2 : 1;
740
756
  const arcIndex = renderAsGroup ? offset > 0 ? offset / groupSize - 1 : offset === 0 ? 0 : -1 : -1;
741
757
  const arcColored = arcIndex >= 0 && arcIndex < groupCount && !(deletedArcIndices == null ? void 0 : deletedArcIndices.has(arcIndex));
@@ -1101,6 +1117,7 @@ function NumberLine({ className }) {
1101
1117
  groupCount,
1102
1118
  tickStep,
1103
1119
  strokeWidth,
1120
+ tickHeightScale,
1104
1121
  arcColors,
1105
1122
  showGroupTicks,
1106
1123
  showMajorTicks,
@@ -1315,6 +1332,7 @@ function NumberLine({ className }) {
1315
1332
  groupSize,
1316
1333
  groupCount,
1317
1334
  tickStep,
1335
+ tickHeightScale,
1318
1336
  showGroupTicks,
1319
1337
  showMajorTicks,
1320
1338
  showMinorTicks,
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export declare interface NumberLineContextValue {
17
17
  tickStep: number;
18
18
  /** 用户自由绘制笔迹的固定视觉线宽,单位为 CSS 像素。 */
19
19
  strokeWidth: number;
20
+ /** 仅用于补偿 SVG 整体缩放后的刻度线逻辑高度;默认值为 1。 */
21
+ tickHeightScale: number;
20
22
  arcColors?: string[];
21
23
  showGroupTicks: boolean;
22
24
  showMajorTicks: boolean;
@@ -67,11 +69,18 @@ export declare interface NumberLineProps {
67
69
  /**
68
70
  * SVG 逻辑坐标相对于实际 CSS 像素的倍率;默认 1。
69
71
  *
70
- * 大于 1 时只扩大内部 viewBox 和布局计算使用的坐标,不改变 SVG 元素本身的
71
- * CSS 宽高。宿主可在整体 rem 缩放的场景传入反向倍率,使标签密度、弧线位置和
72
- * 指针坐标仍以设计稿尺寸计算,同时避免 CSS transform 产生独立合成层。
72
+ * 大于 1 时扩大内部 viewBox 和布局计算使用的坐标,不改变 SVG 元素本身的 CSS 宽高。
73
+ * 宿主可在整体 rem 缩放的场景传入反向倍率,使标签密度、弧线位置和指针坐标仍以
74
+ * 设计稿尺寸计算,同时避免 CSS transform 产生独立合成层。
73
75
  */
74
76
  coordinateScale?: number;
77
+ /**
78
+ * 数轴刻度线高度的逻辑补偿倍率;默认 1。
79
+ *
80
+ * 当 coordinateScale 缩小竖屏的 SVG 视觉坐标时,宿主可传入相同的反向倍率,使刻度线
81
+ * 映射到实际画布后的显示高度仍与横屏一致;不会影响主轴、数字标签、弧线或绘制笔迹。
82
+ */
83
+ tickHeightScale?: number;
75
84
  arcColors?: string[];
76
85
  /** show group boundary ticks (tallest). Default: true */
77
86
  showGroupTicks?: boolean;
@@ -99,7 +108,7 @@ export declare interface NumberLineProps {
99
108
  getShowLabelStep?: (params: unknown) => boolean;
100
109
  }
101
110
 
102
- export declare function NumberLineProvider({ min, max, groupSize, groupCount, tickStep, strokeWidth: configuredStrokeWidth, coordinateScale: configuredCoordinateScale, arcColors, showGroupTicks, showMajorTicks, showMinorTicks, enableZoom, minZoom, maxZoom, showLabelStep, viewMin: controlledViewMin, viewMax: controlledViewMax, getShowLabelStep, onViewChange, children, }: NumberLineProviderProps): JSX_2.Element;
111
+ export declare function NumberLineProvider({ min, max, groupSize, groupCount, tickStep, strokeWidth: configuredStrokeWidth, coordinateScale: configuredCoordinateScale, tickHeightScale: configuredTickHeightScale, arcColors, showGroupTicks, showMajorTicks, showMinorTicks, enableZoom, minZoom, maxZoom, showLabelStep, viewMin: controlledViewMin, viewMax: controlledViewMax, getShowLabelStep, onViewChange, children, }: NumberLineProviderProps): JSX_2.Element;
103
112
 
104
113
  declare interface NumberLineProviderProps extends NumberLineProps {
105
114
  children: default_2.ReactNode;
package/dist/index.js CHANGED
@@ -256,6 +256,7 @@ function NumberLineProvider({
256
256
  tickStep,
257
257
  strokeWidth: configuredStrokeWidth,
258
258
  coordinateScale: configuredCoordinateScale = 1,
259
+ tickHeightScale: configuredTickHeightScale = 1,
259
260
  arcColors,
260
261
  showGroupTicks = true,
261
262
  showMajorTicks = true,
@@ -273,10 +274,15 @@ function NumberLineProvider({
273
274
  const { min: domainMin, max: domainMax } = normalizeDomain(min, max);
274
275
  const strokeWidth = typeof configuredStrokeWidth === "number" && Number.isFinite(configuredStrokeWidth) && configuredStrokeWidth > 0 ? configuredStrokeWidth : DEFAULT_DRAWING_STROKE_WIDTH;
275
276
  const coordinateScale = typeof configuredCoordinateScale === "number" && Number.isFinite(configuredCoordinateScale) && configuredCoordinateScale > 0 ? configuredCoordinateScale : 1;
277
+ const tickHeightScale = typeof configuredTickHeightScale === "number" && Number.isFinite(configuredTickHeightScale) && configuredTickHeightScale > 0 ? configuredTickHeightScale : 1;
276
278
  const [selectedArcIndices, setSelectedArcIndices] = useState(/* @__PURE__ */ new Set());
277
279
  const [deletedArcIndices, setDeletedArcIndices] = useState(/* @__PURE__ */ new Set());
278
280
  const [groupTicksHidden, setGroupTicksHidden] = useState(false);
279
- const { ref: svgRef, width: measuredWidth, height: measuredHeight } = useContainerSize();
281
+ const {
282
+ ref: svgRef,
283
+ width: measuredWidth,
284
+ height: measuredHeight
285
+ } = useContainerSize();
280
286
  const containerWidth = measuredWidth * coordinateScale;
281
287
  const containerHeight = measuredHeight * coordinateScale;
282
288
  const history = useHistory();
@@ -295,9 +301,7 @@ function NumberLineProvider({
295
301
  const eraseGesture = useRef(null);
296
302
  const createHistorySnapshot = useCallback(
297
303
  (strokesOverride) => ({
298
- strokes: (strokesOverride ?? drawing.strokes).map((stroke) => ({
299
- ...stroke
300
- })),
304
+ strokes: (strokesOverride ?? drawing.strokes).map((stroke) => ({ ...stroke })),
301
305
  deletedArcIndices: [...deletedArcIndices],
302
306
  selectedArcIndices: [...selectedArcIndices],
303
307
  groupTicksHidden
@@ -313,21 +317,26 @@ function NumberLineProvider({
313
317
  setDeletedArcIndices(/* @__PURE__ */ new Set());
314
318
  setSelectedArcIndices(/* @__PURE__ */ new Set());
315
319
  setGroupTicksHidden(false);
316
- }, [domainMin, domainMax, groupSize, groupCount, tickStep, drawing.resetStrokes, history.clear]);
317
- const getLocalCoords = useCallback(
318
- (event) => {
319
- const svg = svgRef.current;
320
- if (!svg) return null;
321
- const rect = svg.getBoundingClientRect();
322
- const viewBox = svg.viewBox.baseVal;
323
- if (rect.width <= 0 || rect.height <= 0 || viewBox.width <= 0 || viewBox.height <= 0) return null;
324
- return [
325
- viewBox.x + (event.clientX - rect.left) / rect.width * viewBox.width,
326
- viewBox.y + (event.clientY - rect.top) / rect.height * viewBox.height
327
- ];
328
- },
329
- [svgRef]
330
- );
320
+ }, [
321
+ domainMin,
322
+ domainMax,
323
+ groupSize,
324
+ groupCount,
325
+ tickStep,
326
+ drawing.resetStrokes,
327
+ history.clear
328
+ ]);
329
+ const getLocalCoords = useCallback((event) => {
330
+ const svg = svgRef.current;
331
+ if (!svg) return null;
332
+ const rect = svg.getBoundingClientRect();
333
+ const viewBox = svg.viewBox.baseVal;
334
+ if (rect.width <= 0 || rect.height <= 0 || viewBox.width <= 0 || viewBox.height <= 0) return null;
335
+ return [
336
+ viewBox.x + (event.clientX - rect.left) / rect.width * viewBox.width,
337
+ viewBox.y + (event.clientY - rect.top) / rect.height * viewBox.height
338
+ ];
339
+ }, [svgRef]);
331
340
  const strokeIdsNear = useCallback(
332
341
  (x, y, radius) => {
333
342
  const ids = [];
@@ -384,7 +393,8 @@ function NumberLineProvider({
384
393
  } else {
385
394
  const nextIds = strokeIdsNear(coords2[0], coords2[1], radius);
386
395
  setEraseHoverIds((prev) => {
387
- if (nextIds.length === prev.size && nextIds.every((id) => prev.has(id))) return prev;
396
+ if (nextIds.length === prev.size && nextIds.every((id) => prev.has(id)))
397
+ return prev;
388
398
  return new Set(nextIds);
389
399
  });
390
400
  }
@@ -456,7 +466,9 @@ function NumberLineProvider({
456
466
  }, [drawing, history, selectedArcIndices, createHistorySnapshot]);
457
467
  const clearAll = useCallback(() => {
458
468
  const arcCount = Math.max(0, Math.ceil(Number(groupCount) || 0));
459
- const allArcsDeleted = deletedArcIndices.size >= arcCount && Array.from({ length: arcCount }, (_, index) => index).every((index) => deletedArcIndices.has(index));
469
+ const allArcsDeleted = deletedArcIndices.size >= arcCount && Array.from({ length: arcCount }, (_, index) => index).every(
470
+ (index) => deletedArcIndices.has(index)
471
+ );
460
472
  const alreadyClear = drawing.strokes.length === 0 && selectedArcIndices.size === 0 && allArcsDeleted && groupTicksHidden;
461
473
  if (alreadyClear) return;
462
474
  history.push(createHistorySnapshot());
@@ -467,7 +479,15 @@ function NumberLineProvider({
467
479
  setDeletedArcIndices(new Set(Array.from({ length: arcCount }, (_, i) => i)));
468
480
  setSelectedArcIndices(/* @__PURE__ */ new Set());
469
481
  setGroupTicksHidden(true);
470
- }, [drawing, history, groupCount, deletedArcIndices, selectedArcIndices, groupTicksHidden, createHistorySnapshot]);
482
+ }, [
483
+ drawing,
484
+ history,
485
+ groupCount,
486
+ deletedArcIndices,
487
+ selectedArcIndices,
488
+ groupTicksHidden,
489
+ createHistorySnapshot
490
+ ]);
471
491
  const onReset = clearAll;
472
492
  const showAllArcs = useCallback(() => {
473
493
  setDeletedArcIndices((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
@@ -484,33 +504,27 @@ function NumberLineProvider({
484
504
  eraseGesture.current = null;
485
505
  setEraseHoverIds(/* @__PURE__ */ new Set());
486
506
  }, [history, drawing]);
487
- const onTogglePen = useCallback(() => {
488
- drawing.toggleTool("pen");
489
- clearArcSelection();
490
- }, [drawing, clearArcSelection]);
491
- const onToggleEraser = useCallback(() => {
492
- drawing.toggleTool("eraser");
493
- clearArcSelection();
494
- }, [drawing, clearArcSelection]);
507
+ const onTogglePen = useCallback(
508
+ () => {
509
+ drawing.toggleTool("pen");
510
+ clearArcSelection();
511
+ },
512
+ [drawing, clearArcSelection]
513
+ );
514
+ const onToggleEraser = useCallback(
515
+ () => {
516
+ drawing.toggleTool("eraser");
517
+ clearArcSelection();
518
+ },
519
+ [drawing, clearArcSelection]
520
+ );
495
521
  const canDelete = drawing.strokes.some((s) => s.selected) || selectedArcIndices.size > 0;
496
522
  useEffect(() => {
497
523
  setSelectedArcIndices(/* @__PURE__ */ new Set());
498
524
  setEraseHoverIds((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Set());
499
525
  }, [drawing.tool]);
500
- const keyHandlers = useRef({
501
- toggleTool: drawing.toggleTool,
502
- clearArcSelection,
503
- canDelete,
504
- onDelete,
505
- onUndo
506
- });
507
- keyHandlers.current = {
508
- toggleTool: drawing.toggleTool,
509
- clearArcSelection,
510
- canDelete,
511
- onDelete,
512
- onUndo
513
- };
526
+ const keyHandlers = useRef({ toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo });
527
+ keyHandlers.current = { toggleTool: drawing.toggleTool, clearArcSelection, canDelete, onDelete, onUndo };
514
528
  useEffect(() => {
515
529
  const svg = svgRef.current;
516
530
  if (!svg) return;
@@ -546,6 +560,7 @@ function NumberLineProvider({
546
560
  tickStep,
547
561
  strokeWidth,
548
562
  arcColors,
563
+ tickHeightScale,
549
564
  showGroupTicks: showGroupTicks && !groupTicksHidden,
550
565
  showMajorTicks,
551
566
  showMinorTicks,
@@ -668,6 +683,7 @@ const AxisLayer = React.memo(function AxisLayer2({
668
683
  groupSize,
669
684
  groupCount,
670
685
  tickStep,
686
+ tickHeightScale,
671
687
  showGroupTicks,
672
688
  showMajorTicks,
673
689
  showMinorTicks,
@@ -733,7 +749,7 @@ const AxisLayer = React.memo(function AxisLayer2({
733
749
  v
734
750
  }) : void 0;
735
751
  const shouldShowLabel = currentShowLabelStep ?? (showLabel || showLabelStep);
736
- const tickH = renderAsGroup ? 7 : renderAsMajor ? 10 : renderAsHalf ? 8 : 6;
752
+ const tickH = (renderAsGroup ? 7 : renderAsMajor ? 10 : renderAsHalf ? 8 : 6) * tickHeightScale;
737
753
  const strokeW = renderAsGroup ? 4 : renderAsMajor ? 2 : renderAsHalf ? 2 : 1;
738
754
  const arcIndex = renderAsGroup ? offset > 0 ? offset / groupSize - 1 : offset === 0 ? 0 : -1 : -1;
739
755
  const arcColored = arcIndex >= 0 && arcIndex < groupCount && !(deletedArcIndices == null ? void 0 : deletedArcIndices.has(arcIndex));
@@ -1099,6 +1115,7 @@ function NumberLine({ className }) {
1099
1115
  groupCount,
1100
1116
  tickStep,
1101
1117
  strokeWidth,
1118
+ tickHeightScale,
1102
1119
  arcColors,
1103
1120
  showGroupTicks,
1104
1121
  showMajorTicks,
@@ -1313,6 +1330,7 @@ function NumberLine({ className }) {
1313
1330
  groupSize,
1314
1331
  groupCount,
1315
1332
  tickStep,
1333
+ tickHeightScale,
1316
1334
  showGroupTicks,
1317
1335
  showMajorTicks,
1318
1336
  showMinorTicks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcg-overseas/number-line",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.6",
4
4
  "description": "Interactive number line component with group arcs and freehand drawing",
5
5
  "type": "module",
6
6
  "license": "MIT",