diff --git a/.gitignore b/.gitignore index d44c22f..8d679ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ build/bin node_modules -frontend/dist \ No newline at end of file +frontend/dist + +green-wall.exe \ No newline at end of file diff --git a/README.md b/README.md index 682a3ac..2d847f9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,39 @@ Make sure Git is installed on your computer. Download the app, open it, and first grab a Personal Access Token (PAT) so you can sign in to GitHub. You can follow this guide: [how to get your PAT](docs/githubtoken_en.md). +### New: Image → Contribution Heatmap + +- Upload any image (PNG/JPG/SVG) and turn it into a contribution heatmap. +- Choose rows (1–7) and columns (1–52) to fit your image’s shape. +- Pick a mode: + - **Auto** – works for most photos and grayscale images. + - **Binary** – best for black‑on‑white text or line art. +- Fine‑tune with brightness invert, threshold, scaling filter, and stroke recovery. +- Hover on the calendar to place the pattern, click to apply, right‑click to cancel. + +#### Quick guide + +| # | What you have | Recommended settings | +|---|---------------|----------------------| +| 1 | Black text on white background | Mode: Binary; Invert: on; Smoothing: Nearest; Threshold: 0–10; Recovery 1: 12–24; Dilation: 0–1 | +| 2 | Low‑contrast photo or drawing | Mode: Auto; Smoothing: Bilinear; Threshold: 0–15 | +| 3 | Very thin lines (≤2px) | Mode: Binary; Invert: on; Smoothing: Nearest; Recovery 1: 20–28; Dilation: 1–2 | +| 4 | Bright/noisy photo | Mode: Auto; Smoothing: Bilinear; Threshold: 5–20 | + +**Tips** +- Use “Target rows” to keep tall images from being flattened. +- If Binary mode looks too empty, increase “Binary stroke recovery” or add “Stroke dilation”. +- For sharp text, keep “Scaling filter” on “Nearest (preserve sharp edges)”. + +**Examples** +- ![success_example1](docs/images/success_example1.png) – Black text, Binary mode +- ![success_example2](docs/images/success_example2.png) – Grayscale photo, Auto mode +- ![success_example3](docs/images/success_example3.png) – Thin line art, Binary + dilation +- ![success_example4](docs/images/success_example4.png) – Balanced photo, Auto mode +- ![failure_example1](docs/images/failure_example1.png) – Over‑exposed photo (try higher threshold or invert off) +- ![failure_example2](docs/images/failure_example2.png) – Low contrast (increase threshold or use Binary + recovery) +- ![failure_example3](docs/images/failure_example3.png) – Noisy background (crop or smooth, then raise threshold) + Once you’re logged in you’ll see your avatar and name in the upper-left corner. Drag across the calendar to paint your design. When you’re satisfied, click **Create Remote Repo**. You can edit the repo name and description, choose whether it’s public or private, and then press **Generate & Push** to let the app create and push the repository for you automatically. > **Heads-up:** GitHub may take anywhere from 5 minutes to 2 days to show the contributions on your profile. You can keep the repo private and enable “Include private contributions” in your profile settings so others can’t see the repo content but the contribution streak still counts. diff --git a/README_zh.md b/README_zh.md index 3faf840..de94d25 100644 --- a/README_zh.md +++ b/README_zh.md @@ -14,6 +14,32 @@ 下载软件,打开后,首先要获取你的PAT来登录github,你可以参考这个:[如何获取你的github访问令牌](docs/githubtoken.md) +### 新增:图片转贡献图 + +- 上传图片(PNG/JPG/SVG),一键生成贡献热力图。 + - 尽量选择线条清晰、对比度的照片,但是细节不宜过多,否则将被压缩的难以辨认。 +- 自由设置行数(1~7)和列数(1~52),匹配图片形状。 +- 两种模式: + - **自动** – 出来会有色阶变化 + - **二值化** – 出来是最深和无色 +- 可调亮度反转、阈值、缩放平滑、笔画补强。 +- 在日历上悬停定位,左键应用,右键取消。 + +**小贴士** +- 二值化结果太稀疏时,加大“二值补笔画强度” +- 文字清晰优先选“邻近点(保细节)”。 +- 又想要色阶变化,又想要文字清晰,可以适当调高亮度阈值 + +**成果示例** +![success_example1](docs/images/success_example1.png) +![success_example2](docs/images/success_example2.png) +![success_example3](docs/images/success_example3.png) +![success_example4](docs/images/success_example4.png) +**失败示例** +![failure_example1](docs/images/failure_example1.png) +![failure_example2](docs/images/failure_example2.png) +![failure_example3](docs/images/failure_example3.png) + 登录成功左上角会显示你的头像和名字。拖动鼠标在日历上尽情画画,发挥你的艺术才能!画完后点击创建远程仓库,你可以自定义仓库名称和描述,选择仓库是否公开,确认无误后点击生成并且推送,软件会自动在你的GitHub上创建对应的仓库。 注意: GitHub 可能需要 5 分钟至两天才会显示你的贡献度图案。你可以把仓库设置为私人仓库,并在贡献统计中允许显示私人仓库的贡献,这样他人看不到仓库内容但可以看到贡献记录。 diff --git a/docs/images/failure_example1.png b/docs/images/failure_example1.png new file mode 100644 index 0000000..4604319 Binary files /dev/null and b/docs/images/failure_example1.png differ diff --git a/docs/images/failure_example2.png b/docs/images/failure_example2.png new file mode 100644 index 0000000..70da85d Binary files /dev/null and b/docs/images/failure_example2.png differ diff --git a/docs/images/failure_example3.png b/docs/images/failure_example3.png new file mode 100644 index 0000000..58733d8 Binary files /dev/null and b/docs/images/failure_example3.png differ diff --git a/docs/images/success_example1.png b/docs/images/success_example1.png new file mode 100644 index 0000000..d5d932b Binary files /dev/null and b/docs/images/success_example1.png differ diff --git a/docs/images/success_example2.png b/docs/images/success_example2.png new file mode 100644 index 0000000..8d4be3a Binary files /dev/null and b/docs/images/success_example2.png differ diff --git a/docs/images/success_example3.png b/docs/images/success_example3.png new file mode 100644 index 0000000..63d6891 Binary files /dev/null and b/docs/images/success_example3.png differ diff --git a/docs/images/success_example4.png b/docs/images/success_example4.png new file mode 100644 index 0000000..eb608ae Binary files /dev/null and b/docs/images/success_example4.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b786a9f..2e351eb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2415,6 +2416,7 @@ "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2432,6 +2434,7 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2493,6 +2496,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2719,6 +2723,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3111,6 +3116,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3531,6 +3537,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4086,6 +4093,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5552,6 +5560,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6540,6 +6549,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6757,6 +6767,7 @@ "resolved": "https://mirrors.huaweicloud.com/repository/npm/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7049,6 +7060,7 @@ "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -7765,6 +7777,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7902,6 +7915,7 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8027,6 +8041,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8136,6 +8151,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8524,6 +8540,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 63c63c8..f6f18c9 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -cf7eeb812cadce6dc9108a9563da0e8c \ No newline at end of file +c23a00f6832717449a7945163092849e \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1250260..34c2312 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,6 @@ import GitInstallSidebar from './components/GitInstallSidebar'; import GitPathSettings from './components/GitPathSettings'; import LoginModal from './components/LoginModal'; import { TranslationProvider, useTranslations, Language } from './i18n'; -import { BrowserOpenURL, EventsOn } from '../wailsjs/runtime/runtime'; import type { main } from '../wailsjs/go/models'; function App() { @@ -48,6 +47,12 @@ type AppLayoutProps = { }; const AppLayout: React.FC = ({ contributions }) => { + const hasWailsApp = React.useCallback(() => { + if (typeof window === 'undefined') return false; + const w = window as typeof window & { go?: { main?: { App?: unknown } } }; + return Boolean(w?.go?.main?.App); + }, []); + const { language, setLanguage, t } = useTranslations(); const [isGitInstalled, setIsGitInstalled] = React.useState(null); const [isGitPathSettingsOpen, setIsGitPathSettingsOpen] = React.useState(false); @@ -56,8 +61,16 @@ const AppLayout: React.FC = ({ contributions }) => { const checkGit = React.useCallback(async () => { try { - const { CheckGitInstalled } = await import('../wailsjs/go/main/App'); - const response = await CheckGitInstalled(); + if (typeof window !== 'undefined' && !hasWailsApp()) { + console.warn('CheckGitInstalled skipped: wails runtime not available (dev mode)'); + setIsGitInstalled(false); + return; + } + const mod = await import('../wailsjs/go/main/App'); + if (!mod || typeof mod.CheckGitInstalled !== 'function') { + throw new Error('CheckGitInstalled not available'); + } + const response = await mod.CheckGitInstalled(); setIsGitInstalled(response.installed); } catch (error) { console.error('Failed to check Git installation:', error); @@ -72,8 +85,15 @@ const AppLayout: React.FC = ({ contributions }) => { React.useEffect(() => { (async () => { try { - const { GetGithubLoginStatus } = await import('../wailsjs/go/main/App'); - const status = await GetGithubLoginStatus(); + if (typeof window !== 'undefined' && !hasWailsApp()) { + console.warn('GetGithubLoginStatus skipped: wails runtime not available (dev mode)'); + return; + } + const mod = await import('../wailsjs/go/main/App'); + if (!mod || typeof mod.GetGithubLoginStatus !== 'function') { + throw new Error('GetGithubLoginStatus not available'); + } + const status = await mod.GetGithubLoginStatus(); if (status.authenticated && status.user) { setGithubUser(status.user); } else { @@ -86,16 +106,28 @@ const AppLayout: React.FC = ({ contributions }) => { }, []); React.useEffect(() => { - const unsubscribe = EventsOn('github:auth-changed', (status: main.GithubLoginStatus) => { - if (status && status.authenticated && status.user) { - setGithubUser(status.user); - return; + let unsubscribe: (() => void) | undefined; + (async () => { + try { + const { EventsOn } = await import('../wailsjs/runtime/runtime'); + if (typeof EventsOn === 'function') { + unsubscribe = EventsOn('github:auth-changed', (status: main.GithubLoginStatus) => { + if (status && status.authenticated && status.user) { + setGithubUser(status.user); + return; + } + setGithubUser(null); + }); + } + } catch (error) { + console.warn('EventsOn not available (dev mode)', error); } - setGithubUser(null); - }); + })(); return () => { - unsubscribe(); + if (unsubscribe) { + unsubscribe(); + } }; }, []); @@ -115,8 +147,10 @@ const AppLayout: React.FC = ({ contributions }) => { const logoutLabel = language === 'zh' ? '退出' : 'Log out'; const handleLogout = React.useCallback(async () => { try { - const { LogoutGithub } = await import('../wailsjs/go/main/App'); - await LogoutGithub(); + const mod = await import('../wailsjs/go/main/App'); + if (typeof mod?.LogoutGithub === 'function') { + await mod.LogoutGithub(); + } setGithubUser(null); } catch (error) { console.error('Failed to log out from GitHub:', error); @@ -127,8 +161,19 @@ const AppLayout: React.FC = ({ contributions }) => { }, []); const displayName = githubUser?.name?.trim() || githubUser?.login || ''; - const openRepository = React.useCallback(() => { - BrowserOpenURL('https://github.com/zmrlft/GreenWall'); + const openRepository = React.useCallback(async () => { + try { + const { BrowserOpenURL } = await import('../wailsjs/runtime/runtime'); + if (typeof BrowserOpenURL === 'function') { + BrowserOpenURL('https://github.com/zmrlft/GreenWall'); + return; + } + } catch (error) { + console.warn('BrowserOpenURL not available (dev mode)', error); + } + if (typeof window !== 'undefined') { + window.open('https://github.com/zmrlft/GreenWall', '_blank', 'noopener,noreferrer'); + } }, []); return ( diff --git a/frontend/src/components/CalendarControls.tsx b/frontend/src/components/CalendarControls.tsx index bdf2ce7..4ef454f 100644 --- a/frontend/src/components/CalendarControls.tsx +++ b/frontend/src/components/CalendarControls.tsx @@ -185,29 +185,32 @@ export const CalendarControls: React.FC = ({
{t('labels.drawMode')}
- {onPenIntensityChange && ( )} - +
{isRemoteModalOpen && ( diff --git a/frontend/src/components/ImageImportCard.tsx b/frontend/src/components/ImageImportCard.tsx new file mode 100644 index 0000000..6201e36 --- /dev/null +++ b/frontend/src/components/ImageImportCard.tsx @@ -0,0 +1,616 @@ +import React from 'react'; +import clsx from 'clsx'; +import { useTranslations } from '../i18n'; + +type QuantizedGrid = { + width: number; + height: number; + data: number[][]; +}; + +type Props = { + onPreview?: (grid: QuantizedGrid) => void; + className?: string; +}; + +const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); + +const levelColors = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']; +const levelToCount = [0, 1, 3, 6, 9]; + +type Mode = 'auto' | 'binary'; + +function otsuThreshold(values: number[]): number { + if (values.length === 0) return 128; + const hist = new Array(256).fill(0); + for (const v of values) { + hist[clamp(Math.round(v), 0, 255)] += 1; + } + const total = values.length; + let sum = 0; + for (let i = 0; i < 256; i++) sum += i * hist[i]; + let sumB = 0; + let wB = 0; + let wF = 0; + let varMax = 0; + let threshold = 128; + for (let t = 0; t < 256; t++) { + wB += hist[t]; + if (wB === 0) continue; + wF = total - wB; + if (wF === 0) break; + sumB += t * hist[t]; + const mB = sumB / wB; + const mF = (sum - sumB) / wF; + const varBetween = wB * wF * (mB - mF) * (mB - mF); + if (varBetween > varMax) { + varMax = varBetween; + threshold = t; + } + } + return threshold; +} + +function computeQuantileThresholds(values: number[], buckets: number) { + const sorted = [...values].sort((a, b) => a - b); + const thresholds: number[] = []; + for (let i = 1; i < buckets; i++) { + const idx = Math.floor((sorted.length * i) / buckets); + thresholds.push(sorted[idx] ?? 255); + } + return thresholds; +} + +function brightnessToLevel(brightness: number, thresholds: number[]) { + let level = 0; + for (const t of thresholds) { + if (brightness > t) { + level += 1; + } else { + break; + } + } + return clamp(level, 0, 4); +} + +export const ImageImportCard: React.FC = ({ onPreview, className }) => { + const { t } = useTranslations(); + const [fileUrl, setFileUrl] = React.useState(null); + const [autoWidth, setAutoWidth] = React.useState(null); + const [manualWidth, setManualWidth] = React.useState(''); + const [autoHeight, setAutoHeight] = React.useState(null); + const [manualHeight, setManualHeight] = React.useState(''); + const [invert, setInvert] = React.useState(true); + const [threshold, setThreshold] = React.useState(''); + const [mode, setMode] = React.useState('auto'); + const [imageSmoothing, setImageSmoothing] = React.useState(true); + const [binaryRelax, setBinaryRelax] = React.useState(12); + const [binaryRelax2, setBinaryRelax2] = React.useState(0); + const [preview, setPreview] = React.useState(null); + const [isProcessing, setIsProcessing] = React.useState(false); + const fileInputRef = React.useRef(null); + const canvasRef = React.useRef(null); + const lastProcessKey = React.useRef(null); + + React.useEffect(() => { + return () => { + if (fileUrl) { + URL.revokeObjectURL(fileUrl); + } + }; + }, [fileUrl]); + + const targetWidth = React.useMemo(() => { + const parsed = Number(manualWidth); + if (!Number.isNaN(parsed) && parsed > 0) { + return clamp(Math.floor(parsed), 1, 52); + } + if (autoWidth !== null) { + return autoWidth; + } + return 14; // safe default + }, [manualWidth, autoWidth]); + + const targetHeight = React.useMemo(() => { + const parsed = Number(manualHeight); + if (!Number.isNaN(parsed) && parsed > 0) { + return clamp(Math.floor(parsed), 1, 7); + } + if (autoHeight !== null) { + return clamp(autoHeight, 1, 7); + } + return null; + }, [manualHeight, autoHeight]); + + const handlePickFile = () => { + fileInputRef.current?.click(); + }; + + const processImage = React.useCallback( + async ( + file: File, + invertBrightness: boolean, + widthOverride?: number, + heightOverride?: number + ) => { + setIsProcessing(true); + let attemptedKey: string | null = null; + let objectUrl: string = ''; + try { + objectUrl = URL.createObjectURL(file); + setFileUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return objectUrl; + }); + attemptedKey = [ + objectUrl, + widthOverride ?? targetWidth, + heightOverride ?? targetHeight, + invertBrightness ? 1 : 0, + threshold === '' ? 'none' : threshold, + mode, + imageSmoothing ? 1 : 0, + binaryRelax, + binaryRelax2, + ].join('|'); + + const img = await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = objectUrl; + }); + + const w = img.naturalWidth || img.width; + const h = img.naturalHeight || img.height; + const suggestedWidth = clamp(Math.round((w / h) * 7), 1, 52); + setAutoWidth(suggestedWidth); + + const finalWidth = clamp(widthOverride ?? suggestedWidth, 1, 52); + const suggestedHeight = clamp(Math.round((h / w) * finalWidth), 1, 7); + setAutoHeight(suggestedHeight); + + const finalHeight = clamp(heightOverride ?? suggestedHeight, 1, 7); + + const canvas = canvasRef.current; + if (!canvas) { + throw new Error('Canvas not ready'); + } + canvas.width = finalWidth; + canvas.height = finalHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Canvas context unavailable'); + } + ctx.clearRect(0, 0, finalWidth, finalHeight); + ctx.imageSmoothingEnabled = imageSmoothing ? true : false; + ctx.drawImage(img, 0, 0, finalWidth, finalHeight); + const { data } = ctx.getImageData(0, 0, finalWidth, finalHeight); + + const brightnessValues: number[] = []; + const grid: number[][] = Array.from({ length: finalHeight }, () => + Array(finalWidth).fill(0) + ); + + const thresholdValue = + threshold === '' || threshold <= 0 ? null : clamp(Math.floor(threshold), 1, 255); + + for (let y = 0; y < finalHeight; y++) { + for (let x = 0; x < finalWidth; x++) { + const idx = (y * finalWidth + x) * 4; + const r = data[idx]; + const g = data[idx + 1]; + const b = data[idx + 2]; + let value = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + if (invertBrightness) { + value = 255 - value; + } + if (thresholdValue !== null && value <= thresholdValue) { + value = 0; + } + brightnessValues.push(value); + grid[y][x] = value; + } + } + + const nonZero = brightnessValues.filter((v) => v > 0); + const minNonZero = nonZero.length ? Math.min(...nonZero) : 0; + const maxNonZero = nonZero.length ? Math.max(...nonZero) : 0; + const normalizeValue = (v: number) => { + if (v <= 0) return 0; + if (maxNonZero === minNonZero) return 255; + return clamp(Math.round(((v - minNonZero) / (maxNonZero - minNonZero)) * 255), 0, 255); + }; + + const normalizedValues: number[] = []; + const normalizedGrid: number[][] = grid.map((row) => + row.map((v) => { + const nv = normalizeValue(v); + normalizedValues.push(nv); + return nv; + }) + ); + + const effectiveValues = normalizedValues.filter((v) => v > 0); + const sourceForQuantile = + effectiveValues.length > 0 && effectiveValues.some((v) => v !== effectiveValues[0]) + ? effectiveValues + : normalizedValues; + const thresholds = computeQuantileThresholds(sourceForQuantile, 5); + const hasVariance = sourceForQuantile.some((v) => v !== sourceForQuantile[0]); + const otsu = otsuThreshold(sourceForQuantile); + const binarize = (thr: number) => + normalizedGrid.map((row) => + row.map((v) => (v > thr ? levelToCount[4] : levelToCount[0])) + ); + + let quantized: number[][] = []; + if (mode === 'binary') { + const quantizedOtsu = binarize(otsu); + const activeOtsu = quantizedOtsu.flat().filter((v) => v > 0).length; + let chosen = quantizedOtsu; + + if (binaryRelax > 0) { + const relaxedThr = clamp(otsu - binaryRelax, 0, 255); + const quantizedRelax = binarize(relaxedThr); + const activeRelax = quantizedRelax.flat().filter((v) => v > 0).length; + const sparseThreshold = (finalWidth * finalHeight) / 20; + if (activeRelax > activeOtsu || activeOtsu < sparseThreshold) { + chosen = quantizedRelax; + } + } + + if (binaryRelax2 > 0) { + const relaxedThr2 = clamp(otsu - binaryRelax - binaryRelax2, 0, 255); + const quantizedRelax2 = binarize(relaxedThr2); + const activeRelax2 = quantizedRelax2.flat().filter((v) => v > 0).length; + const activeChosen = chosen.flat().filter((v) => v > 0).length; + if (activeRelax2 > activeChosen) { + chosen = quantizedRelax2; + } + } + quantized = chosen; + } else { + quantized = normalizedGrid.map((row) => + row.map((v) => { + if (!hasVariance) { + const linearLevel = Math.round((v / 255) * 4); + return levelToCount[clamp(linearLevel, 0, 4)]; + } + return levelToCount[brightnessToLevel(v, thresholds)]; + }) + ); + } + + setPreview({ + width: finalWidth, + height: finalHeight, + data: quantized, + }); + const processKey = [ + objectUrl, + finalWidth, + finalHeight, + invertBrightness ? 1 : 0, + thresholdValue ?? 'none', + mode, + imageSmoothing ? 1 : 0, + binaryRelax, + binaryRelax2, + ].join('|'); + attemptedKey = processKey; + lastProcessKey.current = processKey; + } catch (error) { + console.error(error); + window.alert(t('imageImport.loadFailed')); + } finally { + if (attemptedKey) { + lastProcessKey.current = attemptedKey; + } + setIsProcessing(false); + } + }, + [ + t, + threshold, + mode, + invert, + imageSmoothing, + binaryRelax, + binaryRelax2, + targetWidth, + targetHeight, + ] + ); + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + await processImage(file, invert); + // allow selecting the same filename again by resetting the input value + event.target.value = ''; + }; + + const handlePreviewOnCalendar = () => { + if (!preview) { + window.alert(t('imageImport.noPreview')); + return; + } + onPreview?.(preview); + }; + + const handleReprocessWithWidth = () => { + if (!fileUrl) return; + fetch(fileUrl) + .then((r) => r.blob()) + .then((blob) => { + const file = new File([blob], 'reprocess', { type: blob.type }); + return processImage(file, invert, targetWidth, targetHeight ?? undefined); + }) + .catch((error) => { + console.error(error); + window.alert(t('imageImport.loadFailed')); + }); + }; + React.useEffect(() => { + // reprocess when parameters change, but skip if same key (avoid flicker loops) + if (!fileUrl || isProcessing) return; + const desiredKey = [ + fileUrl, + targetWidth, + targetHeight, + invert ? 1 : 0, + threshold === '' ? 'none' : threshold, + mode, + imageSmoothing ? 1 : 0, + binaryRelax, + binaryRelax2, + ].join('|'); + if (lastProcessKey.current === desiredKey) return; + + fetch(fileUrl) + .then((r) => r.blob()) + .then((blob) => { + const file = new File([blob], 'reprocess', { type: blob.type }); + return processImage(file, invert, targetWidth, targetHeight ?? undefined); + }) + .catch((error) => { + console.error(error); + }); + }, [ + invert, + targetWidth, + targetHeight, + threshold, + mode, + imageSmoothing, + binaryRelax, + binaryRelax2, + fileUrl, + isProcessing, + ]); + + return ( +
+
+
{t('imageImport.title')}
+ {t('imageImport.description') && ( +

{t('imageImport.description')}

+ )} +
+ + + +
+ +
+ +
+ + + + + + {mode === 'binary' && ( + <> + + + + )} + +
+ +
+
{t('imageImport.preview')}
+
+ {isProcessing && ( +
{t('imageImport.processing')}
+ )} + {!isProcessing && preview && ( +
+
+ {preview.data.map((row, y) => ( +
+ {row.map((val, x) => { + const idx = levelToCount.indexOf(val); + const color = levelColors[idx >= 0 ? idx : 0]; + return ( + + ); + })} +
+ ))} +
+
+ {preview.width} × {preview.height} •{' '} + {fileUrl ? t('imageImport.changeImage') : t('imageImport.selectImage')} +
+
+ )} + {!isProcessing && !preview && ( +
+ {t('imageImport.noPreview')} +
+ )} + +
+
+ + +
+ ); +}; diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 6a5080b..4132e43 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -105,6 +105,40 @@ type TranslationDict = { workbench: { placeholder: string; }; + imageImport: { + title: string; + description: string; + selectImage: string; + changeImage: string; + targetWidth: string; + targetWidthHint: string; + targetHeight: string; + targetHeightHint: string; + startDate: string; + threshold: string; + thresholdHint: string; + mode: string; + modeAuto: string; + modeBinary: string; + modeHint: string; + smoothing: string; + smoothingOn: string; + smoothingOff: string; + smoothingHint: string; + binaryRelax: string; + binaryRelaxHint: string; + binaryRelax2: string; + binaryRelax2Hint: string; + invert: string; + previewOnCalendar: string; + previewOnCalendarHint: string; + apply: string; + preview: string; + noPreview: string; + processing: string; + invalidDate: string; + loadFailed: string; + }; characterSelector: { title: string; selectCharacter: string; @@ -269,6 +303,40 @@ const translations: Record = { placeholder: '✨ This area is under development! Got any wild feature ideas? Drop them in the issues and your creativity might ship~ Tips: Right-click to switch between the brush and eraser. In copy mode, select a pattern, press Ctrl+C to copy it, then Ctrl+V or left-click to paste.', }, + imageImport: { + title: 'Image → Heatmap', + description: '', + selectImage: 'Choose image', + changeImage: 'Change image', + targetWidth: 'Target columns', + targetWidthHint: 'Fill manually to scale (1–52)', + targetHeight: 'Target rows', + targetHeightHint: 'Fill manually to scale (1–7)', + startDate: 'Start date (top row = Sunday)', + threshold: 'Brightness threshold', + thresholdHint: 'Pixels below this brightness in the source will be set to 0 (0-255)', + mode: 'Quantisation mode', + modeAuto: 'Auto (grayscale)', + modeBinary: 'Binary (pure black/white)', + modeHint: 'Auto: grayscale | Binary: pure black/white', + smoothing: 'Scaling filter', + smoothingOn: 'Bilinear (smoother)', + smoothingOff: 'Nearest (preserve sharp edges)', + smoothingHint: 'If thin strokes break, try Nearest; if blocky, try Bilinear', + binaryRelax: 'Binary stroke recovery', + binaryRelaxHint: 'Lower Otsu threshold by this value when result is too sparse (0–64)', + binaryRelax2: 'Secondary recovery', + binaryRelax2Hint: 'Additional threshold reduction after the first recovery (0–64)', + invert: 'Invert brightness', + previewOnCalendar: 'Preview on calendar (hover to place, click to apply)', + previewOnCalendarHint: 'Use hover to position; left-click to apply, right-click to cancel', + apply: 'Apply to calendar', + preview: 'Preview', + noPreview: 'Upload an image to see preview', + processing: 'Processing image...', + invalidDate: 'Please pick a valid start date.', + loadFailed: 'Failed to load image, try another file.', + }, characterSelector: { title: 'Select Pattern', selectCharacter: 'Select Character (A-Z, a-z, 0-9)', @@ -310,7 +378,7 @@ const translations: Record = { 'GreenWall will reuse your generated commits, create a GitHub repository, add it as origin, and push everything for you.', nameLabel: 'Repository Name', namePlaceholder: 'my-contributions', - nameHelp: 'Use letters, numbers, ".", "_" or "-" (up to 100 characters).', + nameHelp: 'Use letters, numbers, ".", "_", or "-" (up to 100 characters).', privacyLabel: 'Visibility', publicOption: 'Public', privateOption: 'Private', @@ -320,7 +388,7 @@ const translations: Record = { confirm: 'Generate & Push', confirming: 'Working...', nameRequired: 'Repository name is required.', - nameInvalid: 'Repository name can only include letters, numbers, ".", "_" or "-".', + nameInvalid: 'Repository name can only include letters, numbers, ".", "_", or "-".', }, }, zh: { @@ -428,6 +496,40 @@ const translations: Record = { placeholder: '✨ 该区域正在开发中!大家有哪些脑洞大开的功能想法?快来 issues 留言,你的创意可能会被实现哦~操作说明:右键可以切换画笔和橡皮擦,复制模式下框选好图案后按 ctrl+C 复制图案,ctrl+V 或者左键粘贴图案', }, + imageImport: { + title: '图片转贡献图', + description: '', + selectImage: '选择图片', + changeImage: '更换图片', + targetWidth: '目标列数', + targetWidthHint: '可手动填写以缩放(1~52)', + targetHeight: '目标行数', + targetHeightHint: '可手动填写以缩放(1~7)', + startDate: '起始日期(最上方为周日)', + threshold: '亮度阈值', + thresholdHint: '原图中低于此亮度的像素会置为 0(0-255)', + mode: '量化模式', + modeAuto: '自动(灰度图)', + modeBinary: '二值化(纯黑白)', + modeHint: '自动:灰度图|二值化:纯黑白', + smoothing: '缩放平滑', + smoothingOn: '双线性(更平滑)', + smoothingOff: '邻近点(保细节)', + smoothingHint: '笔画断裂选邻近,颗粒感重选双线性', + binaryRelax: '二值补笔画强度', + binaryRelaxHint: '当二值结果太稀疏时,下调 Otsu 阈值的幅度(0-64)', + binaryRelax2: '二次补笔画', + binaryRelax2Hint: '在第一次补笔画后再下调的幅度(0-64)', + invert: '反转亮度', + previewOnCalendar: '在日历中预览(悬停定位,点击应用)', + previewOnCalendarHint: '鼠标悬停定位,左键应用,右键取消', + apply: '应用到贡献表', + preview: '预览', + noPreview: '上传图片后会显示预览', + processing: '图片处理中...', + invalidDate: '请选择有效的起始日期。', + loadFailed: '图片加载失败,请重试其他文件。', + }, characterSelector: { title: '选择图案', selectCharacter: '选择字符 (A-Z, a-z, 0-9)', diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 623397b..7cb89d7 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -48,6 +48,10 @@ export function EventsOff(eventName, ...additionalEventNames) { return window.runtime.EventsOff(eventName, ...additionalEventNames); } +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + export function EventsOnce(eventName, callback) { return EventsOnMultiple(eventName, callback, 1); } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c7ba4de --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "GreenWall", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}