Add persistent anonymous usage logging

This commit is contained in:
wuyanwanwu
2026-08-14 01:05:50 +08:00
parent b890ce4035
commit 5a312f5039
18 changed files with 169 additions and 48 deletions
+2 -2
View File
@@ -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; }
+91 -28
View File
@@ -28,8 +28,17 @@ type ColorCluster = {
type SamplingStrategy = "smooth" | "dominant";
function logUsage(event: Record<string, unknown>) {
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<string | null>;
};
const TRANSPARENT_ALPHA_THRESHOLD = 128;
const MARD_SERIES_NAMES: Record<string, string> = {
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<number | null> = [];
const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
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<BeadColor | null>,
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<SamplingStrategy>("smooth");
const [cropZoom, setCropZoom] = useState(1);
@@ -383,7 +409,7 @@ export default function Home() {
const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(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<BeadColor | null> = 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<string, number>();
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<HTMLInputElement>) => {
@@ -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<HTMLCanvasElement>) => {
@@ -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() {
</section>
<aside className="color-panel">
<div className="panel-heading"><span>03</span><div><h2></h2><p>{usedColors.length} · {pixels.length} </p></div></div>
<div className="panel-heading"><span>03</span><div><h2></h2><p>{usedColors.length} · {beadCount} </p></div></div>
<div className="search-box"><span></span><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="输入 A6、MARD A 或 #FEAC4C" /></div>
<div className="selection-tools">
<label className="switch-row"><input type="checkbox" checked={onlySelected} onChange={(e) => setOnlySelected(e.target.checked)} /><span className="switch" /><span></span></label>
@@ -894,7 +957,7 @@ export default function Home() {
</div>
{filteredHistory.length ? <div className="history-grid">{filteredHistory.map((entry) => {
const colorCounts = new Map<string, number>();
entry.codes.forEach((code) => colorCounts.set(code, (colorCounts.get(code) ?? 0) + 1));
entry.codes.forEach((code) => { if (code) colorCounts.set(code, (colorCounts.get(code) ?? 0) + 1); });
const topCodes = [...colorCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
return <article className="history-card" key={entry.id}>
<div className="history-swatches">{topCodes.map(([code, count]) => <i key={code} style={{ backgroundColor: PALETTE.find((color) => color.code === code)?.hex, flexGrow: count }} />)}</div>