@dcg-overseas/graphic-transform
v0.1.1
Published
Graphic transform tool
Readme
@dcg-overseas/graphic-transform
为小学数学教学场景提供的图形变换工具:在 14×14 网格上对图形执行 平移 / 反射 / 旋转,配套受控状态、内部历史栈、画布拖拽和 palette → canvas 拖放。
包提供 画布 + 操作逻辑;消费者负责 变换类型选择器、参数面板、形状选择器、撤销/重置工具栏。所有 UI 由应用层组合,包不携带 CSS。
安装
pnpm add @dcg-overseas/graphic-transformreact、react-dom(>=18)作为 peer 依赖。
快速开始
import { useState } from 'react'
import {
GraphicTransformProvider,
GraphicTransformBoard,
useGraphicTransformContext,
} from '@dcg-overseas/graphic-transform'
import type { TransformType } from '@dcg-overseas/graphic-transform'
export default function Demo() {
const [transformType, setTransformType] = useState<TransformType | null>(null)
return (
<GraphicTransformProvider transformType={transformType}>
<ModeSelect value={transformType} onChange={setTransformType} />
{/* Board fills its parent — wrap it in a sized container */}
<div style={{ width: 480, height: 480 }}>
<GraphicTransformBoard />
</div>
<Toolbar />
</GraphicTransformProvider>
)
}
function ModeSelect({ value, onChange }: {
value: TransformType | null
onChange: (t: TransformType | null) => void
}) {
return (
<select value={value ?? ''} onChange={(e) => onChange((e.target.value || null) as TransformType | null)}>
<option value="">请选择</option>
<option value="translation">平移</option>
<option value="reflection">反射</option>
<option value="rotation">旋转</option>
</select>
)
}
function Toolbar() {
const { canUndo, undo, reset } = useGraphicTransformContext()
return (
<div>
<button onClick={undo} disabled={!canUndo}>撤销</button>
<button onClick={reset}>重置</button>
</div>
)
}完整可运行示例见 apps/web/src/pages/graphic-transform/。
设计
GraphicTransformProvider ─── 历史栈 (DrawingState[])
│ 变换数学
│ 拖拽/落点 client→math 换算
│ theme + classNames 默认值
│
├── GraphicTransformBoard ← 渲染 SVG 画布
│
└── useGraphicTransformContext() ← 任意后代读取状态、调操作transformType由消费者受控(useState+ select)—— 包不会单独存它,所以切换变换类型不会污染 history 栈。- 绘图状态(
shapeId/shapeOffset/translation/reflection/rotation)由包内拥有,每次变更都进 history。撤销永远只回退“一次有意义的绘制操作”。 - 画布拖拽期间使用
tempOffset临时显示,松手时一次性 push 到 history。 - palette → canvas 拖放由消费者实现(自定义预览/ghost),调用
ctx.dropShapeAtClientPoint(shapeId, clientX, clientY)即可完成落点换算并入 history。
API 参考
<GraphicTransformProvider> props
| Prop | 类型 | 必填 | 说明 |
|---|---|---|---|
| transformType | TransformType \| null | ✓ | 当前变换类型,消费者受控。null 表示“请选择”空态。 |
| initialDrawingState | Partial<DrawingState> | | 仅在首次挂载时使用的初始绘图状态。 |
| theme | GraphicTransformTheme | | inline-style fallback 颜色/线宽,见 主题。 |
| classNames | GraphicTransformClassNames | | SVG 元素的 className 覆盖,见 classNames 定制。 |
| children | ReactNode | ✓ | 任意放在 context 内的组件(Board + 你的工具栏 / 面板)。 |
Provider 没有
width/height。<GraphicTransformBoard>自动填满父容器,父容器 CSS 决定画布尺寸。
<GraphicTransformBoard> props
无。组件从 context 读取所有状态,填满最近的父容器:
<div style={{ width: 480, height: 480 }}>
<GraphicTransformBoard />
</div>SVG 内已设置 touch-action: none,移动端拖拽不会被浏览器误判为滚动。
useGraphicTransformContext() 返回值
{
// 受控配置(来自 props)
transformType: TransformType | null,
// 当前绘图状态(来自 history 栈顶;拖拽中是 tempOffset)
shapeId: ShapeId | null,
shapeOffset: Point,
translation: TranslationState,
reflection: ReflectionState,
rotation: RotationState,
// 派生(memo)
currentShape: ShapeDef | null,
originalPoints: Point[], // 原始多边形(已加 offset)
transformedPoints: Point[], // 变换后多边形(参数不全时为 [])
maxTranslation: { up, down, left, right },
isReflectionActive: boolean,
isDragging: boolean,
// 写入(自动入 history)
setShapeId: (id: ShapeId | null) => void,
setTranslation: (t: TranslationState) => void,
setReflection: (r: ReflectionState) => void,
setRotation: (r: RotationState) => void,
// 历史
canUndo: boolean,
undo: () => void,
reset: () => void,
// 拖放(应用层 palette 调用)
/** 把图形落到屏幕坐标点;命中画布返回 true 并入 history,否则返回 false */
dropShapeAtClientPoint: (shapeId: ShapeId, clientX: number, clientY: number) => boolean,
// theme / classNames(已填默认值)
theme: Required<GraphicTransformTheme>,
classNames: Required<GraphicTransformClassNames>,
// 内部 — Board 使用,消费者通常不需要
beginCanvasDrag, updateCanvasDrag, endCanvasDrag, registerSvgRef,
}数据模型
type TransformType = 'translation' | 'reflection' | 'rotation'
type ShapeId = 'shape1' | 'shape2' | 'shape3' | 'shape4' | 'shape5' | 'shape6'
type ReflectionAxis = 'vertical' | 'horizontal'
type RotationDirection = 'clockwise' | 'counter-clockwise'
type RotationAngle = '90' | '180' | '270' | '360'
interface Point { x: number; y: number }
interface TranslationState {
upDownVal: number
upDownDir: 'up' | 'down'
leftRightVal: number
leftRightDir: 'left' | 'right'
}
interface ReflectionState { axis: ReflectionAxis | null }
interface RotationState { direction: RotationDirection | null; angle: RotationAngle | null }
interface DrawingState {
shapeId: ShapeId | null
shapeOffset: Point
translation: TranslationState
reflection: ReflectionState
rotation: RotationState
}所有坐标都是网格单位(整数)。grid 范围固定 [-7, 7],即 14×14 格。
内置图形
import { SHAPES, SHAPE_LABELS } from '@dcg-overseas/graphic-transform'| ID | 图形 | 顶点数 |
|---|---|---|
| shape1 | 直角三角形 | 3 |
| shape2 | 凹五边形(M 形) | 5 |
| shape3 | 箭头六边形 | 6 |
| shape4 | 五边形房屋 | 5 |
| shape5 | 蝴蝶结 | 6 |
| shape6 | 等腰三角形 | 3 |
SHAPES[id].rotationCenter 取图形的“左下顶点”作为旋转中心(对称图形如菱形取最左点)。SHAPE_LABELS[id] 提供中文显示名,可直接用于 <select>。
变换数学
平移
(x, y) → (x + dx, y + dy),其中 dx = leftRightDir==='right' ? +val : -val,dy 同理(“上”为正)。
反射
- 垂直对称(纵轴 y 轴):
(x, y) → (-x, y) - 水平对称(横轴 x 轴):
(x, y) → (x, -y)
旋转
绕 rotationCenter 旋转 angle 度,顺时针为视觉负向(SVG y 轴在显示前已通过 CSS scale(1, -1) 翻转)。
const sign = direction === 'clockwise' ? -1 : 1
const rad = sign * angleDeg * Math.PI / 180
// 标准旋转矩阵 [cos -sin; sin cos]360° 即恒等变换,270° 等同反方向 90°。
主题 (GraphicTransformTheme)
inline-style fallback,仅在对应 className 为空时生效。如果你给 classNames 全填了,theme 颜色不会有任何效果(因为 className 模式完全交给 CSS)。
{
axisStrokeWidth?: number // 默认 0.07
gridStrokeWidth?: number // 默认 0.04
axisColor?: string // 默认 '#94a3b8'
gridColor?: string // 默认 '#e2e8f0'
originalFill?: string // 默认 '#bfdbfe' 原始图形
originalStroke?: string // 默认 '#2563eb'
transformedFill?: string // 默认 '#fecaca' 变换后(平移/旋转)
transformedStroke?: string // 默认 '#dc2626'
reflectionFill?: string // 默认 '#fed7aa' 反射时覆盖 transformed*
reflectionStroke?: string // 默认 '#ea580c'
mirrorLineColor?: string // 默认 '#ef4444'
rotationCenterColor?: string // 默认 '#ef4444'
boundaryColor?: string // 默认 '#e2e8f0' 画布内边界
boundaryStrokeWidth?: number // 默认 0.05
}适合“零 CSS 即可使用”的场景。下一节是更灵活的 className 定制。
classNames 定制
包不携带 CSS。如果你需要伪类(:hover、.dragging)、媒体查询、CSS 变量,请走 className 路径:
import type { GraphicTransformClassNames } from '@dcg-overseas/graphic-transform'
const BOARD_CLASS_NAMES: GraphicTransformClassNames = {
svg: 'gt-board-svg',
boundary: 'gt-boundary',
gridLine: 'gt-grid-line',
axis: 'gt-axis',
mirrorLine: 'gt-mirror-line',
shapeOriginal: 'gt-shape-original',
shapeOriginalDragging: 'dragging', // 拖拽时附加在 shapeOriginal 上
shapeTransformed: 'gt-shape-transformed',
shapeTransformedReflection: 'reflection', // 反射时附加在 shapeTransformed 上
rotationCenter: 'gt-rotation-center',
}
<GraphicTransformProvider transformType={transformType} classNames={BOARD_CLASS_NAMES}>
…
</GraphicTransformProvider>对应 CSS 示例:
.gt-board-svg { width: 100%; height: 100%; touch-action: none; }
.gt-boundary { fill: none; stroke: #e2e8f0; stroke-width: 0.05; }
.gt-grid-line { stroke: #e2e8f0; }
.gt-axis { stroke: #94a3b8; }
.gt-mirror-line { stroke: #ef4444; }
.gt-rotation-center { fill: #ef4444; }
.gt-shape-original { fill: #bfdbfe; stroke: #2563eb; cursor: grab; stroke-width: 0.1; }
.gt-shape-original.dragging { cursor: grabbing; }
.gt-shape-transformed { fill: #fecaca; stroke: #dc2626; fill-opacity: 0.7; pointer-events: none; }
.gt-shape-transformed.reflection { fill: #fed7aa; stroke: #ea580c; }取舍:对每个 SVG 元素,如果你给了 className,Board 不再写 inline style(fill/stroke 都靠你的 CSS);如果某个 className 是空字符串,则使用 theme 的 inline-style fallback。可以混用 —— 比如只给 shapeOriginal 一个 className(为了 :hover),其它元素继续吃 theme。
palette → canvas 拖放
包不渲染 palette,但提供了“落点换算”原语:
function MyShapePalette() {
const { dropShapeAtClientPoint } = useGraphicTransformContext()
const [drag, setDrag] = useState<{ shapeId: ShapeId; x: number; y: number } | null>(null)
return (
<div
style={{ touchAction: 'none', cursor: 'grab' }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId)
setDrag({ shapeId: 'shape1', x: e.clientX, y: e.clientY })
}}
onPointerMove={(e) => drag && setDrag({ ...drag, x: e.clientX, y: e.clientY })}
onPointerUp={(e) => {
if (!drag) return
e.currentTarget.releasePointerCapture(e.pointerId)
dropShapeAtClientPoint(drag.shapeId, e.clientX, e.clientY)
setDrag(null)
}}
onPointerCancel={() => setDrag(null)}
>
{/* 你的预览 + 跟随手指的 ghost */}
</div>
)
}dropShapeAtClientPoint 会:
- 检查屏幕坐标是否落在 SVG 边界内,否则返回
false不入 history - 通过
getScreenCTM()把clientX/Y转为 SVG 网格坐标 Math.round吸附到整数格- 用形状的多边形顶点裁剪到 ±7 边界
- 一次性 push 到 history
完整 palette 示例(含 ghost):apps/web/src/pages/graphic-transform/components.tsx。
撤销策略
每次以下操作进 history,可单步撤销:
setShapeId—— 选 / 换 / 清图形setTranslation/setReflection/setRotation—— 改任意参数- 画布上拖拽图形(松手时一次)
- palette → canvas 落点(命中时一次)
不进 history:
- 切换
transformType(消费者状态,与 history 解耦) - 拖拽过程中的中间帧
reset() 清空整个 history。
网格约定
- 14×14 格,坐标范围
[-7, 7],HALF_GRID = 7 - SVG
viewBox="-7 -7 14 14",通过 CSStransform: scale(1, -1)把 y 轴翻转为数学习惯(向上为正) - 1 格 =
画布像素 / 14,响应父容器宽度
移动端
- 默认
touch-action: none,单指拖拽不会触发页面滚动 - 使用 Pointer Events(统一鼠标 / 触摸 / 笔),
setPointerCapture保证手指移出元素后事件继续触发 - 已处理
pointercancel:系统中断手势时会清理拖拽状态
导出
// 组件 + Hook
export { GraphicTransformProvider, GraphicTransformBoard }
export { useGraphicTransformContext, GraphicTransformContext }
// 常量
export { SHAPES, SHAPE_LABELS, GRID_SIZE, HALF_GRID, INITIAL_STATE }
// 类型
export type {
AppState, DrawingState, Point, ShapeDef, ShapeId,
TransformType, TranslationState, ReflectionState, RotationState,
ReflectionAxis, RotationAngle, RotationDirection,
GraphicTransformTheme, GraphicTransformClassNames,
GraphicTransformProviderProps, GraphicTransformContextValue,
}