diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..1cabea6 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,51 @@ +# Third-Party Notices + +This product bundles third-party software. Their licenses and copyright +notices are reproduced below as required. + +## force-graph + +Used client-side in the "Obsidian Wrapped" feature to render the vault graph +poster on an off-screen canvas. The library and its node names never leave the +browser. + +- Version: 1.51.4 +- License: MIT +- Copyright (c) 2018 Vasco Asturiano +- https://github.com/vasturiano/force-graph + +``` +MIT License + +Copyright (c) 2018 Vasco Asturiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +force-graph depends on the d3-force family (ISC) by Mike Bostock for its +force simulation, bundled transitively. + +## next/og (Satori + resvg) + +Server-side share-card PNG rendering (`ImageResponse`) uses Vercel's `next/og`, +which bundles `satori` and `@resvg/resvg-wasm` (both MPL-2.0). These are part of +the `next` dependency and distributed under Next.js's own license terms. + +- satori — MPL-2.0 — https://github.com/vercel/satori +- resvg-wasm — MPL-2.0 — https://github.com/yisibl/resvg-js diff --git a/package-lock.json b/package-lock.json index ef7abdd..502339f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "disposable-email-domains": "^1.0.62", + "force-graph": "^1.51.4", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", "lucide-react": "^1.7.0", @@ -4522,6 +4523,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -5245,6 +5252,15 @@ "win32" ] }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5730,6 +5746,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -5916,6 +5942,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6094,6 +6132,222 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7283,6 +7537,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7299,6 +7567,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -7777,6 +8071,15 @@ "node": ">=0.8.19" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7792,6 +8095,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8369,6 +8681,18 @@ "node": ">=4.0" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8735,6 +9059,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10118,6 +10448,16 @@ "node": ">=0.10.0" } }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11362,6 +11702,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", diff --git a/package.json b/package.json index 2fe6695..d8b028a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "disposable-email-domains": "^1.0.62", + "force-graph": "^1.51.4", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", "lucide-react": "^1.7.0", diff --git a/src/app/(student)/wrapped/WrappedClient.tsx b/src/app/(student)/wrapped/WrappedClient.tsx index 43bcd36..1ff64ca 100644 --- a/src/app/(student)/wrapped/WrappedClient.tsx +++ b/src/app/(student)/wrapped/WrappedClient.tsx @@ -5,6 +5,7 @@ import type { Stats, RawNote, Period, RunPayload } from "./_client/types"; import { supportsFSA, readViaFSA, readViaInput, readViaDrop } from "./_client/reader"; type Screen = "intro" | "loading" | "result" | "error"; +type View = "card" | "graph"; const ARCHETYPE_RU: Record = { architect: "🏛 Архитектор", @@ -21,14 +22,49 @@ export function WrappedClient({ userName }: { userName: string }) { const [error, setError] = useState(""); const [shareUrl, setShareUrl] = useState(""); const [sharing, setSharing] = useState(false); + const [view, setView] = useState("card"); + const [graphUrl, setGraphUrl] = useState(""); + const [graphLabels, setGraphLabels] = useState(false); + const [graphBusy, setGraphBusy] = useState(false); const inputRef = useRef(null); const notesRef = useRef([]); + // Сбросить отрисованный граф (при новых данных / смене периода). + const resetGraph = useCallback(() => { + setView("card"); + setGraphUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return ""; + }); + }, []); + + // Отрисовать граф-постер в офф-скрин Canvas → blob URL. force-graph грузим лениво. + const renderGraph = useCallback( + async (labels: boolean) => { + if (!stats || !stats.graph.nodes.length) return; + setGraphBusy(true); + setError(""); + try { + const { renderGraphPoster } = await import("./_client/graphPoster"); + const blob = await renderGraphPoster(stats.graph, labels); + setGraphUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(blob); + }); + } catch { + setError("Не удалось построить граф"); + } + setGraphBusy(false); + }, + [stats], + ); + const runWorker = useCallback((notes: RawNote[], p: Period) => { setScreen("loading"); const worker = new Worker(new URL("./_client/parse.worker.ts", import.meta.url), { type: "module" }); worker.onmessage = (e: MessageEvent) => { setStats(e.data); + resetGraph(); setScreen("result"); worker.terminate(); }; @@ -38,7 +74,7 @@ export function WrappedClient({ userName }: { userName: string }) { worker.terminate(); }; worker.postMessage({ notes, period: p }); - }, []); + }, [resetGraph]); const handleNotes = useCallback( (notes: RawNote[]) => { @@ -174,7 +210,7 @@ export function WrappedClient({ userName }: { userName: string }) { {screen === "result" && stats && (
{/* переключатель периода */} -
+
{(["year", "quarter", "all"] as Period[]).map((p) => ( + ))} +
+ {/* граф-постер хранилища */} + {view === "graph" && ( +
+
+

Граф твоего хранилища

+ +
+

+ {stats.graph.nodes.length} самых связанных заметок из твоего хранилища. Имена остаются в браузере. +

+
+ {graphBusy ? ( +

Строю граф…

+ ) : graphUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Граф хранилища + ) : stats.graph.nodes.length === 0 ? ( +

+ Слишком мало связей между заметками, чтобы построить граф. +

+ ) : null} +
+ {graphUrl && ( + + Скачать граф (PNG) + + )} +
+ )} + {/* DOM-карточка результата (ДС-2) */} + {view === "card" && (

Ты — {ARCHETYPE_RU[stats.archetype]}

@@ -226,13 +321,14 @@ export function WrappedClient({ userName }: { userName: string }) {

)}
+ )} {/* действия */}
-
diff --git a/src/app/(student)/wrapped/_client/graphPoster.ts b/src/app/(student)/wrapped/_client/graphPoster.ts new file mode 100644 index 0000000..300fa6d --- /dev/null +++ b/src/app/(student)/wrapped/_client/graphPoster.ts @@ -0,0 +1,69 @@ +// Граф-постер: force-graph (Canvas) в офф-скрин контейнере → PNG. ДС-2-окрас. +// 100% client-side — имена узлов остаются в браузере, на сервер не уходят. + +import ForceGraph from "force-graph"; +import type { GraphData } from "./types"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const SIZE = 1080; +const BG = "#F5F5F0"; +const NODE = "#323232"; +const LINK = "#AAAAAA"; + +export async function renderGraphPoster(graph: GraphData, labels: boolean): Promise { + const el = document.createElement("div"); + el.style.cssText = "position:fixed;left:-99999px;top:0;width:1080px;height:1080px;"; + document.body.appendChild(el); + + try { + const fg = (ForceGraph as any)()(el) + .width(SIZE) + .height(SIZE) + .backgroundColor(BG) + .graphData({ + nodes: graph.nodes.map((n) => ({ ...n })), + links: graph.links.map((l) => ({ ...l })), + }) + .nodeId("id") + .nodeRelSize(labels ? 3 : 2.5) + .nodeVal((n: any) => 1 + Math.sqrt(n.deg ?? 1)) + .nodeColor(() => NODE) + .linkColor(() => LINK) + .linkWidth(0.4) + .enableNodeDrag(false) + .enablePointerInteraction(false) + .cooldownTime(3500); + + if (labels) { + fg.nodeCanvasObjectMode(() => "after").nodeCanvasObject((node: any, ctx: CanvasRenderingContext2D, scale: number) => { + if ((node.deg ?? 0) >= 6) { + const fontSize = 11 / scale; + ctx.font = `${fontSize}px "Fira Mono", monospace`; + ctx.fillStyle = NODE; + ctx.textBaseline = "middle"; + ctx.fillText(String(node.id), node.x + 2 + Math.sqrt(node.deg) / scale, node.y); + } + }); + } + + // дождаться остановки симуляции (или таймаут) + await new Promise((resolve) => { + let done = false; + const finish = () => { if (!done) { done = true; resolve(); } }; + fg.onEngineStop(finish); + setTimeout(finish, 5000); + }); + + fg.zoomToFit(0, 50); + await new Promise((r) => setTimeout(r, 400)); + + const canvas = el.querySelector("canvas") as HTMLCanvasElement | null; + if (!canvas) throw new Error("canvas not found"); + return await new Promise((resolve, reject) => + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png"), + ); + } finally { + document.body.removeChild(el); + } +} diff --git a/src/app/(student)/wrapped/_client/parse.worker.ts b/src/app/(student)/wrapped/_client/parse.worker.ts index b881df3..6881f1c 100644 --- a/src/app/(student)/wrapped/_client/parse.worker.ts +++ b/src/app/(student)/wrapped/_client/parse.worker.ts @@ -1,7 +1,7 @@ // Web Worker: парсит .md-тексты в агрегаты (off-main, без фриза UI). // Получает RawNote[], возвращает Stats. Тексты заметок дальше воркера не идут. -import type { RawNote, Stats, Period, Archetype } from "./types"; +import type { RawNote, Stats, Period, Archetype, GraphData } from "./types"; const WIKILINK = /\[\[([^\]|#]+)/g; const TAG = /(^|\s)#([\p{L}\d_/-]+)/gu; @@ -13,12 +13,22 @@ function filterByPeriod(notes: RawNote[], period: Period): RawNote[] { return notes.filter((n) => n.mtime >= cutoff); } +function buildGraph(degree: Map, edges: { source: string; target: string }[]): GraphData { + const TOP = 150; + const top = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP); + const nodes = top.map(([id, deg]) => ({ id, deg })); + const ids = new Set(nodes.map((n) => n.id)); + const links = edges.filter((e) => ids.has(e.source) && ids.has(e.target)).slice(0, 600); + return { nodes, links }; +} + function buildStats( notes: RawNote[], degree: Map, tags: Map, linkCount: number, period: Period, + edges: { source: string; target: string }[], ): Stats { const noteCount = notes.length; const topTags = [...tags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, n]) => ({ tag, n })); @@ -57,7 +67,7 @@ function buildStats( if (linkDensity >= 3) achievements.push("Плотная сеть знаний"); if (orphanRatio < 0.1 && noteCount >= 50) achievements.push("Почти нет сирот"); - return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period }; + return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period, graph: buildGraph(degree, edges) }; } self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => { @@ -66,16 +76,21 @@ self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => { const degree = new Map(); const tags = new Map(); let linkCount = 0; + const edges: { source: string; target: string }[] = []; for (const n of filtered) { + const src = n.name.replace(/\.md$/i, ""); for (const m of n.text.matchAll(WIKILINK)) { linkCount++; const t = m[1].trim(); - if (t) degree.set(t, (degree.get(t) ?? 0) + 1); + if (t) { + degree.set(t, (degree.get(t) ?? 0) + 1); + edges.push({ source: src, target: t }); + } } for (const m of n.text.matchAll(TAG)) { const t = m[2]; tags.set(t, (tags.get(t) ?? 0) + 1); } } - self.postMessage(buildStats(filtered, degree, tags, linkCount, period)); + self.postMessage(buildStats(filtered, degree, tags, linkCount, period, edges)); }; diff --git a/src/app/(student)/wrapped/_client/types.ts b/src/app/(student)/wrapped/_client/types.ts index c5e18ee..f1925d1 100644 --- a/src/app/(student)/wrapped/_client/types.ts +++ b/src/app/(student)/wrapped/_client/types.ts @@ -5,6 +5,12 @@ export type Archetype = "architect" | "gardener" | "librarian" | "pragmatist"; export type Period = "year" | "quarter" | "all"; +// Граф для постера (топ-N узлов по степени). Имена живут только в браузере. +export interface GraphData { + nodes: { id: string; deg: number }[]; + links: { source: string; target: string }[]; +} + export interface Stats { noteCount: number; linkCount: number; @@ -17,6 +23,7 @@ export interface Stats { archetype: Archetype; score: number; // 0..100 period: Period; + graph: GraphData; // топ-N узлов/рёбер для граф-постера (не уходит в БД) } // Сырьё — НИКОГДА не покидает браузер.