@dcg-overseas/number-line
v0.1.5
Published
Interactive number line component with group arcs and freehand drawing
Readme
@dcg-overseas/number-line
交互式数轴组件,支持分组弧线(带箭头终点标签)、自由画笔、橡皮擦、撤销与键盘快捷键。
- 容器自适应:
viewBox跟随容器实际像素,文字和线条始终保持设计尺寸 - 笔迹归一化存储(
[0,1]坐标 +vectorEffect="non-scaling-stroke"),容器 resize 后笔迹位置稳定不漂移 - Provider/Component 解耦:状态在
NumberLineProvider中管理,工具栏与数轴 SVG 各自独立组装
安装
pnpm add @dcg-overseas/number-line
# peer deps
pnpm add react react-dom快速开始
import { NumberLineProvider, NumberLine } from '@dcg-overseas/number-line'
function Demo() {
return (
<NumberLineProvider
min={0}
max={100}
groupSize={10}
groupCount={10}
tickStep={5}
>
<div style={{ width: '100%', height: 200 }}>
<NumberLine />
</div>
</NumberLineProvider>
)
}⚠️ 必须给
<NumberLine />的父容器明确的宽高(CSS 尺寸或 flex 布局),SVG 默认width:100% height:100%,没有外部尺寸约束会塌陷为 0。
API
<NumberLineProvider>
数轴的状态根。包裹任何需要访问数轴上下文的子组件(<NumberLine /> 和你自己的工具栏按钮)。
| Prop | 类型 | 默认值 | 说明 |
|------|------|-------|------|
| min | number | — | 数轴起点值(含) |
| max | number | — | 数轴终点值(含) |
| groupSize | number | — | 每组弧线跨度(数轴单位)。例:groupSize=8 表示每段弧从 n 跳到 n+8 |
| groupCount | number | — | 弧线组数。从 min 起依次绘制 groupCount 段弧线 |
| tickStep | number | — | 主刻度步长(数轴单位)。例:tickStep=5 表示 0、5、10、15… 为主刻度 |
| arcColors | string[] | 5 色循环 | 弧线颜色数组,按 groupIndex % length 轮换。默认值见 utils/geometry.ts ARC_COLORS |
| showGroupTicks | boolean | true | 是否显示分组边界刻度(最长,10px) |
| showMajorTicks | boolean | true | 是否显示主刻度(中等,7px) |
| showMinorTicks | boolean | false | 是否显示次刻度(最短,4px)。默认隐藏 |
| enableZoom | boolean | false | 启用滚轮/双指缩放 + 拖拽平移。默认禁用 |
| minZoom | number | 1 | 最小缩放级别(1 = 完整 [min, max] 范围) |
| maxZoom | number | 50 | 最大缩放级别(50 = 放大 50 倍) |
| viewMin | number? | — | 受控模式:当前可视范围起点。需与 viewMax 和 onViewChange 配合 |
| viewMax | number? | — | 受控模式:当前可视范围终点 |
| onViewChange | (viewMin, viewMax) => void | — | 非受控模式:viewport 变化时触发(滚轮/拖拽/双击重置) |
| children | ReactNode | — | 子节点(必须包含 <NumberLine /> 或自定义渲染器) |
弧线生成规则
弧线 g 的范围:[min + g*groupSize, min + (g+1)*groupSize]
g ∈ [0, groupCount)
若 (min + (g+1)*groupSize) > max,弧线提前停止三档刻度优先级
一个位置可能同时是多档刻度(例如 tickStep=5, groupSize=10 时,10 既是主刻度也是分组边界)。视觉与可见性按 分组边界 > 主刻度 > 次刻度 归类,仅看其最高档对应的 show*Ticks。
// 默认:只显示主刻度 + 分组边界
<NumberLineProvider min={0} max={100} groupSize={10} groupCount={10} tickStep={5} />
// 显示全部三档刻度
<NumberLineProvider ... showMinorTicks />
// 极简模式:仅分组边界
<NumberLineProvider ... showMajorTicks={false} />缩放与平移(enableZoom)
启用后支持:
- 滚轮缩放:以鼠标位置为中心放大/缩小
- 拖拽平移:
tool='none'时,左键拖拽 >5px 触发平移(避免与点击选中冲突) - 双指缩放(触摸屏):pinch 手势
- 双击重置:双击空白处恢复到完整
[min, max]范围
// 非受控模式(内部维护 viewport)
<NumberLineProvider
min={0}
max={100}
enableZoom
minZoom={1}
maxZoom={50}
onViewChange={(viewMin, viewMax) => console.log('viewport:', viewMin, viewMax)}
...
/>
// 受控模式(父组件控制 viewport)
function App() {
const [view, setView] = useState([0, 100])
return (
<NumberLineProvider
min={0}
max={100}
enableZoom
viewMin={view[0]}
viewMax={view[1]}
onViewChange={(a, b) => setView([a, b])}
...
/>
)
}注意事项:
- 笔迹坐标是归一化
[0,1](容器像素比例),不会跟随数值缩放移动。适合「批注」语义,不适合「标记 v=50 处」语义。 - viewport 始终夹紧在
[min, max]内,不会平移到数据范围外。 - 缩放时刻度密度自动调整(
labelStep按可视范围重算)。 - 桌面端:滚轮和 pinch 需要鼠标悬停或 SVG 获得焦点(视觉上有蓝色边框提示)。
- 移动端:双指 pinch 直接生效(触摸即激活)。SVG 外的双指仍会触发浏览器整页缩放——如果不需要整页缩放,可在宿主 HTML 添加:
但出于无障碍考虑,禁用整页缩放需谨慎评估。<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<NumberLine>
数轴 SVG 视图。必须放在 <NumberLineProvider> 内,自动从 context 读取所有配置和事件处理。
| Prop | 类型 | 默认值 | 说明 |
|------|------|-------|------|
| className | string? | — | 追加到 SVG 根节点的 className(基础类名 nl-svg) |
渲染层级(从下到上):
AxisLayer— 数轴线 + 刻度 + 标签GroupArcsLayer— 分组弧线 + 弧顶箭头标签 + 选中高亮DrawingLayer— 用户笔迹(normalized[0,1]坐标,scale 还原)
布局参数(内部常量,无需配置):
| 名称 | 值 | 含义 |
|------|---|------|
| PAD_LEFT | 30 | 数轴左 padding |
| PAD_RIGHT | 40 | 数轴右 padding |
| AXIS_BOTTOM_OFFSET | 50 | 数轴线距容器底部像素(给刻度数字留空间) |
| ARC_TOP_PADDING | 16 | 弧线顶部距容器顶部像素 |
| MIN_ARC_HEIGHT | 24 | 弧线最小高度(容器极小时兜底) |
useNumberLineContext()
读取 / 操作数轴状态的 hook。必须在 <NumberLineProvider> 子树内调用,否则抛错。
返回 NumberLineContextValue:
布局
| 字段 | 类型 | 说明 |
|------|------|------|
| svgRef | RefObject<SVGSVGElement> | SVG 元素 ref |
| containerWidth | number | 容器实际像素宽 |
| containerHeight | number | 容器实际像素高 |
| svgCursor | string | 当前 SVG cursor(自动随 tool 切换) |
配置(透传自 Provider props)
| 字段 | 类型 |
|------|------|
| min max groupSize groupCount tickStep | number |
| arcColors | string[] \| undefined |
| showGroupTicks showMajorTicks showMinorTicks | boolean |
绘图状态
| 字段 | 类型 | 说明 |
|------|------|------|
| tool | 'none' \| 'pen' \| 'eraser' | 当前工具 |
| strokes | Stroke[] | 已落笔的所有笔迹 |
| liveStroke | Stroke \| null | 正在绘制中的笔迹(半透明预览) |
| selectedArcIndices | Set<number> | 当前选中的弧线 group index |
| deletedArcIndices | Set<number> | 已被擦除/删除的弧线 group index |
事件回调(已绑定到 <NumberLine>,自定义渲染时使用)
| 字段 | 类型 |
|------|------|
| onPointerDown onPointerMove onPointerUp | (e: PointerEvent) => void |
| onStrokeClick | (id: string, e: PointerEvent) => void |
| onArcClick | (groupIndex: number, e: PointerEvent) => void |
工具栏 API
| 字段 | 类型 | 说明 |
|------|------|------|
| canDelete | boolean | 是否有可删除的选中项(笔迹或弧线) |
| canUndo | boolean | 是否有可撤销的操作 |
| onTogglePen | () => void | 切换画笔工具 |
| onToggleEraser | () => void | 切换橡皮擦 |
| onDelete | () => void | 删除当前选中 |
| onReset | () => void | 清空所有笔迹和弧线删除记录 |
| onUndo | () => void | 撤销上一步操作 |
缩放 API(仅 enableZoom=true 时有效)
| 字段 | 类型 | 说明 |
|------|------|------|
| viewMin viewMax | number | 当前可视范围(等于 [min, max] 当 zoom 禁用) |
| resetView | () => void | 重置到完整数据范围 |
| zoomAt | (factor: number, fraction: number) => void | 以 viewport 的 fraction 位置(0..1)为中心缩放 factor 倍 |
| panByFraction | (df: number) => void | 平移 viewport 宽度的 df 倍(正数右移,负数左移) |
自定义工具栏示例
<NumberLine> 不附带工具栏。把按钮和数轴一起放在 <NumberLineProvider> 下,通过 useNumberLineContext 接入:
import { NumberLineProvider, NumberLine, useNumberLineContext } from '@dcg-overseas/number-line'
function Toolbar() {
const { tool, canDelete, canUndo, onTogglePen, onToggleEraser, onDelete, onReset, onUndo } =
useNumberLineContext()
return (
<div className="flex gap-2">
<button data-active={tool === 'pen'} onClick={onTogglePen}>✏️ 画笔</button>
<button data-active={tool === 'eraser'} onClick={onToggleEraser}>🩹 擦除</button>
<button disabled={!canDelete} onClick={onDelete}>🗑 删除</button>
<button onClick={onReset}>♻️ 重置</button>
<button disabled={!canUndo} onClick={onUndo}>↶ 撤销</button>
</div>
)
}
function App() {
return (
<NumberLineProvider min={0} max={80} groupSize={8} groupCount={10} tickStep={1}>
<Toolbar />
<div style={{ height: 220 }}>
<NumberLine />
</div>
</NumberLineProvider>
)
}键盘快捷键
快捷键绑定在 SVG 元素上(tabIndex={0},需先聚焦 SVG),多实例互不干扰,也不会拦截外部输入框。
| 按键 | 行为 |
|------|------|
| Esc | 切回 'none' 模式,清空弧线选中 |
| Delete / Backspace | 删除当前选中的笔迹和弧线 |
| Ctrl+Z / Cmd+Z | 撤销上一步 |
类型导出
import type {
NumberLineProps,
NumberLineContextValue,
} from '@dcg-overseas/number-line'完整类型见 src/types.ts 与 src/context/NumberLineContext.ts。
设计要点
自适应坐标系
viewBox 动态跟随容器实际像素(viewBox={0 0 ${w} ${h}} + preserveAspectRatio="none"),1 SVG 单位 = 1 CSS 像素。这样:
- 文字和线条始终是设计尺寸(fontSize=11 永远是 11px),不会因为容器纵横比异常被缩到看不见
- 数轴自动贴近容器底部,弧线撑满上方空间,无空白浪费
笔迹归一化(resize 稳定)
笔迹存储为 [0, 1] 归一化坐标(M 0.234 0.456 L ...)。渲染时用 <g transform={scale(${w} ${h})}> 还原到容器像素,并配合 vectorEffect="non-scaling-stroke" 锁定线宽。
效果:容器从 400×200 变成 600×300,所有笔迹的相对位置仍然贴合数轴,stroke 宽度保持 2.5px。
操作级撤销
撤销栈记录的是「操作」而非「状态」(HistoryOp),分两类:
add— 新增笔迹,撤销 = 移除指定 iddelete— 删除笔迹/弧线,撤销 = 恢复删除前完整快照 + 还原弧线 group index
这样即使一次 delete 同时删了多条笔迹和多条弧线,撤销也能精确还原。
License
MIT
