diff --git a/archery/functions/api/layout.ts b/archery/functions/api/layout.ts index ca919be..67b32da 100644 --- a/archery/functions/api/layout.ts +++ b/archery/functions/api/layout.ts @@ -1,10 +1,12 @@ -import { solveOptimalLayout, validateInput, type LayoutInput } from '../../shared/layout'; +import { diagnoseNoLayoutReason, solveOptimalLayout, validateInput, type LayoutInput } from '../../shared/layout'; interface JsonPayload { pageWidthMm?: unknown; pageHeightMm?: unknown; targetDiameterMm?: unknown; minSpacingMm?: unknown; + layoutMode?: unknown; + desiredTargets?: unknown; } export const onRequestPost: PagesFunction = async (context) => { @@ -15,6 +17,8 @@ export const onRequestPost: PagesFunction = async (context) => { pageHeightMm: Number(payload.pageHeightMm), targetDiameterMm: Number(payload.targetDiameterMm), minSpacingMm: Number(payload.minSpacingMm), + layoutMode: payload.layoutMode === 'target_count' ? 'target_count' : 'auto_fill', + desiredTargets: payload.desiredTargets === undefined ? undefined : Number(payload.desiredTargets), }; const validation = validateInput(input); @@ -34,7 +38,7 @@ export const onRequestPost: PagesFunction = async (context) => { return json({ ok: false, layout: null, - reason: '当前参数不存在满足严格对称约束的 (n, m, s) 组合', + reason: diagnoseNoLayoutReason(input), }); } diff --git a/archery/shared/layout.ts b/archery/shared/layout.ts index e1132d2..f29c56d 100644 --- a/archery/shared/layout.ts +++ b/archery/shared/layout.ts @@ -1,10 +1,14 @@ export type PagePreset = 'A4' | 'A3' | 'custom'; +export type TargetColorMode = 'bw' | 'color'; +export type LayoutMode = 'auto_fill' | 'target_count'; export interface LayoutInput { pageWidthMm: number; pageHeightMm: number; targetDiameterMm: number; minSpacingMm: number; + layoutMode?: LayoutMode; + desiredTargets?: number; } export interface LayoutSolution { @@ -14,6 +18,11 @@ export interface LayoutSolution { totalTargets: number; } +export interface NearbyLayoutSuggestion { + diameterMm: number; + layout: LayoutSolution; +} + export interface ValidationResult { ok: boolean; reason?: string; @@ -40,6 +49,14 @@ export function validateInput(input: LayoutInput): ValidationResult { } } + const mode = normalizeLayoutMode(input.layoutMode); + if (mode === 'target_count') { + const desiredTargets = Number(input.desiredTargets); + if (!Number.isFinite(desiredTargets) || desiredTargets <= 0) { + return { ok: false, reason: '指定数量模式下 desiredTargets 必须是大于 0 的数字' }; + } + } + return { ok: true }; } @@ -60,7 +77,7 @@ export function solveOptimalLayout(input: LayoutInput): LayoutSolution | null { return null; } - let best: LayoutSolution | null = null; + const candidates: LayoutSolution[] = []; // 穷举整数列数 n、行数 m,严格约束同一个 s 同时满足宽高公式: // W = nD + (n+1)s @@ -93,25 +110,148 @@ export function solveOptimalLayout(input: LayoutInput): LayoutSolution | null { totalTargets, }; - if (!best) { - best = candidate; + candidates.push(candidate); + } + } + + if (candidates.length === 0) { + return null; + } + + const mode = normalizeLayoutMode(input.layoutMode); + if (mode === 'target_count') { + return pickClosestByDesiredTargets(candidates, Math.max(1, Math.round(Number(input.desiredTargets)))); + } + + return pickAutoFillBest(candidates); +} + +export function diagnoseNoLayoutReason(input: LayoutInput): string { + const validation = validateInput(input); + if (!validation.ok) { + return validation.reason ?? '参数无效'; + } + + const W = input.pageWidthMm; + const H = input.pageHeightMm; + const D = input.targetDiameterMm; + const sMin = input.minSpacingMm; + + // s > 0 时,必须满足 n·D < W 且 m·D < H。 + const maxColumnsWithPositiveSpacing = Math.floor((W - EPS) / D); + const maxRowsWithPositiveSpacing = Math.floor((H - EPS) / D); + if (maxColumnsWithPositiveSpacing < 1 || maxRowsWithPositiveSpacing < 1) { + return '标靶直径过大:页面至少有一个方向连 1 个标靶都无法留出正间距。'; + } + + let hasExactSymmetricPair = false; + let maxFeasibleSpacing = -Infinity; + + for (let n = 1; n <= maxColumnsWithPositiveSpacing; n += 1) { + for (let m = 1; m <= maxRowsWithPositiveSpacing; m += 1) { + const spacingFromWidth = (W - n * D) / (n + 1); + const spacingFromHeight = (H - m * D) / (m + 1); + + if (spacingFromWidth <= EPS || spacingFromHeight <= EPS) { + continue; + } + + if (Math.abs(spacingFromWidth - spacingFromHeight) > EPS) { continue; } - // 主目标:最大化数量。并列时优先更大间距,再优先更多列(使预览更“横向密集”)。 - if ( - candidate.totalTargets > best.totalTargets || - (candidate.totalTargets === best.totalTargets && candidate.spacingMm > best.spacingMm + EPS) || - (candidate.totalTargets === best.totalTargets && - Math.abs(candidate.spacingMm - best.spacingMm) <= EPS && - candidate.columns > best.columns) - ) { - best = candidate; + hasExactSymmetricPair = true; + const spacingMm = (spacingFromWidth + spacingFromHeight) / 2; + if (spacingMm > maxFeasibleSpacing) { + maxFeasibleSpacing = spacingMm; } } } - return best; + if (!hasExactSymmetricPair) { + return '当前页面尺寸与直径组合不存在“同一 s 同时满足宽高公式”的严格对称解(与 s_min 无关)。请优先调整直径 D 或页面尺寸。'; + } + + return `存在严格对称解,但可行间距上限约为 ${maxFeasibleSpacing.toFixed(4)} mm,小于当前 s_min=${sMin.toFixed( + 4, + )} mm。请减小 s_min。`; +} + +export function suggestNearbyFeasibleLayouts( + input: LayoutInput, + options?: { + limit?: number; + stepMm?: number; + maxDeltaMm?: number; + minDiameterMm?: number; + }, +): NearbyLayoutSuggestion[] { + const pageValidation: Array = [input.pageWidthMm, input.pageHeightMm, input.minSpacingMm]; + for (const value of pageValidation) { + if (!Number.isFinite(value) || value <= 0) { + return []; + } + } + + const limit = Math.max(1, Math.round(options?.limit ?? 3)); + const stepMm = Math.max(0.01, options?.stepMm ?? 0.1); + const maxDeltaMm = Math.max(stepMm, options?.maxDeltaMm ?? 20); + const minDiameterMm = Math.max(0.1, options?.minDiameterMm ?? 1); + const maxDiameterMm = Math.min(input.pageWidthMm, input.pageHeightMm) - input.minSpacingMm - EPS; + if (maxDiameterMm < minDiameterMm) { + return []; + } + + const baseDiameter = Number.isFinite(input.targetDiameterMm) ? input.targetDiameterMm : minDiameterMm; + const visited = new Set(); + const results: NearbyLayoutSuggestion[] = []; + + const tryDiameter = (diameterMm: number): void => { + const normalized = Number(diameterMm.toFixed(4)); + if (normalized < minDiameterMm - EPS || normalized > maxDiameterMm + EPS) { + return; + } + const key = normalized.toFixed(4); + if (visited.has(key)) { + return; + } + visited.add(key); + + const layout = solveOptimalLayout({ + ...input, + targetDiameterMm: normalized, + }); + if (layout) { + results.push({ diameterMm: normalized, layout }); + } + }; + + const stepCount = Math.ceil(maxDeltaMm / stepMm); + for (let i = 0; i <= stepCount && results.length < limit; i += 1) { + if (i === 0) { + tryDiameter(baseDiameter); + continue; + } + + const delta = i * stepMm; + tryDiameter(baseDiameter - delta); + if (results.length >= limit) { + break; + } + tryDiameter(baseDiameter + delta); + } + + return results.sort((a, b) => { + const deltaA = Math.abs(a.diameterMm - baseDiameter); + const deltaB = Math.abs(b.diameterMm - baseDiameter); + if (Math.abs(deltaA - deltaB) > EPS) { + return deltaA - deltaB; + } + if (a.layout.totalTargets !== b.layout.totalTargets) { + return b.layout.totalTargets - a.layout.totalTargets; + } + return b.layout.spacingMm - a.layout.spacingMm; + }); } export function getTargetCenterMm( @@ -130,3 +270,100 @@ export function ringColorByIndex(ringIndexFromOuter: number, startWithBlack: boo const isBlack = startWithBlack ? ringIndexFromOuter % 2 === 0 : ringIndexFromOuter % 2 !== 0; return isBlack ? '#000000' : '#FFFFFF'; } + +export function ringScoreByIndex(ringIndexFromOuter: number, ringCount: number): number { + const safeRingCount = Math.max(1, Math.round(ringCount)); + return safeRingCount - ringIndexFromOuter; +} + +export function ringFillColor( + ringIndexFromOuter: number, + ringCount: number, + colorMode: TargetColorMode, + startWithBlack: boolean, +): string { + if (colorMode === 'bw') { + return ringColorByIndex(ringIndexFromOuter, startWithBlack); + } + + // 经典环靶配色(按分值分段):1-2 白,3-4 黑,5-6 蓝,7-8 红,9+ 黄 + const score = ringScoreByIndex(ringIndexFromOuter, ringCount); + if (score >= 9) { + return '#facc15'; + } + if (score >= 7) { + return '#dc2626'; + } + if (score >= 5) { + return '#2563eb'; + } + if (score >= 3) { + return '#000000'; + } + return '#ffffff'; +} + +export function ringScoreTextColor(fillHex: string): '#000000' | '#FFFFFF' { + const hex = fillHex.toLowerCase(); + if (hex === '#000000' || hex === '#2563eb' || hex === '#dc2626') { + return '#FFFFFF'; + } + return '#000000'; +} + +function normalizeLayoutMode(mode?: LayoutMode): LayoutMode { + return mode === 'target_count' ? 'target_count' : 'auto_fill'; +} + +function pickAutoFillBest(candidates: LayoutSolution[]): LayoutSolution { + let best = candidates[0]; + for (let i = 1; i < candidates.length; i += 1) { + const candidate = candidates[i]; + if ( + candidate.totalTargets > best.totalTargets || + (candidate.totalTargets === best.totalTargets && candidate.spacingMm > best.spacingMm + EPS) || + (candidate.totalTargets === best.totalTargets && + Math.abs(candidate.spacingMm - best.spacingMm) <= EPS && + candidate.columns > best.columns) + ) { + best = candidate; + } + } + return best; +} + +function pickClosestByDesiredTargets(candidates: LayoutSolution[], desiredTargets: number): LayoutSolution { + let best = candidates[0]; + for (let i = 1; i < candidates.length; i += 1) { + const candidate = candidates[i]; + const candidateDiff = Math.abs(candidate.totalTargets - desiredTargets); + const bestDiff = Math.abs(best.totalTargets - desiredTargets); + if (candidateDiff < bestDiff - EPS) { + best = candidate; + continue; + } + if (Math.abs(candidateDiff - bestDiff) > EPS) { + continue; + } + + // 差距相同,优先不低于目标数量。 + const candidateMeetsTarget = candidate.totalTargets >= desiredTargets; + const bestMeetsTarget = best.totalTargets >= desiredTargets; + if (candidateMeetsTarget && !bestMeetsTarget) { + best = candidate; + continue; + } + if (!candidateMeetsTarget && bestMeetsTarget) { + continue; + } + + // 再并列时优先更大间距和更多列。 + if ( + candidate.spacingMm > best.spacingMm + EPS || + (Math.abs(candidate.spacingMm - best.spacingMm) <= EPS && candidate.columns > best.columns) + ) { + best = candidate; + } + } + return best; +} diff --git a/archery/src/main.ts b/archery/src/main.ts index 5682a07..8097641 100644 --- a/archery/src/main.ts +++ b/archery/src/main.ts @@ -1,17 +1,24 @@ import './style.css'; import { exportArcheryPdf } from './pdf'; import { + diagnoseNoLayoutReason, + type LayoutMode, PAGE_PRESETS_MM, getTargetCenterMm, - ringColorByIndex, + ringFillColor, + ringScoreByIndex, + ringScoreTextColor, solveOptimalLayout, + suggestNearbyFeasibleLayouts, type LayoutInput, type LayoutSolution, type PagePreset, + type TargetColorMode, } from '../shared/layout'; const PX_PER_MM = 96 / 25.4; const DEFAULT_PAGE_PRESET: PagePreset = 'A4'; +const EPS = 1e-7; const app = document.querySelector('#app'); if (!app) { @@ -46,7 +53,7 @@ app.innerHTML = ` + + + + + + + +
@@ -97,8 +130,12 @@ const refs = { customHeight: getEl('custom-height'), diameter: getEl('diameter'), minSpacing: getEl('min-spacing'), + layoutMode: getEl('layout-mode'), + desiredTargets: getEl('desired-targets'), ringCount: getEl('ring-count'), checkerboard: getEl('checkerboard'), + targetColorMode: getEl('target-color-mode'), + showRingScores: getEl('show-ring-scores'), zoom: getEl('zoom'), zoomLabel: getEl('zoom-label'), previewStage: getEl('preview-stage'), @@ -114,25 +151,42 @@ const refs = { let currentLayout: LayoutSolution | null = null; refs.pagePreset.value = DEFAULT_PAGE_PRESET; +refs.layoutMode.value = 'auto_fill'; +refs.targetColorMode.value = 'bw'; toggleCustomSizeInputs(); +toggleDesiredTargetsInput(); +toggleCheckerboardAvailability(); recalculate(); +type EditedField = 'page' | 'customWidth' | 'customHeight' | 'diameter' | 'minSpacing' | 'other'; + for (const el of [ refs.pagePreset, refs.customWidth, refs.customHeight, refs.diameter, refs.minSpacing, + refs.layoutMode, + refs.desiredTargets, refs.ringCount, refs.checkerboard, + refs.targetColorMode, + refs.showRingScores, refs.zoom, ]) { el.addEventListener('input', () => { + const editedField = resolveEditedField(el); if (el === refs.pagePreset) { applyPresetToCustomInputs(); toggleCustomSizeInputs(); } - recalculate(); + if (el === refs.targetColorMode) { + toggleCheckerboardAvailability(); + } + if (el === refs.layoutMode) { + toggleDesiredTargetsInput(); + } + recalculate(editedField); }); } @@ -149,6 +203,8 @@ refs.downloadBtn.addEventListener('click', async () => { diameterMm: getPositiveNumber(refs.diameter), ringCount: getPositiveInteger(refs.ringCount), checkerboardEnabled: refs.checkerboard.checked, + colorMode: getTargetColorMode(), + showRingScores: refs.showRingScores.checked, layout: currentLayout, }); refs.status.textContent = 'PDF 已生成:矢量图可直接打印,请使用 100% 缩放。'; @@ -182,11 +238,22 @@ refs.verifyApiBtn.addEventListener('click', async () => { } }); -function recalculate(): void { - const pageSize = getPageSize(); - const input = buildLayoutInput(); - const layout = solveOptimalLayout(input); +function recalculate(editedField: EditedField = 'other'): void { + let input = buildLayoutInput(); + let layout = solveOptimalLayout(input); + let autoAdjustMessage = ''; + + if (!layout && editedField !== 'other') { + const adjusted = autoAdjustToFeasible(input, editedField); + if (adjusted) { + autoAdjustMessage = adjusted; + input = buildLayoutInput(); + layout = solveOptimalLayout(input); + } + } + currentLayout = layout; + const pageSize = getPageSize(); refs.zoomLabel.textContent = `${Math.round(getPositiveNumber(refs.zoom))}%`; refs.outPage.textContent = `${pageSize.widthMm.toFixed(1)} × ${pageSize.heightMm.toFixed(1)} mm`; @@ -196,7 +263,24 @@ function recalculate(): void { refs.outM.textContent = '-'; refs.outS.textContent = '-'; refs.outTotal.textContent = '0'; - refs.status.textContent = '当前参数无可行排布。可尝试减小 D、减小 s_min 或增大页面尺寸。'; + const reason = diagnoseNoLayoutReason(input); + const suggestions = suggestNearbyFeasibleLayouts(input, { + limit: 3, + stepMm: 0.1, + maxDeltaMm: 20, + minDiameterMm: 1, + }); + if (suggestions.length > 0) { + const formatted = suggestions + .map( + (item, idx) => + `${idx + 1}) D=${item.diameterMm.toFixed(1)}mm(n=${item.layout.columns}, m=${item.layout.rows}, s=${item.layout.spacingMm.toFixed(2)}mm)`, + ) + .join(';'); + refs.status.textContent = `${reason} 附近可行参数建议:${formatted}`; + } else { + refs.status.textContent = `${reason} 附近 ±20mm 未找到可行直径,建议切换页面尺寸后重试。`; + } renderSvgPreview(null, input); return; } @@ -205,10 +289,151 @@ function recalculate(): void { refs.outM.textContent = `${layout.rows}`; refs.outS.textContent = layout.spacingMm.toFixed(4); refs.outTotal.textContent = `${layout.totalTargets}`; - refs.status.textContent = '已完成本地最优排布计算。'; + const modeStatus = getLayoutModeStatus(layout); + refs.status.textContent = autoAdjustMessage + ? `${autoAdjustMessage} 已自动完成可行排布。${modeStatus}` + : `已完成本地最优排布计算。${modeStatus}`; renderSvgPreview(layout, input); } +function autoAdjustToFeasible(input: LayoutInput, editedField: EditedField): string | null { + if (editedField !== 'minSpacing') { + const maxSpacing = getMaxStrictSpacingForCurrentDiameter(input); + if (maxSpacing !== null && maxSpacing + EPS < input.minSpacingMm) { + const newMinSpacing = Math.max(0.1, Number(maxSpacing.toFixed(4))); + refs.minSpacing.value = `${newMinSpacing}`; + return `已自动将 s_min 调整为 ${newMinSpacing.toFixed(4)} mm。`; + } + } + + if (editedField !== 'diameter') { + const [nearest] = suggestNearbyFeasibleLayouts(input, { + limit: 1, + stepMm: 0.1, + maxDeltaMm: 40, + minDiameterMm: 1, + }); + if (nearest) { + refs.diameter.value = `${nearest.diameterMm.toFixed(1)}`; + return `已自动将 D 调整为 ${nearest.diameterMm.toFixed(1)} mm。`; + } + } + + const customPage = suggestNearestCustomPageForFixedDiameter(input); + if (!customPage) { + return null; + } + + refs.pagePreset.value = 'custom'; + refs.customWidth.value = `${customPage.widthMm.toFixed(1)}`; + refs.customHeight.value = `${customPage.heightMm.toFixed(1)}`; + toggleCustomSizeInputs(); + + return `已自动切换为自定义页面 ${customPage.widthMm.toFixed(1)} × ${customPage.heightMm.toFixed(1)} mm。`; +} + +function getMaxStrictSpacingForCurrentDiameter(input: LayoutInput): number | null { + const W = input.pageWidthMm; + const H = input.pageHeightMm; + const D = input.targetDiameterMm; + if (!Number.isFinite(W) || !Number.isFinite(H) || !Number.isFinite(D) || W <= 0 || H <= 0 || D <= 0) { + return null; + } + + const maxColumns = Math.floor((W - EPS) / D); + const maxRows = Math.floor((H - EPS) / D); + if (maxColumns < 1 || maxRows < 1) { + return null; + } + + let maxSpacing = -Infinity; + for (let n = 1; n <= maxColumns; n += 1) { + for (let m = 1; m <= maxRows; m += 1) { + const spacingFromWidth = (W - n * D) / (n + 1); + const spacingFromHeight = (H - m * D) / (m + 1); + if (spacingFromWidth <= EPS || spacingFromHeight <= EPS) { + continue; + } + if (Math.abs(spacingFromWidth - spacingFromHeight) > EPS) { + continue; + } + const spacingMm = (spacingFromWidth + spacingFromHeight) / 2; + if (spacingMm > maxSpacing) { + maxSpacing = spacingMm; + } + } + } + + return Number.isFinite(maxSpacing) ? maxSpacing : null; +} + +function suggestNearestCustomPageForFixedDiameter( + input: LayoutInput, +): { widthMm: number; heightMm: number } | null { + const W = input.pageWidthMm; + const H = input.pageHeightMm; + const D = input.targetDiameterMm; + const s = input.minSpacingMm; + if (!Number.isFinite(W) || !Number.isFinite(H) || !Number.isFinite(D) || !Number.isFinite(s)) { + return null; + } + if (W <= 0 || H <= 0 || D <= 0 || s <= 0) { + return null; + } + + const nApprox = Math.max(1, Math.round((W - s) / (D + s))); + const mApprox = Math.max(1, Math.round((H - s) / (D + s))); + const nMin = Math.max(1, nApprox - 8); + const nMax = nApprox + 8; + const mMin = Math.max(1, mApprox - 8); + const mMax = mApprox + 8; + + let best: { widthMm: number; heightMm: number; score: number; total: number } | null = null; + for (let n = nMin; n <= nMax; n += 1) { + for (let m = mMin; m <= mMax; m += 1) { + const widthMm = n * D + (n + 1) * s; + const heightMm = m * D + (m + 1) * s; + if (widthMm <= 0 || heightMm <= 0) { + continue; + } + + const score = Math.abs(widthMm - W) + Math.abs(heightMm - H); + const total = n * m; + if ( + !best || + score < best.score - EPS || + (Math.abs(score - best.score) <= EPS && total > best.total) + ) { + best = { widthMm, heightMm, score, total }; + } + } + } + + if (!best) { + return null; + } + return { widthMm: best.widthMm, heightMm: best.heightMm }; +} + +function resolveEditedField(el: HTMLElement): EditedField { + if (el === refs.pagePreset) { + return 'page'; + } + if (el === refs.customWidth) { + return 'customWidth'; + } + if (el === refs.customHeight) { + return 'customHeight'; + } + if (el === refs.diameter) { + return 'diameter'; + } + if (el === refs.minSpacing) { + return 'minSpacing'; + } + return 'other'; +} + function renderSvgPreview(layout: LayoutSolution | null, input: LayoutInput): void { const zoom = getPositiveNumber(refs.zoom) / 100; const widthPx = input.pageWidthMm * PX_PER_MM * zoom; @@ -224,8 +449,11 @@ function renderSvgPreview(layout: LayoutSolution | null, input: LayoutInput): vo } const ringCount = getPositiveInteger(refs.ringCount); - const checkerboardEnabled = refs.checkerboard.checked; + const colorMode = getTargetColorMode(); + const checkerboardEnabled = colorMode === 'bw' && refs.checkerboard.checked; + const showRingScores = refs.showRingScores.checked; const circles: string[] = []; + const scoreLabels: string[] = []; for (let row = 0; row < layout.rows; row += 1) { for (let col = 0; col < layout.columns; col += 1) { @@ -233,11 +461,30 @@ function renderSvgPreview(layout: LayoutSolution | null, input: LayoutInput): vo const startWithBlack = checkerboardEnabled ? (row + col) % 2 === 0 : true; for (let ringIndex = 0; ringIndex < ringCount; ringIndex += 1) { - const radiusMm = (input.targetDiameterMm / 2) * ((ringCount - ringIndex) / ringCount); - const fill = ringColorByIndex(ringIndex, startWithBlack); - const outerStroke = ringIndex === 0 ? ' stroke="#000" stroke-width="0.15"' : ''; + const outerRadiusMm = (input.targetDiameterMm / 2) * ((ringCount - ringIndex) / ringCount); + const innerRadiusMm = + ringIndex === ringCount - 1 ? 0 : (input.targetDiameterMm / 2) * ((ringCount - ringIndex - 1) / ringCount); + const fill = ringFillColor(ringIndex, ringCount, colorMode, startWithBlack); circles.push( - ``, + ``, + ); + + if (!showRingScores) { + continue; + } + + const bandThicknessMm = outerRadiusMm - innerRadiusMm; + if (bandThicknessMm < 0.9) { + continue; + } + + const score = ringScoreByIndex(ringIndex, ringCount); + const labelRadiusMm = (outerRadiusMm + innerRadiusMm) / 2; + const fontSizeMm = Math.min(3.2, Math.max(1.2, bandThicknessMm * 0.9)); + const labelY = center.y - labelRadiusMm + fontSizeMm * 0.35; + const textColor = ringScoreTextColor(fill); + scoreLabels.push( + `${score}`, ); } } @@ -259,6 +506,7 @@ function renderSvgPreview(layout: LayoutSolution | null, input: LayoutInput): vo > ${circles.join('')} + ${scoreLabels.join('')} @@ -282,11 +530,14 @@ function getPageSize(): { widthMm: number; heightMm: number } { function buildLayoutInput(): LayoutInput { const page = getPageSize(); + const layoutMode = getLayoutMode(); return { pageWidthMm: page.widthMm, pageHeightMm: page.heightMm, targetDiameterMm: getPositiveNumber(refs.diameter), minSpacingMm: getPositiveNumber(refs.minSpacing), + layoutMode, + desiredTargets: layoutMode === 'target_count' ? getPositiveInteger(refs.desiredTargets) : undefined, }; } @@ -296,6 +547,16 @@ function toggleCustomSizeInputs(): void { refs.customHeight.disabled = !isCustom; } +function toggleDesiredTargetsInput(): void { + const isTargetCountMode = getLayoutMode() === 'target_count'; + refs.desiredTargets.disabled = !isTargetCountMode; +} + +function toggleCheckerboardAvailability(): void { + const isColorMode = getTargetColorMode() === 'color'; + refs.checkerboard.disabled = isColorMode; +} + function applyPresetToCustomInputs(): void { const preset = refs.pagePreset.value as PagePreset; if (preset === 'custom') { @@ -318,6 +579,26 @@ function getPositiveInteger(input: HTMLInputElement): number { return Math.max(1, Math.round(getPositiveNumber(input))); } +function getTargetColorMode(): TargetColorMode { + return refs.targetColorMode.value === 'color' ? 'color' : 'bw'; +} + +function getLayoutMode(): LayoutMode { + return refs.layoutMode.value === 'target_count' ? 'target_count' : 'auto_fill'; +} + +function getLayoutModeStatus(layout: LayoutSolution): string { + if (getLayoutMode() !== 'target_count') { + return ''; + } + + const desired = getPositiveInteger(refs.desiredTargets); + if (layout.totalTargets === desired) { + return `已满足指定数量 ${desired}。`; + } + return `指定数量=${desired},当前可行最接近数量=${layout.totalTargets}。`; +} + function getEl(id: string): T { const el = document.getElementById(id); if (!el) { diff --git a/archery/src/pdf.ts b/archery/src/pdf.ts index c12f633..cb14d46 100644 --- a/archery/src/pdf.ts +++ b/archery/src/pdf.ts @@ -1,5 +1,12 @@ import { PDFDocument, rgb } from 'pdf-lib'; -import { getTargetCenterMm, ringColorByIndex, type LayoutSolution } from '../shared/layout'; +import { + getTargetCenterMm, + ringFillColor, + ringScoreByIndex, + ringScoreTextColor, + type LayoutSolution, + type TargetColorMode, +} from '../shared/layout'; export interface PdfRenderInput { pageWidthMm: number; @@ -7,6 +14,8 @@ export interface PdfRenderInput { diameterMm: number; ringCount: number; checkerboardEnabled: boolean; + colorMode: TargetColorMode; + showRingScores: boolean; layout: LayoutSolution; } @@ -40,20 +49,42 @@ export async function exportArcheryPdf(input: PdfRenderInput): Promise { const centerXPt = mmToPt(center.x); const centerYPt = pageHeightPt - mmToPt(center.y); - const startWithBlack = input.checkerboardEnabled ? (row + col) % 2 === 0 : true; + const checkerboardEnabled = input.colorMode === 'bw' && input.checkerboardEnabled; + const startWithBlack = checkerboardEnabled ? (row + col) % 2 === 0 : true; for (let ringIndex = 0; ringIndex < input.ringCount; ringIndex += 1) { - const radiusMm = (input.diameterMm / 2) * ((input.ringCount - ringIndex) / input.ringCount); - const colorHex = ringColorByIndex(ringIndex, startWithBlack); - const isBlack = colorHex === '#000000'; - const color = isBlack ? rgb(0, 0, 0) : rgb(1, 1, 1); + const outerRadiusMm = (input.diameterMm / 2) * ((input.ringCount - ringIndex) / input.ringCount); + const innerRadiusMm = + ringIndex === input.ringCount - 1 ? 0 : (input.diameterMm / 2) * ((input.ringCount - ringIndex - 1) / input.ringCount); + const colorHex = ringFillColor(ringIndex, input.ringCount, input.colorMode, startWithBlack); + const color = hexToRgbColor(colorHex); page.drawCircle({ x: centerXPt, y: centerYPt, - size: mmToPt(radiusMm), + size: mmToPt(outerRadiusMm), color, - borderColor: ringIndex === 0 ? rgb(0, 0, 0) : undefined, - borderWidth: ringIndex === 0 ? mmToPt(0.15) : 0, + borderColor: rgb(0, 0, 0), + borderWidth: mmToPt(0.1), + }); + + if (!input.showRingScores) { + continue; + } + + const bandThicknessMm = outerRadiusMm - innerRadiusMm; + if (bandThicknessMm < 0.9) { + continue; + } + + const score = ringScoreByIndex(ringIndex, input.ringCount); + const labelRadiusMm = (outerRadiusMm + innerRadiusMm) / 2; + const fontSizeMm = Math.min(3.2, Math.max(1.2, bandThicknessMm * 0.9)); + const labelColorHex = ringScoreTextColor(colorHex); + page.drawText(`${score}`, { + x: centerXPt - mmToPt(fontSizeMm * 0.35), + y: centerYPt + mmToPt(labelRadiusMm) - mmToPt(fontSizeMm * 0.35), + size: mmToPt(fontSizeMm), + color: hexToRgbColor(labelColorHex), }); } } @@ -63,3 +94,21 @@ export async function exportArcheryPdf(input: PdfRenderInput): Promise { const fileName = `archery-${input.pageWidthMm}x${input.pageHeightMm}-${input.diameterMm}mm.pdf`; triggerBrowserDownload(bytes, fileName); } + +function hexToRgbColor(hex: string): ReturnType { + const normalized = hex.trim().toLowerCase(); + switch (normalized) { + case '#000000': + return rgb(0, 0, 0); + case '#ffffff': + return rgb(1, 1, 1); + case '#2563eb': + return rgb(37 / 255, 99 / 255, 235 / 255); + case '#dc2626': + return rgb(220 / 255, 38 / 255, 38 / 255); + case '#facc15': + return rgb(250 / 255, 204 / 255, 21 / 255); + default: + return rgb(0, 0, 0); + } +}