diff --git a/.gitignore b/.gitignore index c24f723..312ab6c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ yarn-error.log* /outputs/ /work/ /mard-source.html +/data/ diff --git a/app/globals.css b/app/globals.css index 18d9ea5..2708911 100644 --- a/app/globals.css +++ b/app/globals.css @@ -76,7 +76,7 @@ input[type="range"] { accent-color: var(--green); } .zoom-control button { width: 36px; height: 36px; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 20px; } .zoom-control input { width: 95px; } .zoom-control output { width: 38px; color: var(--muted); font-size: 11px; text-align: right; } -.canvas-viewport { position: relative; width: 100%; height: clamp(520px, 68vh, 760px); min-height: 0; flex: 0 0 clamp(520px, 68vh, 760px); contain: size layout; overflow: hidden; } +.canvas-viewport { position: relative; width: min(100%, 82vh, 1000px); aspect-ratio: 1 / 1; height: auto; min-height: 0; flex: 0 0 auto; align-self: center; contain: size layout; overflow: hidden; } .canvas-stage { position: absolute; inset: 0; width: auto; height: auto; overflow: scroll; display: block; padding: 36px; cursor: grab; touch-action: none; overscroll-behavior: contain; user-select: none; scrollbar-gutter: stable; } .canvas-stage.is-dragging { cursor: grabbing; } .canvas-wrap { width: max-content; line-height: 0; position: relative; margin: auto; border: 1px solid var(--ink); box-shadow: 10px 10px 0 rgba(32,39,36,.13); background: white; } @@ -161,7 +161,7 @@ input[type="range"] { accent-color: var(--green); } .pattern-toolbar { align-items: flex-start; flex-direction: column; padding: 20px; } .zoom-control { width: 100%; } .zoom-control input { flex: 1; } - .canvas-viewport { height: max(360px, min(68vh, 620px)); min-height: 0; flex-basis: max(360px, min(68vh, 620px)); } + .canvas-viewport { width: min(100%, 82vh); height: auto; min-height: 0; aspect-ratio: 1 / 1; flex-basis: auto; } .canvas-stage { display: block; padding: 24px; } .canvas-wrap { margin: auto; } .pattern-footer { align-items: flex-start; flex-wrap: wrap; padding: 14px 18px; } diff --git a/app/page.tsx b/app/page.tsx index 585b320..b7fed8d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -28,8 +28,17 @@ type ColorCluster = { type SamplingStrategy = "smooth" | "dominant"; +function logUsage(event: Record) { + void fetch("/api/usage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + keepalive: true, + }).catch(() => undefined); +} + type Pixel = { - color: BeadColor; + color: BeadColor | null; }; type HoveredPixel = { @@ -47,9 +56,11 @@ type SavedPattern = { width: number; height: number; colorLimit: number; - codes: string[]; + codes: Array; }; +const TRANSPARENT_ALPHA_THRESHOLD = 128; + const MARD_SERIES_NAMES: Record = { A: "黄橙系", B: "绿色系", C: "蓝青系", D: "紫蓝系", E: "粉红系", F: "红色系", G: "肤棕系", H: "黑白灰系", M: "莫兰迪系", @@ -102,9 +113,13 @@ function nearestColor(lab: Oklab, palette: BeadColor[]) { } function mergePerceptualColors(data: Uint8ClampedArray) { - const pixelKeys: number[] = []; + const pixelKeys: Array = []; const histogram = new Map(); for (let index = 0; index < data.length; index += 4) { + if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) { + pixelKeys.push(null); + continue; + } const red = data[index]; const green = data[index + 1]; const blue = data[index + 2]; @@ -187,7 +202,7 @@ function chooseDistinctMardColors(clusters: ColorCluster[], maximum: number) { separated.push(candidate.color); } } - return separated.slice(0, Math.max(1, Math.min(maximum, separated.length))); + return separated.slice(0, Math.max(0, Math.min(maximum, separated.length))); } function sampleDominantRegions( @@ -204,8 +219,7 @@ function sampleDominantRegions( sample.width = width * scale; sample.height = height * scale; const ctx = sample.getContext("2d", { willReadFrequently: true })!; - ctx.fillStyle = "#f7f5ed"; - ctx.fillRect(0, 0, sample.width, sample.height); + ctx.clearRect(0, 0, sample.width, sample.height); drawFittedImage(ctx, image, sample.width, sample.height, fitMode, cropZoom, cropX, cropY); const source = ctx.getImageData(0, 0, sample.width, sample.height).data; const result = new Uint8ClampedArray(width * height * 4); @@ -215,9 +229,14 @@ function sampleDominantRegions( for (let row = 0; row < height; row++) { for (let column = 0; column < width; column++) { const groups: Array<{ count: number; lab: Oklab; samples: Array<{ rgb: [number, number, number]; lab: Oklab }> }> = []; + let transparentCount = 0; for (let offsetY = 0; offsetY < scale; offsetY++) { for (let offsetX = 0; offsetX < scale; offsetX++) { const sourceIndex = ((row * scale + offsetY) * sample.width + column * scale + offsetX) * 4; + if (source[sourceIndex + 3] < TRANSPARENT_ALPHA_THRESHOLD) { + transparentCount += 1; + continue; + } const rgb: [number, number, number] = [source[sourceIndex], source[sourceIndex + 1], source[sourceIndex + 2]]; const lab = rgbToOklab(rgb); let closest: (typeof groups)[number] | undefined; @@ -239,23 +258,28 @@ function sampleDominantRegions( } } } + const targetIndex = (row * width + column) * 4; + if (transparentCount >= scale * scale / 2 || groups.length === 0) { + result[targetIndex + 3] = 0; + confidence[row * width + column] = transparentCount / (scale * scale); + continue; + } const dominant = groups.sort((a, b) => b.count - a.count)[0]; const representative = dominant.samples.reduce((best, current) => oklabDistance(current.lab, dominant.lab) < oklabDistance(best.lab, dominant.lab) ? current : best, ); - const targetIndex = (row * width + column) * 4; result[targetIndex] = representative.rgb[0]; result[targetIndex + 1] = representative.rgb[1]; result[targetIndex + 2] = representative.rgb[2]; result[targetIndex + 3] = 255; - confidence[row * width + column] = dominant.count / (scale * scale); + confidence[row * width + column] = dominant.count / Math.max(1, scale * scale - transparentCount); } } return { data: result, confidence }; } function cleanLowConfidenceIsolatedColors( - colors: BeadColor[], + colors: Array, confidence: Float32Array, width: number, height: number, @@ -267,6 +291,7 @@ function cleanLowConfidenceIsolatedColors( for (let column = 0; column < width; column++) { const index = row * width + column; const current = colors[index]; + if (!current) continue; if (confidence[index] >= 0.625) continue; const chroma = Math.hypot(current.lab[1], current.lab[2]); const protectedDetail = current.lab[0] < 0.32 || current.lab[0] > 0.96 || chroma > 0.14; @@ -283,6 +308,7 @@ function cleanLowConfidenceIsolatedColors( if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue; const neighborIndex = neighborRow * width + neighborColumn; const neighbor = colors[neighborIndex]; + if (!neighbor) continue; if (neighbor.code === current.code) sameColorNeighbors += 1; const entry = neighborCounts.get(neighbor.code) ?? { color: neighbor, count: 0, confidentCount: 0 }; entry.count += 1; @@ -370,11 +396,11 @@ function makeDemoImage() { export default function Home() { const [sourceUrl, setSourceUrl] = useState(""); const [sourceName, setSourceName] = useState("示例:田野小屋"); - const [requestedWidth, setRequestedWidth] = useState(32); - const [requestedHeight, setRequestedHeight] = useState(32); - const [gridWidth, setGridWidth] = useState(32); - const [gridHeight, setGridHeight] = useState(32); - const [colorLimit, setColorLimit] = useState(18); + const [requestedWidth, setRequestedWidth] = useState(100); + const [requestedHeight, setRequestedHeight] = useState(100); + const [gridWidth, setGridWidth] = useState(100); + const [gridHeight, setGridHeight] = useState(100); + const [colorLimit, setColorLimit] = useState(64); const [fitMode, setFitMode] = useState<"cover" | "contain">("cover"); const [samplingStrategy, setSamplingStrategy] = useState("smooth"); const [cropZoom, setCropZoom] = useState(1); @@ -383,7 +409,7 @@ export default function Home() { const [pixels, setPixels] = useState([]); const [selectedCodes, setSelectedCodes] = useState>(new Set()); const [onlySelected, setOnlySelected] = useState(false); - const [zoom, setZoom] = useState(24); + const [zoom, setZoom] = useState(8); const [showGrid, setShowGrid] = useState(true); const [showCodes, setShowCodes] = useState(true); const [query, setQuery] = useState(""); @@ -409,6 +435,7 @@ export default function Home() { const suppressCanvasClickRef = useRef(false); useEffect(() => { + logUsage({ event: "page_view" }); const saved = localStorage.getItem("bead-pattern-history"); if (!saved) return; queueMicrotask(() => { @@ -419,6 +446,7 @@ export default function Home() { const convertImage = ( image = sourceImageRef.current, crop = { zoom: cropZoom, x: cropX, y: cropY }, + recordUsage = true, ) => { if (!image) return; const width = Math.max(8, Math.min(256, requestedWidth)); @@ -434,15 +462,14 @@ export default function Home() { sample.width = width; sample.height = height; const ctx = sample.getContext("2d", { willReadFrequently: true })!; - ctx.fillStyle = "#f7f5ed"; - ctx.fillRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height); drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y); data = ctx.getImageData(0, 0, width, height).data; } const { pixelKeys, clusters, binClusters } = mergePerceptualColors(data); const limitedPalette = chooseDistinctMardColors(clusters, Math.max(2, Math.min(colorLimit, PALETTE.length))); const clusterMatches = clusters.map((cluster) => nearestColor(cluster.lab, limitedPalette)); - let matchedColors = pixelKeys.map((key) => clusterMatches[binClusters.get(key) ?? 0]); + let matchedColors: Array = pixelKeys.map((key) => key === null ? null : clusterMatches[binClusters.get(key) ?? 0]); let cleanedCount = 0; if (dominantConfidence) { const cleaned = cleanLowConfidenceIsolatedColors(matchedColors, dominantConfidence, width, height); @@ -457,8 +484,21 @@ export default function Home() { setPixels(converted); setSelectedCodes(new Set()); const strategyName = samplingStrategy === "dominant" ? `区域主色 · 清理 ${cleanedCount} 个低可信孤立格` : "平滑取色"; - const actualColorCount = new Set(matchedColors.map((color) => color.code)).size; - setStatus(`已转换为 ${width} × ${height} · ${strategyName} · ${actualColorCount} 种差异色`); + const actualColorCount = new Set(matchedColors.filter((color): color is BeadColor => color !== null).map((color) => color.code)).size; + const actualBeadCount = matchedColors.filter((color) => color !== null).length; + if (recordUsage) { + logUsage({ + event: "conversion", + width, + height, + sampling_strategy: samplingStrategy, + color_limit: colorLimit, + actual_colors: actualColorCount, + bead_count: actualBeadCount, + transparent_cells: width * height - actualBeadCount, + }); + } + setStatus(`已转换为 ${width} × ${height} · ${actualBeadCount} 颗拼豆 · ${strategyName} · ${actualColorCount} 种差异色`); }; useEffect(() => { @@ -467,7 +507,7 @@ export default function Home() { image.onload = () => { sourceImageRef.current = image; setSourceUrl(demo); - convertImage(image); + convertImage(image, undefined, false); }; image.src = demo; // This intentionally runs once to create the initial example. @@ -493,12 +533,14 @@ export default function Home() { const usedColors = useMemo(() => { const counts = new Map(); - pixels.forEach(({ color }) => counts.set(color.code, (counts.get(color.code) ?? 0) + 1)); + pixels.forEach(({ color }) => { if (color) counts.set(color.code, (counts.get(color.code) ?? 0) + 1); }); return PALETTE.filter((color) => counts.has(color.code)) .map((color) => ({ ...color, count: counts.get(color.code)! })) .sort((a, b) => b.count - a.count); }, [pixels]); + const beadCount = useMemo(() => pixels.reduce((total, pixel) => total + (pixel.color ? 1 : 0), 0), [pixels]); + const filteredColors = useMemo(() => { const key = query.trim().toLowerCase(); return usedColors.filter((color) => !key || color.code.toLowerCase().includes(key) || color.name.includes(key) || color.hex.toLowerCase().includes(key)); @@ -541,6 +583,15 @@ export default function Home() { pixels.forEach(({ color }, index) => { const x = ruler + (index % gridWidth) * cell; const y = ruler + Math.floor(index / gridWidth) * cell; + if (!color) { + ctx.clearRect(x, y, cell, cell); + if (showGrid) { + ctx.strokeStyle = "rgba(32,38,36,.12)"; + ctx.lineWidth = 0.6; + ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6); + } + return; + } const hasSelection = selectedCodes.size > 0; const isSelected = selectedCodes.has(color.code); if (onlySelected && hasSelection && !isSelected) { @@ -565,6 +616,18 @@ export default function Home() { ctx.fillText(color.code, x + cell / 2, y + cell / 2); } }); + if (showGrid) { + ctx.strokeStyle = "rgba(32,38,36,.48)"; + ctx.lineWidth = 1.5; + for (let column = 5; column < gridWidth; column += 5) { + const x = ruler + column * cell; + ctx.beginPath(); ctx.moveTo(x, ruler); ctx.lineTo(x, ruler + gridHeight * cell); ctx.stroke(); + } + for (let row = 5; row < gridHeight; row += 5) { + const y = ruler + row * cell; + ctx.beginPath(); ctx.moveTo(ruler, y); ctx.lineTo(ruler + gridWidth * cell, y); ctx.stroke(); + } + } }, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]); const handleUpload = (event: ChangeEvent) => { @@ -686,7 +749,7 @@ export default function Home() { const x = Math.floor((event.clientX - rect.left - ruler) / zoom); const y = Math.floor((event.clientY - rect.top - ruler) / zoom); const pixel = pixels[y * gridWidth + x]; - if (x >= 0 && y >= 0 && x < gridWidth && y < gridHeight && pixel) toggleColor(pixel.color.code); + if (x >= 0 && y >= 0 && x < gridWidth && y < gridHeight && pixel?.color) toggleColor(pixel.color.code); }; const handleCanvasMove = (event: React.MouseEvent) => { @@ -701,7 +764,7 @@ export default function Home() { const column = Math.floor((event.clientX - rect.left - ruler) / zoom); const row = Math.floor((event.clientY - rect.top - ruler) / zoom); const pixel = pixels[row * gridWidth + column]; - if (column < 0 || row < 0 || column >= gridWidth || row >= gridHeight || !pixel) { + if (column < 0 || row < 0 || column >= gridWidth || row >= gridHeight || !pixel?.color) { setHoveredPixel(null); return; } @@ -722,7 +785,7 @@ export default function Home() { width: gridWidth, height: gridHeight, colorLimit, - codes: pixels.map((pixel) => pixel.color.code), + codes: pixels.map((pixel) => pixel.color?.code ?? null), }; persistHistory([entry, ...history].slice(0, 30)); setStatus("图纸已保存到本机历史记录"); @@ -730,7 +793,7 @@ export default function Home() { const restorePattern = (entry: SavedPattern) => { const colorMap = new Map(PALETTE.map((color) => [color.code, color])); - setPixels(entry.codes.map((code) => ({ color: colorMap.get(code) ?? PALETTE[0] }))); + setPixels(entry.codes.map((code) => ({ color: code === null ? null : colorMap.get(code) ?? PALETTE[0] }))); setGridWidth(entry.width); setGridHeight(entry.height); setRequestedWidth(entry.width); @@ -868,7 +931,7 @@ export default function Home() {