diff --git a/web/package-lock.json b/web/package-lock.json index 6f0dbd4..cc312b5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.95.0", + "@use-gesture/react": "^10.3.1", "clsx": "^2.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^2.6.0", - "zod": "^3.24.1" + "zod": "^3.24.1", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -24,7 +26,8 @@ "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.1.10" }, "engines": { "node": ">=20" @@ -1200,6 +1203,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1638,6 +1648,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1659,7 +1687,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1675,6 +1703,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1696,6 +1742,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", @@ -1764,6 +1933,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1790,7 +1969,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1842,6 +2021,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1894,6 +2080,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2325,6 +2531,27 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2487,6 +2714,13 @@ "seroval": "^1.0" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2497,6 +2731,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -2528,6 +2776,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2545,6 +2810,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2681,6 +2956,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2696,6 +3078,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/web/package.json b/web/package.json index 0ef2d02..1753925 100644 --- a/web/package.json +++ b/web/package.json @@ -11,16 +11,20 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview" }, "dependencies": { "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.95.0", + "@use-gesture/react": "^10.3.1", "clsx": "^2.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^2.6.0", - "zod": "^3.24.1" + "zod": "^3.24.1", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -30,6 +34,7 @@ "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.1.10" } } diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx new file mode 100644 index 0000000..204e160 --- /dev/null +++ b/web/src/editor/GardenCanvas.tsx @@ -0,0 +1,124 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { Button } from '@/components/ui/Button' +import type { Size } from '@/lib/geometry' +import { ObjectShape } from './ObjectShape' +import { useEditorStore } from './store' +import { useViewport } from './useViewport' +import type { EditorGarden, EditorObject } from './types' + +const GRID_CM = 100 // 1 m grid +const GRID_MIN_CELL_PX = 6 // grid is invisible below this cell size (zoomed out) +const GRID_MAX_OPACITY = 0.18 + +/** + * The editor canvas: a full-size SVG with a single world that pan/zoom/pinch + * transforms. Renders a 1 m grid that fades in as you zoom, the garden boundary, + * and its objects. Interactions beyond viewport + basic select (move/resize/ + * rotate, palette) land in #11; this is the foundation, driven by mock data. + */ +export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) { + const svgRef = useRef(null) + const containerRef = useRef(null) + const [size, setSize] = useState({ w: 0, h: 0 }) + const fittedForRef = useRef(null) + const gridId = 'garden-grid-' + useId().replace(/:/g, '') + + const viewport = useEditorStore((s) => s.viewport) + const selectedId = useEditorStore((s) => s.selectedId) + const select = useEditorStore((s) => s.select) + const { fitToRect } = useViewport(svgRef) + + const gardenRect = useMemo( + () => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }), + [garden.widthCm, garden.heightCm], + ) + + // Track the container's pixel size for fit math. + useEffect(() => { + const el = containerRef.current + if (!el) return + const ro = new ResizeObserver(([entry]) => { + setSize({ w: entry.contentRect.width, h: entry.contentRect.height }) + }) + ro.observe(el) + return () => ro.disconnect() + }, []) + + // Frame the garden once the canvas size is known, and again if we switch to a + // different garden. + useEffect(() => { + const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0 + if (usable && fittedForRef.current !== garden.id) { + fittedForRef.current = garden.id + fitToRect(gardenRect, size) + } + }, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect]) + + // Grid fades in as cells grow from GRID_MIN_CELL_PX to 2× that. + const cellPx = GRID_CM * viewport.scale + const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY)) + + const ordered = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects]) + + return ( +
+ select(null)} + > + + {gridOpacity > 0.005 && ( + <> + + + + + + + + )} + + {/* Garden boundary. */} + + + {ordered.map((o) => ( + + ))} + + + + {/* Overlay: zoom readout + fit control. */} +
+ {viewport.scale.toFixed(2)} px/cm +
+ +
+ ) +} diff --git a/web/src/editor/ObjectShape.tsx b/web/src/editor/ObjectShape.tsx new file mode 100644 index 0000000..6014772 --- /dev/null +++ b/web/src/editor/ObjectShape.tsx @@ -0,0 +1,110 @@ +import { memo, type PointerEvent } from 'react' +import type { EditorObject } from './types' + +const DEFAULT_FILL = '#8a8a8a' + +// Default fills by kind (overridable per object via object.color). Muted, earthy +// tones so plops (added in #15) read clearly on top. +const kindColors: Record = { + bed: '#8a6d4b', + grow_bag: '#9c7a52', + container: '#6b7f8a', + in_ground: '#7a6a4a', + tree: '#4f7a4f', + path: '#b8b0a0', + structure: DEFAULT_FILL, +} + +// Label font size = this fraction of the object's smaller side, clamped to a +// readable cm range. Corner radius = this fraction of the smaller half-side. +const LABEL_FONT_FACTOR = 0.28 +const LABEL_FONT_MIN_CM = 8 +const LABEL_FONT_MAX_CM = 40 +const CORNER_RADIUS_FACTOR = 0.06 + +function fillFor(o: EditorObject): string { + return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL +} + +/** + * One garden object in world (cm) space: a centered rect or ellipse (a circle + * when width == height), rotated about its center, with the object's name. The + * parent world applies the viewport scale, so geometry is authored in cm and + * strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at + * any zoom. memo'd so a pan/zoom (which only changes the world transform) + * doesn't re-render every object. Full move/resize/rotate come in #11. + */ +export const ObjectShape = memo(function ObjectShape({ + object, + selected, + onSelect, +}: { + object: EditorObject + selected: boolean + onSelect: (id: number) => void +}) { + const fill = fillFor(object) + const halfW = Math.max(0, object.widthCm / 2) + const halfH = Math.max(0, object.heightCm / 2) + const fontCm = Math.max( + LABEL_FONT_MIN_CM, + Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR), + ) + + function handleDown(e: PointerEvent) { + e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect + onSelect(object.id) + } + + const stroke = selected ? '#2f7a3e' : '#00000033' + const strokeWidth = selected ? 2 : 1 + + return ( + + {object.shape === 'circle' ? ( + + ) : ( + + )} + + {object.name && ( + + {object.name} + + )} + + ) +}) diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts new file mode 100644 index 0000000..a15a27b --- /dev/null +++ b/web/src/editor/store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand' +import type { Viewport } from '@/lib/geometry' + +// Ephemeral editor state only (per DESIGN § State): the viewport, the current +// selection, and which object is "focused" (zoomed into for plops, #15). All +// server state stays in react-query. Selection interactions grow in #11. + +export const MIN_SCALE = 0.05 // px per cm — fully zoomed out +export const MAX_SCALE = 20 // px per cm — fully zoomed in + +interface EditorState { + viewport: Viewport + setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void + + selectedId: number | null + select: (id: number | null) => void + + focusedObjectId: number | null + setFocusedObject: (id: number | null) => void +} + +export const useEditorStore = create((set) => ({ + viewport: { tx: 0, ty: 0, scale: 1 }, + setViewport: (next) => + set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })), + + selectedId: null, + select: (id) => set({ selectedId: id }), + + focusedObjectId: null, + setFocusedObject: (id) => set({ focusedObjectId: id }), +})) diff --git a/web/src/editor/types.ts b/web/src/editor/types.ts new file mode 100644 index 0000000..e711f8d --- /dev/null +++ b/web/src/editor/types.ts @@ -0,0 +1,30 @@ +// The object shape the canvas renders. A subset of the server's garden object +// (#10); #11 maps /full's objects onto this. Positioned by center in garden cm. + +import type { UnitPref } from '@/lib/units' + +export type ObjectShapeKind = 'rect' | 'circle' + +export interface EditorObject { + id: number + kind: string + name: string + shape: ObjectShapeKind + xCm: number + yCm: number + widthCm: number + heightCm: number + rotationDeg: number + zIndex: number + color?: string | null + plantable: boolean +} + +/** The garden being edited: its bounds define the canvas extent. */ +export interface EditorGarden { + id: number + name: string + widthCm: number + heightCm: number + unitPref: UnitPref +} diff --git a/web/src/editor/useViewport.ts b/web/src/editor/useViewport.ts new file mode 100644 index 0000000..0d30ca5 --- /dev/null +++ b/web/src/editor/useViewport.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import { useGesture } from '@use-gesture/react' +import { + easeInOutCubic, + lerp, + zoomToFitRect, + zoomViewportAt, + type Point, + type Rect, + type Size, +} from '@/lib/geometry' +import { MAX_SCALE, MIN_SCALE, useEditorStore } from './store' + +const WHEEL_SENSITIVITY = 0.0015 // exponential zoom per wheel delta unit +const FIT_PADDING = 32 // px margin around a fitted rect +const FIT_DURATION_MS = 350 + +/** + * Wires pan/zoom/pinch gestures onto the svg (via @use-gesture) and returns an + * animated `fitToRect`. Wheel and pinch zoom toward the pointer; drag on empty + * space pans (object dragging in #11 stops propagation). Scale is clamped to + * [MIN_SCALE, MAX_SCALE]. The svg must set touch-action:none so the browser + * doesn't hijack the gestures. + */ +export function useViewport(svgRef: RefObject) { + const setViewport = useEditorStore((s) => s.setViewport) + const animRef = useRef(null) + + const cancelAnim = useCallback(() => { + if (animRef.current != null) { + cancelAnimationFrame(animRef.current) + animRef.current = null + } + }, []) + + // Client (page) coords → coords within the svg, for anchoring zoom. + const clientToCanvas = useCallback( + (clientX: number, clientY: number): Point => { + const rect = svgRef.current?.getBoundingClientRect() + return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY } + }, + [svgRef], + ) + + useGesture( + { + onDragStart: cancelAnim, + onDrag: ({ delta: [dx, dy], pinching, cancel }) => { + if (pinching) { + cancel() + return + } + setViewport((vp) => ({ ...vp, tx: vp.tx + dx, ty: vp.ty + dy })) + }, + onWheelStart: cancelAnim, + onWheel: ({ event, delta: [, dy] }) => { + event.preventDefault() + if (!Number.isFinite(dy)) return // never let a bad delta corrupt the viewport + const p = clientToCanvas(event.clientX, event.clientY) + setViewport((vp) => zoomViewportAt(vp, p, vp.scale * Math.exp(-dy * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)) + }, + onPinchStart: cancelAnim, + onPinch: ({ origin: [ox, oy], offset: [scale] }) => { + if (!Number.isFinite(scale)) return + const p = clientToCanvas(ox, oy) + setViewport((vp) => zoomViewportAt(vp, p, scale, MIN_SCALE, MAX_SCALE)) + }, + }, + { + target: svgRef, + eventOptions: { passive: false }, // so onWheel can preventDefault + drag: { filterTaps: true }, + pinch: { + // Seed the pinch offset with the current scale so offset[0] is absolute. + from: () => [useEditorStore.getState().viewport.scale, 0], + scaleBounds: { min: MIN_SCALE, max: MAX_SCALE }, + }, + }, + ) + + const fitToRect = useCallback( + (rect: Rect, canvasSize: Size) => { + cancelAnim() + const from = useEditorStore.getState().viewport + const to = zoomToFitRect(rect, canvasSize, FIT_PADDING, MIN_SCALE, MAX_SCALE) + const start = performance.now() + const step = (now: number) => { + const t = Math.min(1, (now - start) / FIT_DURATION_MS) + const e = easeInOutCubic(t) + setViewport({ tx: lerp(from.tx, to.tx, e), ty: lerp(from.ty, to.ty, e), scale: lerp(from.scale, to.scale, e) }) + animRef.current = t < 1 ? requestAnimationFrame(step) : null + } + animRef.current = requestAnimationFrame(step) + }, + [cancelAnim, setViewport], + ) + + useEffect(() => cancelAnim, [cancelAnim]) // stop any tween on unmount + + return { fitToRect } +} diff --git a/web/src/lib/geometry.test.ts b/web/src/lib/geometry.test.ts new file mode 100644 index 0000000..648d502 --- /dev/null +++ b/web/src/lib/geometry.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + clampScale, + localToWorld, + screenToWorld, + worldToLocal, + worldToScreen, + zoomToFitRect, + zoomViewportAt, + type Viewport, +} from './geometry' + +const vp: Viewport = { tx: 100, ty: 50, scale: 2 } + +describe('world/screen transforms', () => { + it('round-trips a point', () => { + const p = { x: 37.5, y: -12.25 } + const back = screenToWorld(worldToScreen(p, vp), vp) + expect(back.x).toBeCloseTo(p.x, 9) + expect(back.y).toBeCloseTo(p.y, 9) + }) + + it('applies scale then translate', () => { + expect(worldToScreen({ x: 10, y: 10 }, vp)).toEqual({ x: 120, y: 70 }) + }) +}) + +describe('object-local/world transforms', () => { + const center = { x: 500, y: 300 } + + it('round-trips through any rotation', () => { + for (const deg of [0, 30, 90, 180, 270, 45, -60, 123.4]) { + const local = { x: 40, y: -25 } + const back = worldToLocal(localToWorld(local, center, deg), center, deg) + expect(back.x).toBeCloseTo(local.x, 6) + expect(back.y).toBeCloseTo(local.y, 6) + } + }) + + it('rotates 90° clockwise on screen (y down)', () => { + // A point 10cm to the object's right, rotated 90°, lands 10cm below center. + const w = localToWorld({ x: 10, y: 0 }, center, 90) + expect(w.x).toBeCloseTo(center.x, 6) + expect(w.y).toBeCloseTo(center.y + 10, 6) + }) + + it('local origin maps to the object center', () => { + expect(localToWorld({ x: 0, y: 0 }, center, 37)).toEqual(center) + }) +}) + +describe('clampScale', () => { + it('bounds the value', () => { + expect(clampScale(0.001, 0.05, 20)).toBe(0.05) + expect(clampScale(100, 0.05, 20)).toBe(20) + expect(clampScale(3, 0.05, 20)).toBe(3) + }) +}) + +describe('zoomViewportAt', () => { + it('keeps the point under the cursor stationary', () => { + const cursor = { x: 640, y: 360 } + const before = screenToWorld(cursor, vp) + const next = zoomViewportAt(vp, cursor, vp.scale * 1.25, 0.05, 20) + const after = screenToWorld(cursor, next) + expect(after.x).toBeCloseTo(before.x, 6) + expect(after.y).toBeCloseTo(before.y, 6) + expect(next.scale).toBeCloseTo(2.5, 9) + }) + + it('clamps the scale', () => { + expect(zoomViewportAt(vp, { x: 0, y: 0 }, 1000, 0.05, 20).scale).toBe(20) + }) +}) + +describe('zoomToFitRect', () => { + it('centers the rect and fits it within padding', () => { + const viewport = { w: 800, h: 600 } + const rect = { x: 0, y: 0, w: 1000, h: 1000 } + const fit = zoomToFitRect(rect, viewport, 20, 0.05, 20) + // Limiting dimension is height: (600-40)/1000 = 0.56. + expect(fit.scale).toBeCloseTo(0.56, 9) + // The rect's center (500,500) should map to the viewport center (400,300). + const c = worldToScreen({ x: 500, y: 500 }, fit) + expect(c.x).toBeCloseTo(400, 6) + expect(c.y).toBeCloseTo(300, 6) + }) + + it('respects the min/max scale clamp', () => { + const fit = zoomToFitRect({ x: 0, y: 0, w: 1, h: 1 }, { w: 800, h: 600 }, 0, 0.05, 20) + expect(fit.scale).toBe(20) + }) +}) diff --git a/web/src/lib/geometry.ts b/web/src/lib/geometry.ts new file mode 100644 index 0000000..0dfddda --- /dev/null +++ b/web/src/lib/geometry.ts @@ -0,0 +1,128 @@ +// Pure geometry for the editor. Everything is centimeters in "world" space +// (garden origin top-left, x→right, y→down) except the viewport transform, which +// maps world → screen pixels. Kept dependency-free and unit-tested; the canvas +// and gesture code build on top. + +export interface Point { + x: number + y: number +} + +export interface Size { + w: number + h: number +} + +/** Axis-aligned rectangle by top-left corner + size, in world cm. */ +export interface Rect { + x: number + y: number + w: number + h: number +} + +/** The viewport: world→screen is `screen = world * scale + t`. scale is px/cm. */ +export interface Viewport { + tx: number + ty: number + scale: number +} + +export function worldToScreen(p: Point, vp: Viewport): Point { + return { x: p.x * vp.scale + vp.tx, y: p.y * vp.scale + vp.ty } +} + +export function screenToWorld(p: Point, vp: Viewport): Point { + return { x: (p.x - vp.tx) / vp.scale, y: (p.y - vp.ty) / vp.scale } +} + +/** + * A point in an object's local frame (origin at the object's center, axes + * aligned to the unrotated object) → garden world coordinates. rotationDeg is + * clockwise (screen y grows downward, so a positive angle rotates clockwise on + * screen). Plops live in this local frame, so moving/rotating the parent object + * moves them for free. + */ +export function localToWorld(local: Point, center: Point, rotationDeg: number): Point { + const r = (rotationDeg * Math.PI) / 180 + const cos = Math.cos(r) + const sin = Math.sin(r) + return { + x: center.x + local.x * cos - local.y * sin, + y: center.y + local.x * sin + local.y * cos, + } +} + +/** Inverse of localToWorld: garden world point → the object's local frame. */ +export function worldToLocal(world: Point, center: Point, rotationDeg: number): Point { + const r = (rotationDeg * Math.PI) / 180 + const cos = Math.cos(r) + const sin = Math.sin(r) + const dx = world.x - center.x + const dy = world.y - center.y + return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos } +} + +/** Clamp a scale (px/cm) into [min, max]. */ +export function clampScale(scale: number, min: number, max: number): number { + return Math.min(max, Math.max(min, scale)) +} + +/** Linear interpolation. */ +export function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +/** Cubic ease-in-out for viewport tweens. */ +export function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2 +} + +/** + * Re-scale the viewport while keeping the world point currently under + * `screenPoint` stationary — the math behind wheel-zoom-to-cursor and pinch. The + * new scale is clamped to [minScale, maxScale]. + */ +export function zoomViewportAt( + vp: Viewport, + screenPoint: Point, + nextScale: number, + minScale: number, + maxScale: number, +): Viewport { + const scale = clampScale(nextScale, minScale, maxScale) + const world = screenToWorld(screenPoint, vp) + return { + scale, + tx: screenPoint.x - world.x * scale, + ty: screenPoint.y - world.y * scale, + } +} + +/** + * The viewport that frames `rect` centered in a canvas of `canvasSize` pixels, + * with `padding` px of margin on every side. Scale is clamped to [minScale, + * maxScale] so a tiny/huge rect still lands in a sane zoom. Rect dimensions are + * taken by magnitude, so a degenerate (0/negative) size can't corrupt the math. + */ +export function zoomToFitRect( + rect: Rect, + canvasSize: Size, + padding: number, + minScale: number, + maxScale: number, +): Viewport { + const availW = Math.max(1, canvasSize.w - padding * 2) + const availH = Math.max(1, canvasSize.h - padding * 2) + const rectW = Math.max(Math.abs(rect.w), 1e-6) + const rectH = Math.max(Math.abs(rect.h), 1e-6) + const scale = clampScale(Math.min(availW / rectW, availH / rectH), minScale, maxScale) + + const centerX = rect.x + rect.w / 2 + const centerY = rect.y + rect.h / 2 + return { + scale, + tx: canvasSize.w / 2 - centerX * scale, + ty: canvasSize.h / 2 - centerY * scale, + } +} diff --git a/web/src/lib/units.test.ts b/web/src/lib/units.test.ts new file mode 100644 index 0000000..e72bd35 --- /dev/null +++ b/web/src/lib/units.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { cmFromFtIn, cmFromMeters, displayFromCm, formatCm, formatDimensions } from './units' + +describe('cm conversions', () => { + it('feet+inches → whole cm', () => { + expect(cmFromFtIn(4)).toBe(122) // 4ft = 121.92 → 122 + expect(cmFromFtIn(8)).toBe(244) + expect(cmFromFtIn(4, 6)).toBe(137) // 4'6" = 137.16 → 137 + }) + + it('meters → whole cm', () => { + expect(cmFromMeters(10)).toBe(1000) + expect(cmFromMeters(1.22)).toBe(122) + }) +}) + +describe('displayFromCm', () => { + it('metric keeps cm precision', () => { + expect(displayFromCm(1000, 'metric')).toBe(10) + expect(displayFromCm(122, 'metric')).toBe(1.22) + }) + + it('imperial rounds to 0.1 ft so integers stay clean', () => { + expect(displayFromCm(122, 'imperial')).toBe(4) // not 4.0026 + expect(displayFromCm(244, 'imperial')).toBe(8) // not 8.01 + }) +}) + +describe('formatCm', () => { + it('metric shows meters', () => { + expect(formatCm(122, 'metric')).toBe('1.22 m') + expect(formatCm(1000, 'metric')).toBe('10 m') + }) + + it('imperial shows feet and inches, carrying 12″ up', () => { + expect(formatCm(122, 'imperial')).toBe('4′ 0″') + expect(formatCm(244, 'imperial')).toBe('8′ 0″') + expect(formatCm(137, 'imperial')).toBe('4′ 6″') + }) + + it('formats width × height', () => { + expect(formatDimensions(122, 244, 'imperial')).toBe('4′ 0″ × 8′ 0″') + }) +}) diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index e2aabec..5f80e3b 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -1,13 +1,31 @@ -import { getRouteApi } from '@tanstack/react-router' -import { PageStub } from '@/components/PageStub' +import { GardenCanvas } from '@/editor/GardenCanvas' +import type { EditorGarden, EditorObject } from '@/editor/types' -const routeApi = getRouteApi('/gardens/$gardenId') +// Mock scene for the canvas foundation (#9). #11 replaces this with the garden's +// real /full payload keyed on the route's gardenId. +const mockGarden: EditorGarden = { id: 0, name: 'Demo garden', widthCm: 1200, heightCm: 800, unitPref: 'metric' } + +const mockObjects: EditorObject[] = [ + { id: 1, kind: 'bed', name: 'Bed A', shape: 'rect', xCm: 300, yCm: 250, widthCm: 400, heightCm: 120, rotationDeg: 0, zIndex: 1, plantable: true }, + { id: 2, kind: 'bed', name: 'Bed B', shape: 'rect', xCm: 300, yCm: 450, widthCm: 400, heightCm: 120, rotationDeg: 0, zIndex: 1, plantable: true }, + { id: 3, kind: 'container', name: 'Pot', shape: 'circle', xCm: 800, yCm: 250, widthCm: 120, heightCm: 120, rotationDeg: 0, zIndex: 2, plantable: true }, + { id: 4, kind: 'path', name: '', shape: 'rect', xCm: 600, yCm: 400, widthCm: 60, heightCm: 720, rotationDeg: 0, zIndex: 0, plantable: false }, + { id: 5, kind: 'tree', name: 'Apple', shape: 'circle', xCm: 980, yCm: 620, widthCm: 200, heightCm: 200, rotationDeg: 0, zIndex: 2, plantable: false }, + { id: 6, kind: 'bed', name: 'Angled', shape: 'rect', xCm: 920, yCm: 480, widthCm: 260, heightCm: 100, rotationDeg: 30, zIndex: 1, plantable: true }, +] export function GardenEditorPage() { - const { gardenId } = routeApi.useParams() return ( - - The field editor (canvas, palette, plops) arrives across issues #9–#15. - +
+
+

Editor preview

+ + Mock garden — drag to pan, wheel/pinch to zoom, tap an object to select. Real data + editing land in #11. + +
+
+ +
+
) }