Add persistent anonymous usage logging
This commit is contained in:
+2
-2
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user