From e788bc949c1f128403943d6d17f19e852f24cd1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 22:13:56 +0000 Subject: [PATCH 1/4] Adjust ring color gradient from black outer to yellow center Co-authored-by: TaylorHere --- archery/shared/layout.ts | 101 +++++++++++++++++++++++++++++++++------ archery/src/pdf.ts | 21 ++++---- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/archery/shared/layout.ts b/archery/shared/layout.ts index 05d58db..4aab39f 100644 --- a/archery/shared/layout.ts +++ b/archery/shared/layout.ts @@ -29,6 +29,9 @@ export interface ValidationResult { } const EPS = 1e-7; +const COLOR_RING_PALETTE = ['#000000', '#2563eb', '#dc2626', '#facc15'] as const; +const COLOR_RING_OUTER = '#000000'; +const COLOR_RING_CENTER = '#facc15'; export const PAGE_PRESETS_MM: Record, { width: number; height: number }> = { A4: { width: 210, height: 297 }, @@ -286,31 +289,101 @@ export function ringFillColor( 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'; + const safeRingCount = Math.max(1, Math.round(ringCount)); + const safeIndex = Math.min(safeRingCount - 1, Math.max(0, Math.round(ringIndexFromOuter))); + if (safeRingCount === 1) { + // 单环时中心与外环重合,优先保证中心为黄色。 + return COLOR_RING_CENTER; } - if (score >= 5) { - return '#2563eb'; + if (safeIndex === 0) { + return COLOR_RING_OUTER; } - if (score >= 3) { - return '#000000'; + if (safeIndex === safeRingCount - 1) { + return COLOR_RING_CENTER; } - return '#ffffff'; + const t = safeIndex / (safeRingCount - 1); + return interpolatePaletteColor(t, COLOR_RING_PALETTE); } export function ringScoreTextColor(fillHex: string): '#000000' | '#FFFFFF' { - const hex = fillHex.toLowerCase(); - if (hex === '#000000' || hex === '#2563eb' || hex === '#dc2626') { + const rgb = parseHexRgb(fillHex); + if (!rgb) { + return '#000000'; + } + const luminance = relativeLuminance(rgb.r, rgb.g, rgb.b); + if (luminance < 0.45) { return '#FFFFFF'; } return '#000000'; } +function interpolatePaletteColor(t: number, palette: readonly string[]): string { + if (palette.length === 0) { + return '#000000'; + } + if (palette.length === 1) { + return palette[0]; + } + + const clamped = Math.min(1, Math.max(0, t)); + const scaled = clamped * (palette.length - 1); + const leftIndex = Math.floor(scaled); + const rightIndex = Math.min(palette.length - 1, leftIndex + 1); + if (leftIndex === rightIndex) { + return palette[leftIndex]; + } + + const left = parseHexRgb(palette[leftIndex]); + const right = parseHexRgb(palette[rightIndex]); + if (!left || !right) { + return palette[leftIndex]; + } + + const localT = scaled - leftIndex; + const r = Math.round(left.r + (right.r - left.r) * localT); + const g = Math.round(left.g + (right.g - left.g) * localT); + const b = Math.round(left.b + (right.b - left.b) * localT); + return rgbToHex(r, g, b); +} + +function parseHexRgb(hex: string): { r: number; g: number; b: number } | null { + const normalized = hex.trim().toLowerCase(); + const matched = /^#([0-9a-f]{6})$/.exec(normalized); + if (!matched) { + return null; + } + const raw = matched[1]; + return { + r: Number.parseInt(raw.slice(0, 2), 16), + g: Number.parseInt(raw.slice(2, 4), 16), + b: Number.parseInt(raw.slice(4, 6), 16), + }; +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`; +} + +function toHexByte(value: number): string { + const clamped = Math.min(255, Math.max(0, Math.round(value))); + return clamped.toString(16).padStart(2, '0'); +} + +function relativeLuminance(r: number, g: number, b: number): number { + const toLinear = (channel: number): number => { + const normalized = channel / 255; + if (normalized <= 0.04045) { + return normalized / 12.92; + } + return ((normalized + 0.055) / 1.055) ** 2.4; + }; + + const rl = toLinear(r); + const gl = toLinear(g); + const bl = toLinear(b); + return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl; +} + function normalizeLayoutMode(mode?: LayoutMode): LayoutMode { return mode === 'target_count' ? 'target_count' : 'auto_fill'; } diff --git a/archery/src/pdf.ts b/archery/src/pdf.ts index cb14d46..3449410 100644 --- a/archery/src/pdf.ts +++ b/archery/src/pdf.ts @@ -97,18 +97,13 @@ export async function exportArcheryPdf(input: PdfRenderInput): Promise { 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); + const matched = /^#([0-9a-f]{6})$/.exec(normalized); + if (!matched) { + return rgb(0, 0, 0); } + const value = matched[1]; + const r = Number.parseInt(value.slice(0, 2), 16) / 255; + const g = Number.parseInt(value.slice(2, 4), 16) / 255; + const b = Number.parseInt(value.slice(4, 6), 16) / 255; + return rgb(r, g, b); } From 3b785b729afc86f8fd4a5cc49c75117876fec89b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 22:15:18 +0000 Subject: [PATCH 2/4] Use score-band palette mapping for color rings Co-authored-by: TaylorHere --- archery/shared/layout.ts | 62 ++++++++++++++-------------------------- 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/archery/shared/layout.ts b/archery/shared/layout.ts index 4aab39f..244ff2a 100644 --- a/archery/shared/layout.ts +++ b/archery/shared/layout.ts @@ -29,7 +29,6 @@ export interface ValidationResult { } const EPS = 1e-7; -const COLOR_RING_PALETTE = ['#000000', '#2563eb', '#dc2626', '#facc15'] as const; const COLOR_RING_OUTER = '#000000'; const COLOR_RING_CENTER = '#facc15'; @@ -301,8 +300,21 @@ export function ringFillColor( if (safeIndex === safeRingCount - 1) { return COLOR_RING_CENTER; } - const t = safeIndex / (safeRingCount - 1); - return interpolatePaletteColor(t, COLOR_RING_PALETTE); + + // 按“分数段 -> 箭靶色板”映射,不做插值: + // 9-10 黄,7-8 红,5-6 蓝,1-4 黑。 + const score = ringScoreByIndex(safeIndex, safeRingCount); + const normalizedScore10 = normalizeScoreToTen(score, safeRingCount); + if (normalizedScore10 >= 9) { + return '#facc15'; + } + if (normalizedScore10 >= 7) { + return '#dc2626'; + } + if (normalizedScore10 >= 5) { + return '#2563eb'; + } + return '#000000'; } export function ringScoreTextColor(fillHex: string): '#000000' | '#FFFFFF' { @@ -317,35 +329,6 @@ export function ringScoreTextColor(fillHex: string): '#000000' | '#FFFFFF' { return '#000000'; } -function interpolatePaletteColor(t: number, palette: readonly string[]): string { - if (palette.length === 0) { - return '#000000'; - } - if (palette.length === 1) { - return palette[0]; - } - - const clamped = Math.min(1, Math.max(0, t)); - const scaled = clamped * (palette.length - 1); - const leftIndex = Math.floor(scaled); - const rightIndex = Math.min(palette.length - 1, leftIndex + 1); - if (leftIndex === rightIndex) { - return palette[leftIndex]; - } - - const left = parseHexRgb(palette[leftIndex]); - const right = parseHexRgb(palette[rightIndex]); - if (!left || !right) { - return palette[leftIndex]; - } - - const localT = scaled - leftIndex; - const r = Math.round(left.r + (right.r - left.r) * localT); - const g = Math.round(left.g + (right.g - left.g) * localT); - const b = Math.round(left.b + (right.b - left.b) * localT); - return rgbToHex(r, g, b); -} - function parseHexRgb(hex: string): { r: number; g: number; b: number } | null { const normalized = hex.trim().toLowerCase(); const matched = /^#([0-9a-f]{6})$/.exec(normalized); @@ -360,15 +343,6 @@ function parseHexRgb(hex: string): { r: number; g: number; b: number } | null { }; } -function rgbToHex(r: number, g: number, b: number): string { - return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`; -} - -function toHexByte(value: number): string { - const clamped = Math.min(255, Math.max(0, Math.round(value))); - return clamped.toString(16).padStart(2, '0'); -} - function relativeLuminance(r: number, g: number, b: number): number { const toLinear = (channel: number): number => { const normalized = channel / 255; @@ -384,6 +358,12 @@ function relativeLuminance(r: number, g: number, b: number): number { return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl; } +function normalizeScoreToTen(score: number, ringCount: number): number { + const safeRingCount = Math.max(1, Math.round(ringCount)); + const safeScore = Math.min(safeRingCount, Math.max(1, Math.round(score))); + return Math.min(10, Math.max(1, Math.round((safeScore / safeRingCount) * 10))); +} + function normalizeLayoutMode(mode?: LayoutMode): LayoutMode { return mode === 'target_count' ? 'target_count' : 'auto_fill'; } From 12d87889b270fe92efed95eb8de984731f39c2d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 22:16:33 +0000 Subject: [PATCH 3/4] Map ring labels to fixed 1-10 score scale Co-authored-by: TaylorHere --- archery/shared/layout.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/archery/shared/layout.ts b/archery/shared/layout.ts index 244ff2a..c1cf281 100644 --- a/archery/shared/layout.ts +++ b/archery/shared/layout.ts @@ -275,7 +275,17 @@ export function ringColorByIndex(ringIndexFromOuter: number, startWithBlack: boo export function ringScoreByIndex(ringIndexFromOuter: number, ringCount: number): number { const safeRingCount = Math.max(1, Math.round(ringCount)); - return Math.min(safeRingCount, ringIndexFromOuter + 1); + const safeIndex = Math.min(safeRingCount - 1, Math.max(0, Math.round(ringIndexFromOuter))); + if (safeRingCount === 1) { + return 10; + } + if (safeIndex === 0) { + return 1; + } + if (safeIndex === safeRingCount - 1) { + return 10; + } + return Math.floor(1 + (safeIndex / (safeRingCount - 1)) * 9); } export function ringFillColor( @@ -304,14 +314,13 @@ export function ringFillColor( // 按“分数段 -> 箭靶色板”映射,不做插值: // 9-10 黄,7-8 红,5-6 蓝,1-4 黑。 const score = ringScoreByIndex(safeIndex, safeRingCount); - const normalizedScore10 = normalizeScoreToTen(score, safeRingCount); - if (normalizedScore10 >= 9) { + if (score >= 9) { return '#facc15'; } - if (normalizedScore10 >= 7) { + if (score >= 7) { return '#dc2626'; } - if (normalizedScore10 >= 5) { + if (score >= 5) { return '#2563eb'; } return '#000000'; @@ -358,12 +367,6 @@ function relativeLuminance(r: number, g: number, b: number): number { return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl; } -function normalizeScoreToTen(score: number, ringCount: number): number { - const safeRingCount = Math.max(1, Math.round(ringCount)); - const safeScore = Math.min(safeRingCount, Math.max(1, Math.round(score))); - return Math.min(10, Math.max(1, Math.round((safeScore / safeRingCount) * 10))); -} - function normalizeLayoutMode(mode?: LayoutMode): LayoutMode { return mode === 'target_count' ? 'target_count' : 'auto_fill'; } From 992b5b64987d01ca343b4a1f8d92647defb95f8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 17 Mar 2026 22:22:01 +0000 Subject: [PATCH 4/4] Add manual margin and target spacing mode Co-authored-by: TaylorHere --- archery/shared/layout.ts | 116 ++++++++++++++++++++++++++++++++++++--- archery/src/main.ts | 76 ++++++++++++++++++++++--- archery/src/pdf.ts | 2 +- 3 files changed, 177 insertions(+), 17 deletions(-) diff --git a/archery/shared/layout.ts b/archery/shared/layout.ts index c1cf281..b770bb2 100644 --- a/archery/shared/layout.ts +++ b/archery/shared/layout.ts @@ -1,12 +1,16 @@ export type PagePreset = 'A4' | 'A3' | 'custom'; export type TargetColorMode = 'bw' | 'color'; export type LayoutMode = 'auto_fill' | 'target_count'; +export type SpacingMode = 'auto_min' | 'manual'; export interface LayoutInput { pageWidthMm: number; pageHeightMm: number; targetDiameterMm: number; - minSpacingMm: number; + minSpacingMm?: number; + spacingMode?: SpacingMode; + manualMarginMm?: number; + manualTargetSpacingMm?: number; layoutMode?: LayoutMode; desiredTargets?: number; } @@ -15,6 +19,7 @@ export interface LayoutSolution { columns: number; rows: number; spacingMm: number; + marginMm: number; totalTargets: number; } @@ -42,7 +47,6 @@ export function validateInput(input: LayoutInput): ValidationResult { ['pageWidthMm', input.pageWidthMm], ['pageHeightMm', input.pageHeightMm], ['targetDiameterMm', input.targetDiameterMm], - ['minSpacingMm', input.minSpacingMm], ]; for (const [name, value] of values) { @@ -51,6 +55,23 @@ export function validateInput(input: LayoutInput): ValidationResult { } } + const spacingMode = normalizeSpacingMode(input.spacingMode); + if (spacingMode === 'manual') { + const manualMargin = Number(input.manualMarginMm); + const manualSpacing = Number(input.manualTargetSpacingMm); + if (!Number.isFinite(manualMargin) || manualMargin < 0) { + return { ok: false, reason: '手动模式下页边距必须是大于等于 0 的数字' }; + } + if (!Number.isFinite(manualSpacing) || manualSpacing < 0) { + return { ok: false, reason: '手动模式下靶间距必须是大于等于 0 的数字' }; + } + } else { + const minSpacingMm = Number(input.minSpacingMm); + if (!Number.isFinite(minSpacingMm) || minSpacingMm <= 0) { + return { ok: false, reason: '自动模式下 minSpacingMm 必须是大于 0 的数字' }; + } + } + const mode = normalizeLayoutMode(input.layoutMode); if (mode === 'target_count') { const desiredTargets = Number(input.desiredTargets); @@ -68,10 +89,15 @@ export function solveOptimalLayout(input: LayoutInput): LayoutSolution | null { return null; } + const spacingMode = normalizeSpacingMode(input.spacingMode); + if (spacingMode === 'manual') { + return solveManualSpacingLayout(input); + } + const W = input.pageWidthMm; const H = input.pageHeightMm; const D = input.targetDiameterMm; - const sMin = input.minSpacingMm; + const sMin = Number(input.minSpacingMm); const maxColumns = Math.floor((W - sMin) / (D + sMin)); const maxRows = Math.floor((H - sMin) / (D + sMin)); @@ -109,6 +135,7 @@ export function solveOptimalLayout(input: LayoutInput): LayoutSolution | null { columns: n, rows: m, spacingMm, + marginMm: spacingMm, totalTargets, }; @@ -134,10 +161,22 @@ export function diagnoseNoLayoutReason(input: LayoutInput): string { return validation.reason ?? '参数无效'; } + const spacingMode = normalizeSpacingMode(input.spacingMode); + if (spacingMode === 'manual') { + const marginMm = Number(input.manualMarginMm); + const D = input.targetDiameterMm; + const maxUsableWidth = input.pageWidthMm - 2 * marginMm; + const maxUsableHeight = input.pageHeightMm - 2 * marginMm; + if (maxUsableWidth < D - EPS || maxUsableHeight < D - EPS) { + return '手动页边距过大:当前页面可用区域不足以放下 1 个标靶。请减小页边距或直径。'; + } + return '当前手动页边距/靶间距组合无可行排布。请减小页边距、靶间距或标靶直径。'; + } + const W = input.pageWidthMm; const H = input.pageHeightMm; const D = input.targetDiameterMm; - const sMin = input.minSpacingMm; + const sMin = Number(input.minSpacingMm); // s > 0 时,必须满足 n·D < W 且 m·D < H。 const maxColumnsWithPositiveSpacing = Math.floor((W - EPS) / D); @@ -188,7 +227,12 @@ export function suggestNearbyFeasibleLayouts( minDiameterMm?: number; }, ): NearbyLayoutSuggestion[] { - const pageValidation: Array = [input.pageWidthMm, input.pageHeightMm, input.minSpacingMm]; + if (normalizeSpacingMode(input.spacingMode) === 'manual') { + return []; + } + + const minSpacingMm = Number(input.minSpacingMm); + const pageValidation: Array = [input.pageWidthMm, input.pageHeightMm, minSpacingMm]; for (const value of pageValidation) { if (!Number.isFinite(value) || value <= 0) { return []; @@ -199,7 +243,7 @@ export function suggestNearbyFeasibleLayouts( 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; + const maxDiameterMm = Math.min(input.pageWidthMm, input.pageHeightMm) - minSpacingMm - EPS; if (maxDiameterMm < minDiameterMm) { return []; } @@ -261,10 +305,11 @@ export function getTargetCenterMm( row: number, diameterMm: number, spacingMm: number, + marginMm = spacingMm, ): { x: number; y: number } { return { - x: spacingMm + diameterMm / 2 + col * (diameterMm + spacingMm), - y: spacingMm + diameterMm / 2 + row * (diameterMm + spacingMm), + x: marginMm + diameterMm / 2 + col * (diameterMm + spacingMm), + y: marginMm + diameterMm / 2 + row * (diameterMm + spacingMm), }; } @@ -371,6 +416,61 @@ function normalizeLayoutMode(mode?: LayoutMode): LayoutMode { return mode === 'target_count' ? 'target_count' : 'auto_fill'; } +function normalizeSpacingMode(mode?: SpacingMode): SpacingMode { + return mode === 'manual' ? 'manual' : 'auto_min'; +} + +function solveManualSpacingLayout(input: LayoutInput): LayoutSolution | null { + const W = input.pageWidthMm; + const H = input.pageHeightMm; + const D = input.targetDiameterMm; + const marginMm = Number(input.manualMarginMm); + const spacingMm = Number(input.manualTargetSpacingMm); + + const usableWidth = W - 2 * marginMm; + const usableHeight = H - 2 * marginMm; + if (usableWidth < D - EPS || usableHeight < D - EPS) { + return null; + } + + const maxColumns = Math.floor((usableWidth + spacingMm + EPS) / (D + spacingMm)); + const maxRows = Math.floor((usableHeight + spacingMm + EPS) / (D + spacingMm)); + if (maxColumns < 1 || maxRows < 1) { + return null; + } + + const candidates: LayoutSolution[] = []; + for (let n = 1; n <= maxColumns; n += 1) { + const usedWidth = n * D + (n - 1) * spacingMm; + if (usedWidth > usableWidth + EPS) { + continue; + } + for (let m = 1; m <= maxRows; m += 1) { + const usedHeight = m * D + (m - 1) * spacingMm; + if (usedHeight > usableHeight + EPS) { + continue; + } + candidates.push({ + columns: n, + rows: m, + spacingMm, + marginMm, + totalTargets: n * m, + }); + } + } + + 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); +} + function pickAutoFillBest(candidates: LayoutSolution[]): LayoutSolution { let best = candidates[0]; for (let i = 1; i < candidates.length; i += 1) { diff --git a/archery/src/main.ts b/archery/src/main.ts index 7df35ea..94550fb 100644 --- a/archery/src/main.ts +++ b/archery/src/main.ts @@ -13,6 +13,7 @@ import { type LayoutInput, type LayoutSolution, type PagePreset, + type SpacingMode, type TargetColorMode, } from '../shared/layout'; @@ -61,6 +62,21 @@ app.innerHTML = ` + + + + + +