feat: 新增认识时钟绘制
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||
import { inferGradeFromAge } from '../../utils/debugPublish';
|
||||
import { CLOCK_READING_WORKSHEET_DEFINITIONS } from '../../config/worksheets/clockReading';
|
||||
import {
|
||||
CLOCK_TIME_MODE_OPTIONS,
|
||||
type ClockReadingTimeMode,
|
||||
} from './generators/clock-generator';
|
||||
|
||||
export { CLOCK_READING_WORKSHEET_DEFINITIONS };
|
||||
|
||||
export type { ClockReadingTimeMode };
|
||||
|
||||
type ClockReadingWorksheetRow =
|
||||
(typeof CLOCK_READING_WORKSHEET_DEFINITIONS)[number];
|
||||
|
||||
const CLOCK_READING_WORKSHEET_BY_ID = Object.fromEntries(
|
||||
CLOCK_READING_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
|
||||
) as Record<string, ClockReadingWorksheetRow>;
|
||||
|
||||
export const CLOCK_READING_MODE_OPTIONS = CLOCK_READING_WORKSHEET_DEFINITIONS;
|
||||
|
||||
export { CLOCK_TIME_MODE_OPTIONS };
|
||||
|
||||
export function getModeInfo(id: string) {
|
||||
const m = CLOCK_READING_WORKSHEET_BY_ID[id];
|
||||
return m ? { title: m.title, desc: m.subtitle } : undefined;
|
||||
}
|
||||
|
||||
export function isValidMode(id: string): boolean {
|
||||
return id in CLOCK_READING_WORKSHEET_BY_ID;
|
||||
}
|
||||
|
||||
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
|
||||
const m = CLOCK_READING_WORKSHEET_BY_ID[id];
|
||||
if (!m) return null;
|
||||
return {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
subtitle: m.subtitle,
|
||||
category: 'math',
|
||||
subcategory: 'clock-reading',
|
||||
path: `/mathPages/clockReading/clockReading?id=${m.id}`,
|
||||
ageMin: m.ageMin,
|
||||
ageMax: m.ageMax,
|
||||
grade: inferGradeFromAge(m.ageMin, m.ageMax),
|
||||
difficulty: m.difficulty,
|
||||
previewImg: '',
|
||||
tags: [...m.tags],
|
||||
isNew: false,
|
||||
isHot: false,
|
||||
sortOrder: m.sortOrder,
|
||||
status: 'draft',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "认识时钟",
|
||||
"navigationBarBackgroundColor": "#F8F0E0",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#FEF6E7",
|
||||
"enablePullDownRefresh": false,
|
||||
"usingComponents": {
|
||||
"nav-bar": "../../components3.0/nav-bar/nav-bar",
|
||||
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
|
||||
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
|
||||
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
|
||||
"preview-card": "../../components3.0/preview-card/preview-card"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
@import '../../style/theme.less';
|
||||
|
||||
page {
|
||||
background-color: @bg-page;
|
||||
}
|
||||
|
||||
.cr-page {
|
||||
min-height: 100vh;
|
||||
padding: 0 @page-padding-x;
|
||||
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cr-main {
|
||||
padding-top: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 64rpx;
|
||||
}
|
||||
|
||||
.cr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.cr-section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #6d3b00;
|
||||
padding-left: 8rpx;
|
||||
}
|
||||
|
||||
.cr-chip-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.cr-chip {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24rpx 16rpx;
|
||||
color: @text-secondary;
|
||||
background: @bg-card;
|
||||
border-radius: 32rpx;
|
||||
transition:
|
||||
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.cr-chip--pressed {
|
||||
transform: scale(0.96);
|
||||
background: @brand;
|
||||
color: @text-selected-btn;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
|
||||
}
|
||||
|
||||
.cr-chip--active {
|
||||
background: @brand;
|
||||
color: @text-selected-btn;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
|
||||
}
|
||||
|
||||
.cr-chip__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cr-chip__label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.cr-chip__subtitle {
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
line-height: 28rpx;
|
||||
opacity: 0.75;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cr-chip--active .cr-chip__subtitle,
|
||||
.cr-chip--pressed .cr-chip__subtitle {
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import ClockReadingDraw from './draw/clockReadingDraw';
|
||||
import {
|
||||
buildClockReadingData,
|
||||
type ClockReadingTimeMode,
|
||||
} from './generators/clock-generator';
|
||||
import { createPage, type CanvasDataState } from '../../base/pageMixin';
|
||||
import { defaultShareConfig } from '../../config/config';
|
||||
import {
|
||||
getModeInfo,
|
||||
getPublishMetaByMode,
|
||||
isValidMode,
|
||||
CLOCK_TIME_MODE_OPTIONS,
|
||||
} from './clockReading.config';
|
||||
import type { DebugPublishMeta } from '../../utils/debugPublish';
|
||||
import {
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
batchCheckFavorited,
|
||||
} from '../../utils/favorites';
|
||||
|
||||
const pageInfoLookup = getModeInfo;
|
||||
const WORKSHEET_ID = 'clock-reading';
|
||||
|
||||
type PageData = CanvasDataState & {
|
||||
worksheetId: string;
|
||||
timeMode: ClockReadingTimeMode;
|
||||
timeModeOptions: typeof CLOCK_TIME_MODE_OPTIONS;
|
||||
isPreviewFavorite: boolean;
|
||||
isDevEnv: boolean;
|
||||
debugPublishVisible: boolean;
|
||||
debugPublishLoading: boolean;
|
||||
debugPublishMeta: DebugPublishMeta | null;
|
||||
};
|
||||
|
||||
createPage(
|
||||
{
|
||||
canvas: null as Canvas | null,
|
||||
ctx: null as RenderingContext | null,
|
||||
boxHeight: 0,
|
||||
boxWidth: 0,
|
||||
drawService: null as ClockReadingDraw | null,
|
||||
_favoritedMap: {} as Record<string, boolean>,
|
||||
|
||||
data: {
|
||||
pageTitle: '认识钟表',
|
||||
functionId: WORKSHEET_ID,
|
||||
hasContent: false,
|
||||
showShareDialog: false,
|
||||
worksheetId: WORKSHEET_ID,
|
||||
timeMode: 'random' as ClockReadingTimeMode,
|
||||
timeModeOptions: CLOCK_TIME_MODE_OPTIONS,
|
||||
isPreviewFavorite: false,
|
||||
isDevEnv: false,
|
||||
debugPublishVisible: false,
|
||||
debugPublishLoading: false,
|
||||
debugPublishMeta: null,
|
||||
} as unknown as PageData,
|
||||
|
||||
onLoad(options: { id?: string }) {
|
||||
const worksheetId =
|
||||
options.id && isValidMode(options.id)
|
||||
? options.id
|
||||
: WORKSHEET_ID;
|
||||
this.syncDebugPublishEnv();
|
||||
this.applyWorksheet(worksheetId);
|
||||
this.loadFavoritedMap();
|
||||
},
|
||||
|
||||
onSelectTimeMode(e: WechatMiniprogram.TouchEvent) {
|
||||
const mode = e.currentTarget.dataset.mode as
|
||||
| ClockReadingTimeMode
|
||||
| undefined;
|
||||
if (!mode || mode === this.data.timeMode) return;
|
||||
this.setData({ timeMode: mode }, () => this.drawCanvas());
|
||||
},
|
||||
|
||||
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
|
||||
this.initCanvasFromComponent(e.detail, {
|
||||
createDrawService: (
|
||||
canvas: Canvas,
|
||||
ctx: RenderingContext,
|
||||
options?: Record<string, unknown>,
|
||||
) => new ClockReadingDraw(canvas, ctx, options),
|
||||
drawServiceOptions: {
|
||||
title: this.data.pageTitle,
|
||||
},
|
||||
onCanvasReady: () => {
|
||||
this.drawCanvas();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async drawCanvas() {
|
||||
if (!this.ctx || !this.drawService) return;
|
||||
try {
|
||||
const data = buildClockReadingData(this.data.timeMode);
|
||||
await (this.drawService as ClockReadingDraw).draw(data);
|
||||
this.setData({ hasContent: true });
|
||||
} catch (e) {
|
||||
console.error('clockReading draw failed', e);
|
||||
this.setData({ hasContent: false });
|
||||
}
|
||||
},
|
||||
|
||||
onPreviewRefresh() {
|
||||
this.drawCanvas();
|
||||
},
|
||||
|
||||
async onPreviewFavorite() {
|
||||
const next = !this.data.isPreviewFavorite;
|
||||
this.setData({ isPreviewFavorite: next });
|
||||
const id = this.data.worksheetId;
|
||||
if (id && this._favoritedMap) {
|
||||
this._favoritedMap[id] = next;
|
||||
if (next) {
|
||||
addFavorite(id);
|
||||
} else {
|
||||
removeFavorite(id);
|
||||
}
|
||||
}
|
||||
wx.showToast({
|
||||
title: next ? '收藏成功' : '已取消收藏',
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
|
||||
async loadFavoritedMap() {
|
||||
const ids = [WORKSHEET_ID];
|
||||
this._favoritedMap = await batchCheckFavorited(ids);
|
||||
if (this._favoritedMap?.[WORKSHEET_ID]) {
|
||||
this.setData({ isPreviewFavorite: true });
|
||||
}
|
||||
},
|
||||
|
||||
getPublishMeta(): DebugPublishMeta {
|
||||
const meta = getPublishMetaByMode(this.data.worksheetId);
|
||||
if (!meta) {
|
||||
throw new Error('当前题型配置不存在');
|
||||
}
|
||||
return meta;
|
||||
},
|
||||
|
||||
getWorksheetStatsId() {
|
||||
return this.data.worksheetId;
|
||||
},
|
||||
|
||||
applyWorksheet(worksheetId: string) {
|
||||
if (!isValidMode(worksheetId)) return;
|
||||
|
||||
this.setData(
|
||||
{
|
||||
worksheetId,
|
||||
functionId: worksheetId,
|
||||
isPreviewFavorite: !!this._favoritedMap?.[worksheetId],
|
||||
},
|
||||
() => {
|
||||
this.initPageInfo(worksheetId, '认识钟表');
|
||||
if (this.drawService) {
|
||||
this.drawService.options.title = this.data.pageTitle;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
shareConfig: defaultShareConfig,
|
||||
pageInfoLookup,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
<nav-bar title="认识钟表" />
|
||||
|
||||
<view class="cr-page">
|
||||
<view class="cr-main">
|
||||
<preview-card
|
||||
id="previewCard"
|
||||
showRefresh="{{true}}"
|
||||
showFavorite="{{true}}"
|
||||
favorited="{{isPreviewFavorite}}"
|
||||
bind:canvas-ready="onCanvasReady"
|
||||
bind:refresh="onPreviewRefresh"
|
||||
bind:favorite="onPreviewFavorite" />
|
||||
|
||||
<view class="cr-section">
|
||||
<text class="cr-section-title">时刻类型</text>
|
||||
<view class="cr-chip-row">
|
||||
<view
|
||||
wx:for="{{timeModeOptions}}"
|
||||
wx:key="id"
|
||||
class="cr-chip {{timeMode === item.id ? 'cr-chip--active' : ''}}"
|
||||
hover-class="cr-chip--pressed"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="70"
|
||||
data-mode="{{item.id}}"
|
||||
bind:tap="onSelectTimeMode">
|
||||
<view class="cr-chip__text">
|
||||
<text class="cr-chip__label">{{item.label}}</text>
|
||||
<text class="cr-chip__subtitle">{{item.subtitle}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<preview-footer-actions
|
||||
disabled="{{!hasContent}}"
|
||||
bind:primary="exportToPrint"
|
||||
bind:secondary="onShare" />
|
||||
|
||||
<debug-publish-tools
|
||||
wx:if="{{isDevEnv && hasContent}}"
|
||||
id="debugPublishTools"
|
||||
visible="{{debugPublishVisible}}"
|
||||
loading="{{debugPublishLoading}}"
|
||||
meta="{{debugPublishMeta}}"
|
||||
bind:open="onOpenDebugPublish"
|
||||
bind:close="onCloseDebugPublish"
|
||||
bind:confirm="onConfirmDebugPublish" />
|
||||
|
||||
<share-guide-popup
|
||||
show="{{showShareDialog}}"
|
||||
bind:onClose="onCloseShareDialog"
|
||||
bind:onShareSuccess="onShareSuccess" />
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// 数字 1–12(原 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();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export type ClockReadingTimeMode =
|
||||
| 'random'
|
||||
| 'whole-hour'
|
||||
| 'half-hour'
|
||||
| 'quarter-hour';
|
||||
|
||||
export interface ClockProblem {
|
||||
hour: number;
|
||||
minute: number;
|
||||
}
|
||||
|
||||
export interface ClockReadingData {
|
||||
problems: ClockProblem[];
|
||||
timeMode: ClockReadingTimeMode;
|
||||
}
|
||||
|
||||
export const CLOCK_PROBLEM_COUNT = 12;
|
||||
|
||||
const MINUTES_BY_MODE: Record<
|
||||
Exclude<ClockReadingTimeMode, 'random'>,
|
||||
readonly number[]
|
||||
> = {
|
||||
'whole-hour': [0],
|
||||
'half-hour': [30],
|
||||
'quarter-hour': [15, 45],
|
||||
};
|
||||
|
||||
function pickMinute(timeMode: ClockReadingTimeMode): number {
|
||||
if (timeMode === 'random') {
|
||||
return Math.floor(Math.random() * 60);
|
||||
}
|
||||
const pool = MINUTES_BY_MODE[timeMode];
|
||||
return pool[Math.floor(Math.random() * pool.length)] ?? 0;
|
||||
}
|
||||
|
||||
/** 生成一页 12 道钟表题,同一页内时刻不重复 */
|
||||
export function generateClockProblems(
|
||||
timeMode: ClockReadingTimeMode,
|
||||
): ClockProblem[] {
|
||||
const used = new Set<string>();
|
||||
const problems: ClockProblem[] = [];
|
||||
let guard = 0;
|
||||
|
||||
while (problems.length < CLOCK_PROBLEM_COUNT && guard < 500) {
|
||||
guard += 1;
|
||||
const hour = Math.floor(Math.random() * 12) + 1;
|
||||
const minute = pickMinute(timeMode);
|
||||
const key = `${hour}:${minute}`;
|
||||
if (used.has(key)) continue;
|
||||
used.add(key);
|
||||
problems.push({ hour, minute });
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function buildClockReadingData(
|
||||
timeMode: ClockReadingTimeMode,
|
||||
): ClockReadingData {
|
||||
return {
|
||||
timeMode,
|
||||
problems: generateClockProblems(timeMode),
|
||||
};
|
||||
}
|
||||
|
||||
export const CLOCK_TIME_MODE_OPTIONS = [
|
||||
{ id: 'random' as const, label: '随机', subtitle: '0–59 分均可' },
|
||||
{ id: 'whole-hour' as const, label: '整点', subtitle: '如 3:00' },
|
||||
{ id: 'half-hour' as const, label: '半点', subtitle: '如 6:30' },
|
||||
{ id: 'quarter-hour' as const, label: '刻钟', subtitle: '如 2:15、7:45' },
|
||||
];
|
||||
Reference in New Issue
Block a user