feat: 新增认识时钟绘制

This commit is contained in:
R524809
2026-06-25 12:46:43 +08:00
parent b802709517
commit 783ebfb936
15 changed files with 1119 additions and 1 deletions
@@ -0,0 +1,63 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import type { ClockProblem, ClockReadingData } from '../generators/clock-generator';
import { drawAnalogClock, drawTimeInputBox } from './drawAnalogClock';
const ROWS = 4;
const COLS = 3;
const MARGIN_X = 28;
const CLOCK_RADIUS = 52; // 原 58 的 90%
const CLOCK_TO_BOX_GAP = 13; // 原 20 缩小三分之一
const INPUT_BOX_W = 88;
const INPUT_BOX_H = 30;
const ROW_GAP = 28; // 原 14 的两倍
const CONTENT_BOTTOM_PADDING = 28;
/**
* 认识钟表绘制服务:4×3 网格,每组钟表 + 填写框
*/
export default class ClockReadingDraw extends BaseDrawService {
async draw(data: ClockReadingData) {
if (!data?.problems?.length) return;
this.prepareDraw();
await this.drawHeaderAndDivider();
this.drawGrid(data.problems);
this.drawPrintFooter({ mode: 'A' });
}
private drawGrid(problems: ClockProblem[]) {
const { ctx, canvasWidth, canvasHeight } = this;
const contentTop = this.currentY;
const availableH = canvasHeight - contentTop - CONTENT_BOTTOM_PADDING;
const clockBlockH =
CLOCK_RADIUS * 2 + CLOCK_TO_BOX_GAP + INPUT_BOX_H;
const totalGridH = ROWS * clockBlockH + (ROWS - 1) * ROW_GAP;
const startY = contentTop + Math.max(12, (availableH - totalGridH) / 2);
const availableW = canvasWidth - MARGIN_X * 2;
const colW = availableW / COLS;
const rowH = clockBlockH + ROW_GAP;
for (let i = 0; i < problems.length; i++) {
const row = Math.floor(i / COLS);
const col = i % COLS;
if (row >= ROWS) break;
const problem = problems[i];
const cellCenterX = MARGIN_X + col * colW + colW / 2;
const cellTopY = startY + row * rowH;
const clockCy = cellTopY + CLOCK_RADIUS;
drawAnalogClock(ctx, {
cx: cellCenterX,
cy: clockCy,
radius: CLOCK_RADIUS,
hour: problem.hour,
minute: problem.minute,
});
const boxX = cellCenterX - INPUT_BOX_W / 2;
const boxY = cellTopY + CLOCK_RADIUS * 2 + CLOCK_TO_BOX_GAP;
drawTimeInputBox(ctx, boxX, boxY, INPUT_BOX_W, INPUT_BOX_H);
}
}
}
@@ -0,0 +1,210 @@
const CLOCK_GREEN = '#3D9E47';
const CLOCK_BLACK = '#333333';
const HOUR_HAND_RED = '#E53935';
export interface DrawAnalogClockOptions {
cx: number;
cy: number;
radius: number;
hour: number;
minute: number;
}
function toCanvasAngle(degFromTwelve: number): number {
return ((degFromTwelve - 90) * Math.PI) / 180;
}
function handPoint(
cx: number,
cy: number,
angle: number,
perp: number,
along: number,
perpOffset: number,
) {
return {
x: cx + Math.cos(angle) * along + Math.cos(perp) * perpOffset,
y: cy + Math.sin(angle) * along + Math.sin(perp) * perpOffset,
};
}
/** 指针:近圆心 80% 为矩形,远端 20% 收尖为三角形 */
function drawCompositeHand(
ctx: RenderingContext,
cx: number,
cy: number,
angleDeg: number,
length: number,
width: number,
color: string,
) {
const angle = toCanvasAngle(angleDeg);
const perp = angle + Math.PI / 2;
const halfW = width / 2;
const rectEnd = length * 0.8;
const tip = handPoint(cx, cy, angle, perp, length, 0);
const rectEndR = handPoint(cx, cy, angle, perp, rectEnd, halfW);
const baseR = handPoint(cx, cy, angle, perp, 0, halfW);
const baseL = handPoint(cx, cy, angle, perp, 0, -halfW);
const rectEndL = handPoint(cx, cy, angle, perp, rectEnd, -halfW);
ctx.save();
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(tip.x, tip.y);
ctx.lineTo(rectEndR.x, rectEndR.y);
ctx.lineTo(baseR.x, baseR.y);
ctx.lineTo(baseL.x, baseL.y);
ctx.lineTo(rectEndL.x, rectEndL.y);
ctx.closePath();
ctx.fill();
ctx.restore();
}
const HAND_WIDTH = 2.8;
/** 绘制模拟钟表:绿圈、刻度、数字、红时针、黑分针 */
export function drawAnalogClock(
ctx: RenderingContext,
options: DrawAnalogClockOptions,
): void {
const { cx, cy, radius, hour, minute } = options;
const faceRadius = radius - 4;
ctx.save();
// 外圈(线宽原 3.5 的 90%
ctx.strokeStyle = CLOCK_GREEN;
ctx.lineWidth = 3.15;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
// 刻度(长度缩短为原来的 80%)
const tickOuter = faceRadius - 1;
const hourTickLen = 7 * 0.8;
const minuteTickLen = 3 * 0.8;
for (let i = 0; i < 60; i++) {
const angle = toCanvasAngle(i * 6);
const isHour = i % 5 === 0;
const tickLen = isHour ? hourTickLen : minuteTickLen;
const inner = tickOuter - tickLen;
ctx.strokeStyle = CLOCK_BLACK;
ctx.lineWidth = isHour ? 1.5 : 0.8;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
ctx.lineTo(cx + Math.cos(angle) * tickOuter, cy + Math.sin(angle) * tickOuter);
ctx.stroke();
}
// 数字 112(原 13px 的 70%
ctx.fillStyle = CLOCK_BLACK;
ctx.font = 'bold 9px "Microsoft Yahei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const numberRadius = faceRadius - 14;
for (let n = 1; n <= 12; n++) {
const angle = toCanvasAngle(n * 30);
const nx = cx + Math.cos(angle) * numberRadius;
const ny = cy + Math.sin(angle) * numberRadius;
ctx.fillText(String(n), nx, ny);
}
const minuteAngle = minute * 6;
const hourAngle = (hour % 12) * 30 + minute * 0.5;
// 指针:80% 矩形 + 20% 三角尖
const minuteHandLength = numberRadius - 3;
const hourHandLength = numberRadius * 0.67;
drawCompositeHand(
ctx,
cx,
cy,
minuteAngle,
minuteHandLength,
HAND_WIDTH,
CLOCK_BLACK,
);
drawCompositeHand(
ctx,
cx,
cy,
hourAngle,
hourHandLength,
HAND_WIDTH,
HOUR_HAND_RED,
);
// 中心点
ctx.fillStyle = CLOCK_BLACK;
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const INPUT_GREEN = '#3D9E47';
function strokeRoundRect(
ctx: RenderingContext,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.arcTo(x + w, y, x + w, y + radius, radius);
ctx.lineTo(x + w, y + h - radius);
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
ctx.lineTo(x + radius, y + h);
ctx.arcTo(x, y + h, x, y + h - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.stroke();
}
/** 在矩形内水平、垂直居中绘制文字 */
function fillTextCentered(
ctx: RenderingContext,
text: string,
x: number,
y: number,
width: number,
height: number,
) {
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText(text);
const ascent = metrics.actualBoundingBoxAscent ?? 10;
const descent = metrics.actualBoundingBoxDescent ?? 2;
const textH = ascent + descent;
const baselineY = y + (height - textH) / 2 + ascent;
ctx.fillText(text, x + width / 2, baselineY);
}
/** 绘制数字填写框(绿色圆角描边 + 居中冒号) */
export function drawTimeInputBox(
ctx: RenderingContext,
x: number,
y: number,
width: number,
height: number,
): void {
ctx.save();
ctx.strokeStyle = INPUT_GREEN;
ctx.lineWidth = 1.5;
strokeRoundRect(ctx, x, y, width, height, 6);
ctx.fillStyle = CLOCK_BLACK;
ctx.font = 'bold 16px "Microsoft Yahei", sans-serif';
fillTextCentered(ctx, ':', x, y, width, height);
ctx.restore();
}